Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu como navegación primaria), sin bubbles permanentes en compact (solo endpoint), Performance a barras horizontales con >6 categorías, línea con gradiente y estética de widget, pinch anclado al centro + doble-tap reset + haptics. Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush; minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con haptic); drill-down por tap en Allocation/Performance → Evolution filtrada (premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas (premium); insight contextual del rango visible (premium). L10n ×7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
This commit is contained in:
@@ -645,3 +645,11 @@
|
||||
"intent_streak" = "Du hast eine Serie von %d Monaten.";
|
||||
"intent_streak_with_date" = "Du hast eine Serie von %d Monaten. Dein letzter Check-in war am %@.";
|
||||
"intent_streak_none" = "Du hast noch keine aktive Serie. Erfasse einen Wert, um zu starten.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Benutzerdefinierter Zeitraum";
|
||||
"chart_range_from" = "Von";
|
||||
"chart_range_to" = "Bis";
|
||||
"chart_filtered_prefix" = "Gefiltert: %@";
|
||||
"chart_overlay_contributions" = "vs. Einzahlungen";
|
||||
"chart_insight_best_month" = "Bester Monat im Zeitraum: %1$@ (%2$@)";
|
||||
|
||||
@@ -646,3 +646,11 @@
|
||||
"intent_streak" = "You're on a %d-month streak.";
|
||||
"intent_streak_with_date" = "You're on a %d-month streak. Your last check-in was %@.";
|
||||
"intent_streak_none" = "You don't have an active streak yet. Log a value to start one.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Custom range";
|
||||
"chart_range_from" = "From";
|
||||
"chart_range_to" = "To";
|
||||
"chart_filtered_prefix" = "Filtered: %@";
|
||||
"chart_overlay_contributions" = "vs Contributions";
|
||||
"chart_insight_best_month" = "Best month in range: %1$@ (%2$@)";
|
||||
|
||||
@@ -597,3 +597,11 @@
|
||||
"intent_streak" = "Llevas una racha de %d meses.";
|
||||
"intent_streak_with_date" = "Llevas una racha de %d meses. Tu último check-in fue el %@.";
|
||||
"intent_streak_none" = "Aún no tienes una racha activa. Registra un valor para empezar una.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Rango personalizado";
|
||||
"chart_range_from" = "Desde";
|
||||
"chart_range_to" = "Hasta";
|
||||
"chart_filtered_prefix" = "Filtrado: %@";
|
||||
"chart_overlay_contributions" = "vs Aportaciones";
|
||||
"chart_insight_best_month" = "Mejor mes del rango: %1$@ (%2$@)";
|
||||
|
||||
@@ -645,3 +645,11 @@
|
||||
"intent_streak" = "Tu es sur une série de %d mois.";
|
||||
"intent_streak_with_date" = "Tu es sur une série de %d mois. Ton dernier relevé date du %@.";
|
||||
"intent_streak_none" = "Tu n'as pas encore de série active. Enregistre une valeur pour en commencer une.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Période personnalisée";
|
||||
"chart_range_from" = "De";
|
||||
"chart_range_to" = "À";
|
||||
"chart_filtered_prefix" = "Filtré : %@";
|
||||
"chart_overlay_contributions" = "vs Versements";
|
||||
"chart_insight_best_month" = "Meilleur mois de la période : %1$@ (%2$@)";
|
||||
|
||||
@@ -645,3 +645,11 @@
|
||||
"intent_streak" = "Sei a una serie di %d mesi.";
|
||||
"intent_streak_with_date" = "Sei a una serie di %d mesi. Il tuo ultimo check-in è stato il %@.";
|
||||
"intent_streak_none" = "Non hai ancora una serie attiva. Registra un valore per iniziarne una.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Intervallo personalizzato";
|
||||
"chart_range_from" = "Da";
|
||||
"chart_range_to" = "A";
|
||||
"chart_filtered_prefix" = "Filtrato: %@";
|
||||
"chart_overlay_contributions" = "vs Versamenti";
|
||||
"chart_insight_best_month" = "Miglior mese del periodo: %1$@ (%2$@)";
|
||||
|
||||
@@ -645,3 +645,11 @@
|
||||
"intent_streak" = "%dか月連続で記録中です。";
|
||||
"intent_streak_with_date" = "%dか月連続で記録中です。最後のチェックインは%@でした。";
|
||||
"intent_streak_none" = "まだ連続記録がありません。値を記録して始めましょう。";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "カスタム期間";
|
||||
"chart_range_from" = "開始";
|
||||
"chart_range_to" = "終了";
|
||||
"chart_filtered_prefix" = "フィルター: %@";
|
||||
"chart_overlay_contributions" = "拠出額と比較";
|
||||
"chart_insight_best_month" = "期間内のベスト月: %1$@(%2$@)";
|
||||
|
||||
@@ -645,3 +645,11 @@
|
||||
"intent_streak" = "Você está em uma sequência de %d meses.";
|
||||
"intent_streak_with_date" = "Você está em uma sequência de %d meses. Seu último check-in foi em %@.";
|
||||
"intent_streak_none" = "Você ainda não tem uma sequência ativa. Registre um valor para começar uma.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Período personalizado";
|
||||
"chart_range_from" = "De";
|
||||
"chart_range_to" = "Até";
|
||||
"chart_filtered_prefix" = "Filtrado: %@";
|
||||
"chart_overlay_contributions" = "vs Aportes";
|
||||
"chart_insight_best_month" = "Melhor mês do período: %1$@ (%2$@)";
|
||||
|
||||
@@ -512,6 +512,8 @@ class ChartsViewModel: ObservableObject {
|
||||
selectedCategory: selectedCategory
|
||||
)
|
||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||
// Also needed by the Evolution "vs Contributions" overlay (premium).
|
||||
calculateContributionsData(from: completedSnapshots)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||
calculateAllocationEvolutionData(from: completedSnapshots)
|
||||
|
||||
@@ -5,6 +5,9 @@ struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
var title: String = "Asset Allocation"
|
||||
var showsTargetsComparison: Bool = true
|
||||
/// Drill-down: tapping the chevron on a legend row jumps to Evolution
|
||||
/// filtered to that category/source.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@@ -38,34 +41,47 @@ struct AllocationPieChart: View {
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(data, id: \.category) { item in
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 11, height: 11)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 11, height: 11)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -24,8 +24,14 @@ enum ChartLabels {
|
||||
|
||||
/// Whether point `index` should carry a permanent label: always the first
|
||||
/// and last (the endpoints matter most), plus every `stride`-th in between.
|
||||
/// On iPhone (compact) permanent labels collide with the axis on the narrow
|
||||
/// screen, so only the endpoint keeps its label — exact values live in the
|
||||
/// scrub header instead.
|
||||
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
|
||||
guard count > 0 else { return false }
|
||||
if compact {
|
||||
return count <= 6 ? true : index == count - 1
|
||||
}
|
||||
if index == 0 || index == count - 1 { return true }
|
||||
let s = stride(count: count, compact: compact)
|
||||
// Avoid a label landing right next to the forced last one.
|
||||
|
||||
@@ -1,149 +1,499 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import UIKit
|
||||
|
||||
// MARK: - Time-series chart zoom
|
||||
// MARK: - Time-series chart zoom (window model)
|
||||
//
|
||||
// Shared zoom behavior for every chart with a Date X axis. Default state is
|
||||
// "fit all" (no behavior change); zooming in narrows the visible X domain and
|
||||
// enables horizontal scrolling so the user can pan through history. Zoom is
|
||||
// driven by pinch (magnification) and by explicit +/- buttons for
|
||||
// discoverability and accessibility (HIG: never make gestures the only way).
|
||||
// Shared behavior for every chart with a Date X axis. The state is a visible
|
||||
// WINDOW (start + span) instead of a bare span: pinch, native horizontal pan
|
||||
// and the range brush all manipulate the same window, so there are no modes.
|
||||
//
|
||||
// - Pinch: zooms anchored on the window center, and reveals the brush.
|
||||
// - Pan: native chart scrolling (chartScrollPosition) once zoomed.
|
||||
// - Double tap: reset to fit-all.
|
||||
// - Brush: full-history sparkline with month-snapping handles; the label opens
|
||||
// precise month/year pickers (HIG: gestures are never the only way).
|
||||
|
||||
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
|
||||
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
|
||||
struct ChartZoomModel {
|
||||
/// Currently visible X-domain length in seconds; nil = show everything.
|
||||
var visibleSpan: TimeInterval?
|
||||
/// Span captured when a pinch gesture begins.
|
||||
var pinchAnchor: TimeInterval?
|
||||
|
||||
init() {
|
||||
visibleSpan = nil
|
||||
pinchAnchor = nil
|
||||
}
|
||||
/// Visible X-domain length in seconds; nil = show everything.
|
||||
var spanSeconds: TimeInterval?
|
||||
/// Start of the visible window (chart scroll position).
|
||||
var startX: Date = .distantPast
|
||||
/// Whether the range brush is expanded under the chart.
|
||||
var brushVisible = false
|
||||
/// (span, center) captured when a pinch begins.
|
||||
var pinchAnchor: (span: TimeInterval, center: Date)?
|
||||
|
||||
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
|
||||
private static let zoomStep = 0.6 // per button tap
|
||||
/// Padding after the last point so it isn't clipped at the window edge.
|
||||
static let endPadding: TimeInterval = 60 * 60 * 24 * 15 // half month
|
||||
|
||||
init() {}
|
||||
|
||||
var isZoomed: Bool { spanSeconds != nil }
|
||||
|
||||
/// The currently visible window, given the full data domain.
|
||||
func visibleWindow(dates: [Date]) -> ClosedRange<Date>? {
|
||||
guard let first = dates.first, let last = dates.last else { return nil }
|
||||
guard let span = spanSeconds else { return first...last }
|
||||
let start = max(first, min(startX, last))
|
||||
let end = min(last, start.addingTimeInterval(span))
|
||||
return start <= end ? start...end : first...last
|
||||
}
|
||||
|
||||
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
|
||||
let upper = max(fullSpan, Self.minSpan)
|
||||
let upper = max(fullSpan + Self.endPadding, Self.minSpan)
|
||||
let clamped = min(max(span, Self.minSpan), upper)
|
||||
// Snap back to fit-all when zoomed (almost) fully out.
|
||||
return clamped >= upper * 0.98 ? nil : clamped
|
||||
}
|
||||
|
||||
mutating func zoomIn(fullSpan: TimeInterval) {
|
||||
let current = visibleSpan ?? fullSpan
|
||||
visibleSpan = clamped(current * Self.zoomStep, fullSpan: fullSpan)
|
||||
}
|
||||
|
||||
mutating func zoomOut(fullSpan: TimeInterval) {
|
||||
guard let current = visibleSpan else { return }
|
||||
visibleSpan = clamped(current / Self.zoomStep, fullSpan: fullSpan)
|
||||
/// Sets the window to [dates[i], dates[j]]; fit-all when it covers everything.
|
||||
mutating func setWindow(from i: Int, to j: Int, dates: [Date]) {
|
||||
guard !dates.isEmpty else { return }
|
||||
let lo = max(0, min(i, dates.count - 1))
|
||||
let hi = max(lo, min(j, dates.count - 1))
|
||||
if lo == 0 && hi == dates.count - 1 {
|
||||
spanSeconds = nil
|
||||
return
|
||||
}
|
||||
startX = dates[lo]
|
||||
spanSeconds = dates[hi].timeIntervalSince(dates[lo]) + Self.endPadding
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
visibleSpan = nil
|
||||
spanSeconds = nil
|
||||
pinchAnchor = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
enum ChartHaptics {
|
||||
private static let selection = UISelectionFeedbackGenerator()
|
||||
private static let impact = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
static func tick() { selection.selectionChanged() }
|
||||
static func bump() { impact.impactOccurred() }
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Applies zoomable behavior to a Chart with a Date X axis.
|
||||
/// Applies zoomable window behavior to a Chart with a Date X axis.
|
||||
/// - Parameters:
|
||||
/// - dates: the full set of X values (used to compute the total span)
|
||||
/// - zoom: binding to the chart's zoom state
|
||||
/// - dates: the full set of X values (sorted, typically monthly)
|
||||
/// - values: optional Y values (same count) — enables the range brush
|
||||
/// - zoom: binding to the chart's window state
|
||||
/// - onExpandRange: called when the brush is revealed, so the owner can
|
||||
/// widen the loaded data range (e.g. switch the period to All)
|
||||
@ViewBuilder
|
||||
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
|
||||
let fullSpan = ChartZoomHelper.fullSpan(of: dates)
|
||||
self
|
||||
.modifier(ZoomDomainModifier(zoom: zoom, fullSpan: fullSpan))
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
var model = zoom.wrappedValue
|
||||
if model.pinchAnchor == nil {
|
||||
model.pinchAnchor = model.visibleSpan ?? fullSpan
|
||||
}
|
||||
if let anchor = model.pinchAnchor, value.magnification > 0 {
|
||||
model.visibleSpan = model.clamped(anchor / value.magnification, fullSpan: fullSpan)
|
||||
}
|
||||
zoom.wrappedValue = model
|
||||
}
|
||||
.onEnded { _ in
|
||||
zoom.wrappedValue.pinchAnchor = nil
|
||||
}
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
ChartZoomControls(zoom: zoom, fullSpan: fullSpan)
|
||||
}
|
||||
func zoomableTimeSeries(
|
||||
dates: [Date],
|
||||
values: [Double]? = nil,
|
||||
zoom: Binding<ChartZoomModel>,
|
||||
onExpandRange: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
modifier(ZoomableTimeSeriesModifier(
|
||||
dates: dates, values: values, zoom: zoom, onExpandRange: onExpandRange
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
enum ChartZoomHelper {
|
||||
static func fullSpan(of dates: [Date]) -> TimeInterval {
|
||||
private struct ZoomableTimeSeriesModifier: ViewModifier {
|
||||
let dates: [Date]
|
||||
let values: [Double]?
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let onExpandRange: (() -> Void)?
|
||||
|
||||
private var fullSpan: TimeInterval {
|
||||
guard let min = dates.min(), let max = dates.max(), max > min else {
|
||||
return ChartZoomModel.minSpan
|
||||
}
|
||||
return max.timeIntervalSince(min)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ZoomDomainModifier: ViewModifier {
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let fullSpan: TimeInterval
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let span = zoom.visibleSpan {
|
||||
content
|
||||
.chartScrollableAxes(.horizontal)
|
||||
.chartXVisibleDomain(length: span)
|
||||
} else {
|
||||
content
|
||||
VStack(spacing: 10) {
|
||||
chartWithGestures(content)
|
||||
if zoom.brushVisible, let values, dates.count >= 2 {
|
||||
ChartRangeBrush(dates: dates, values: values, zoom: $zoom)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact +/- (and reset when zoomed) control cluster shown on top of the chart.
|
||||
struct ChartZoomControls: View {
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let fullSpan: TimeInterval
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
controlButton(systemName: "minus.magnifyingglass", enabled: zoom.visibleSpan != nil) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomOut(fullSpan: fullSpan) }
|
||||
@ViewBuilder
|
||||
private func chartWithGestures(_ content: Content) -> some View {
|
||||
Group {
|
||||
if let span = zoom.spanSeconds {
|
||||
content
|
||||
.chartScrollableAxes(.horizontal)
|
||||
.chartXVisibleDomain(length: span)
|
||||
.chartScrollPosition(x: $zoom.startX)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_out"))
|
||||
|
||||
Divider().frame(height: 16)
|
||||
|
||||
controlButton(systemName: "plus.magnifyingglass",
|
||||
enabled: (zoom.visibleSpan ?? fullSpan) > ChartZoomModel.minSpan) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomIn(fullSpan: fullSpan) }
|
||||
}
|
||||
.simultaneousGesture(pinchGesture)
|
||||
.simultaneousGesture(doubleTapGesture)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if values != nil {
|
||||
brushToggle
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_in"))
|
||||
}
|
||||
}
|
||||
|
||||
if zoom.visibleSpan != nil {
|
||||
Divider().frame(height: 16)
|
||||
controlButton(systemName: "arrow.counterclockwise", enabled: true) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.reset() }
|
||||
// Pinch anchored on the window center: the point you're looking at stays put.
|
||||
private var pinchGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
var model = zoom
|
||||
if model.pinchAnchor == nil {
|
||||
let window = model.visibleWindow(dates: dates)
|
||||
let span = model.spanSeconds ?? (fullSpan + ChartZoomModel.endPadding)
|
||||
let center = window.map {
|
||||
$0.lowerBound.addingTimeInterval($0.upperBound.timeIntervalSince($0.lowerBound) / 2)
|
||||
} ?? Date()
|
||||
model.pinchAnchor = (span, center)
|
||||
}
|
||||
guard let anchor = model.pinchAnchor, value.magnification > 0 else { return }
|
||||
let newSpan = model.clamped(anchor.span / value.magnification, fullSpan: fullSpan)
|
||||
model.spanSeconds = newSpan
|
||||
if let newSpan, let first = dates.first, let last = dates.last {
|
||||
let start = anchor.center.addingTimeInterval(-newSpan / 2)
|
||||
let maxStart = last.addingTimeInterval(-newSpan + ChartZoomModel.endPadding)
|
||||
model.startX = max(first, min(start, maxStart))
|
||||
}
|
||||
zoom = model
|
||||
}
|
||||
.onEnded { _ in
|
||||
zoom.pinchAnchor = nil
|
||||
// Zooming in reveals the brush so the window is visible & editable.
|
||||
if zoom.isZoomed && !zoom.brushVisible {
|
||||
withAnimation(.snappy) { zoom.brushVisible = true }
|
||||
ChartHaptics.bump()
|
||||
onExpandRange?()
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_reset"))
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
private func controlButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
private var doubleTapGesture: some Gesture {
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
guard zoom.isZoomed else { return }
|
||||
withAnimation(.snappy) { zoom.reset() }
|
||||
ChartHaptics.bump()
|
||||
}
|
||||
}
|
||||
|
||||
/// Small pill that expands/collapses the range brush — the discoverable,
|
||||
/// non-gesture entry point to custom ranges.
|
||||
private var brushToggle: some View {
|
||||
Button {
|
||||
withAnimation(.snappy) { zoom.brushVisible.toggle() }
|
||||
ChartHaptics.bump()
|
||||
if zoom.brushVisible { onExpandRange?() }
|
||||
} label: {
|
||||
Image(systemName: zoom.brushVisible
|
||||
? "arrow.down.right.and.arrow.up.left"
|
||||
: "calendar.badge.clock")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(enabled ? .appPrimary : .secondary.opacity(0.4))
|
||||
.frame(width: 32, height: 28)
|
||||
.foregroundColor(.appPrimary)
|
||||
.frame(width: 34, height: 28)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.padding(6)
|
||||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Range brush (full-history sparkline + month-snapping handles)
|
||||
|
||||
struct ChartRangeBrush: View {
|
||||
let dates: [Date]
|
||||
let values: [Double]
|
||||
@Binding var zoom: ChartZoomModel
|
||||
|
||||
@State private var dragStartIndices: (lo: Int, hi: Int)?
|
||||
@State private var lastTickedIndex: Int = -1
|
||||
@State private var showingPickers = false
|
||||
|
||||
private static let labelFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Current window as indices into `dates`.
|
||||
private var windowIndices: (lo: Int, hi: Int) {
|
||||
guard let window = zoom.visibleWindow(dates: dates) else { return (0, max(0, dates.count - 1)) }
|
||||
let lo = nearestIndex(to: window.lowerBound)
|
||||
let hi = nearestIndex(to: window.upperBound)
|
||||
return (min(lo, hi), max(lo, hi, min(lo + 1, dates.count - 1)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
rangeLabel
|
||||
GeometryReader { geo in
|
||||
brushBody(width: geo.size.width)
|
||||
}
|
||||
.frame(height: 44)
|
||||
}
|
||||
.sheet(isPresented: $showingPickers) {
|
||||
ChartRangePickerSheet(dates: dates, zoom: $zoom)
|
||||
.presentationDetents([.height(320)])
|
||||
}
|
||||
}
|
||||
|
||||
private var rangeLabel: some View {
|
||||
let (lo, hi) = windowIndices
|
||||
return Button {
|
||||
showingPickers = true
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Text("\(Self.labelFormatter.string(from: dates[lo])) – \(Self.labelFormatter.string(from: dates[hi]))")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func brushBody(width: CGFloat) -> some View {
|
||||
let (lo, hi) = windowIndices
|
||||
let xLo = xPosition(index: lo, width: width)
|
||||
let xHi = xPosition(index: hi, width: width)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
// Full-history sparkline (widget-style: gradient line, no axes)
|
||||
SparklinePath(values: values)
|
||||
.stroke(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
.opacity(0.85)
|
||||
|
||||
// Dimming outside the window
|
||||
Rectangle()
|
||||
.fill(Color(.systemBackground).opacity(0.72))
|
||||
.frame(width: max(0, xLo))
|
||||
Rectangle()
|
||||
.fill(Color(.systemBackground).opacity(0.72))
|
||||
.frame(width: max(0, width - xHi))
|
||||
.offset(x: xHi)
|
||||
|
||||
// Window frame
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6).fill(Color.appPrimary.opacity(0.06))
|
||||
)
|
||||
.frame(width: max(10, xHi - xLo))
|
||||
.offset(x: xLo)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(moveGesture(width: width))
|
||||
|
||||
// Handles
|
||||
handle(at: xLo)
|
||||
.gesture(handleGesture(isLower: true, width: width))
|
||||
handle(at: xHi)
|
||||
.gesture(handleGesture(isLower: false, width: width))
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(at x: CGFloat) -> some View {
|
||||
Capsule()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 5, height: 26)
|
||||
.overlay(
|
||||
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
|
||||
)
|
||||
.frame(width: 30, height: 44) // generous hit area
|
||||
.contentShape(Rectangle())
|
||||
.offset(x: x - 15)
|
||||
}
|
||||
|
||||
// MARK: Geometry helpers
|
||||
|
||||
private func xPosition(index: Int, width: CGFloat) -> CGFloat {
|
||||
guard dates.count > 1 else { return 0 }
|
||||
return width * CGFloat(index) / CGFloat(dates.count - 1)
|
||||
}
|
||||
|
||||
private func index(atX x: CGFloat, width: CGFloat) -> Int {
|
||||
guard dates.count > 1, width > 0 else { return 0 }
|
||||
let frac = max(0, min(1, x / width))
|
||||
return Int((frac * CGFloat(dates.count - 1)).rounded())
|
||||
}
|
||||
|
||||
private func nearestIndex(to date: Date) -> Int {
|
||||
var best = 0
|
||||
var bestDist = TimeInterval.greatestFiniteMagnitude
|
||||
for (i, d) in dates.enumerated() {
|
||||
let dist = abs(d.timeIntervalSince(date))
|
||||
if dist < bestDist { best = i; bestDist = dist }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// MARK: Gestures
|
||||
|
||||
private func handleGesture(isLower: Bool, width: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let (lo, hi) = dragStartIndices ?? windowIndices
|
||||
if dragStartIndices == nil { dragStartIndices = (lo, hi) }
|
||||
let i = index(atX: value.location.x, width: width)
|
||||
var newLo = lo
|
||||
var newHi = hi
|
||||
if isLower {
|
||||
newLo = min(i, hi - 1)
|
||||
} else {
|
||||
newHi = max(i, lo + 1)
|
||||
}
|
||||
let ticked = isLower ? newLo : newHi
|
||||
if ticked != lastTickedIndex {
|
||||
lastTickedIndex = ticked
|
||||
ChartHaptics.tick()
|
||||
}
|
||||
zoom.setWindow(from: newLo, to: newHi, dates: dates)
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragStartIndices = nil
|
||||
lastTickedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging the window itself slides it through history keeping its width —
|
||||
/// e.g. fix a 1-month window and replay the portfolio month by month.
|
||||
private func moveGesture(width: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let (lo, hi) = dragStartIndices ?? windowIndices
|
||||
if dragStartIndices == nil { dragStartIndices = (lo, hi) }
|
||||
let span = hi - lo
|
||||
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
|
||||
let newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
|
||||
if newLo != lastTickedIndex {
|
||||
lastTickedIndex = newLo
|
||||
ChartHaptics.tick()
|
||||
}
|
||||
zoom.setWindow(from: newLo, to: newLo + span, dates: dates)
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragStartIndices = nil
|
||||
lastTickedIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal normalized line path for the brush background.
|
||||
private struct SparklinePath: Shape {
|
||||
let values: [Double]
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
guard values.count > 1 else { return path }
|
||||
let minV = values.min() ?? 0
|
||||
let maxV = values.max() ?? 1
|
||||
let range = maxV - minV
|
||||
let inset: CGFloat = 4
|
||||
func point(_ i: Int) -> CGPoint {
|
||||
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
|
||||
let norm = range > 0 ? (values[i] - minV) / range : 0.5
|
||||
let y = inset + (rect.height - inset * 2) * (1 - CGFloat(norm))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
path.move(to: point(0))
|
||||
for i in 1..<values.count {
|
||||
path.addLine(to: point(i))
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Precise month/year pickers
|
||||
|
||||
/// Two wheel pickers over the months that actually have data — the precise,
|
||||
/// non-gesture way to land exactly on e.g. Feb–Mar 2025.
|
||||
struct ChartRangePickerSheet: View {
|
||||
let dates: [Date]
|
||||
@Binding var zoom: ChartZoomModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var fromIndex = 0
|
||||
@State private var toIndex = 0
|
||||
|
||||
private static let optionFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
HStack(spacing: 0) {
|
||||
picker(title: String(localized: "chart_range_from"), selection: $fromIndex)
|
||||
picker(title: String(localized: "chart_range_to"), selection: $toIndex)
|
||||
}
|
||||
.navigationTitle(String(localized: "chart_range_button"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("OK") {
|
||||
zoom.setWindow(from: fromIndex, to: toIndex, dates: dates)
|
||||
ChartHaptics.bump()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard let window = zoom.visibleWindow(dates: dates) else { return }
|
||||
fromIndex = nearest(window.lowerBound)
|
||||
toIndex = nearest(window.upperBound)
|
||||
}
|
||||
.onChange(of: fromIndex) { _, new in
|
||||
if new >= toIndex { toIndex = min(new + 1, dates.count - 1) }
|
||||
}
|
||||
.onChange(of: toIndex) { _, new in
|
||||
if new <= fromIndex { fromIndex = max(new - 1, 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func picker(title: String, selection: Binding<Int>) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(dates.indices, id: \.self) { i in
|
||||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
|
||||
private func nearest(_ date: Date) -> Int {
|
||||
var best = 0
|
||||
var bestDist = TimeInterval.greatestFiniteMagnitude
|
||||
for (i, d) in dates.enumerated() {
|
||||
let dist = abs(d.timeIntervalSince(date))
|
||||
if dist < bestDist { best = i; bestDist = dist }
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +595,8 @@ struct ChartsContainerView: View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
chartTypeChipBar
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
@@ -603,6 +604,9 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
iPhonePeriodControl
|
||||
if hasActiveFilters {
|
||||
activeFilterChip
|
||||
}
|
||||
chartContent
|
||||
// Swipe left/right to move to the adjacent chart type.
|
||||
// simultaneousGesture so charts with their own scrub drag
|
||||
@@ -613,7 +617,6 @@ struct ChartsContainerView: View {
|
||||
insertion: .move(edge: swipeInsertionEdge).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
))
|
||||
chartPageIndicator
|
||||
if viewModel.hiddenHistoryMonths > 0 {
|
||||
lockedHistoryTeaser
|
||||
}
|
||||
@@ -624,6 +627,92 @@ struct ChartsContainerView: View {
|
||||
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
|
||||
}
|
||||
|
||||
// MARK: - iPhone chart type chip bar
|
||||
//
|
||||
// Always-visible horizontal switcher (Health-app pattern) — replaces the
|
||||
// hidden title menu as primary navigation and the broken page-dot row.
|
||||
// Swipe still works and stays in sync via the auto-scroll.
|
||||
|
||||
private var chartTypeChipBar: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(orderedChartTypes) { chartType in
|
||||
chartTypeChip(chartType)
|
||||
.id(chartType)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.onChange(of: viewModel.selectedChartType) { _, newValue in
|
||||
withAnimation(.snappy) { proxy.scrollTo(newValue, anchor: .center) }
|
||||
}
|
||||
.onAppear {
|
||||
proxy.scrollTo(viewModel.selectedChartType, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chartTypeChip(_ chartType: ChartsViewModel.ChartType) -> some View {
|
||||
let isSelected = viewModel.selectedChartType == chartType
|
||||
let locked = chartType.isPremium && !viewModel.isPremium
|
||||
return Button {
|
||||
guard !isSelected else { return }
|
||||
ChartHaptics.tick()
|
||||
withAnimation(.snappy) { viewModel.selectChart(chartType) }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: locked ? "lock.fill" : chartType.icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text(chartType.rawValue)
|
||||
.font(.footnote.weight(isSelected ? .semibold : .regular))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.foregroundColor(isSelected ? .white : (locked ? .secondary : .primary))
|
||||
.background(
|
||||
Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
Capsule().stroke(isSelected ? Color.clear : Color.gray.opacity(0.25), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// Visible affordance for an active drill-down/filter — tap to clear.
|
||||
private var activeFilterChip: some View {
|
||||
let name = viewModel.selectedCategory?.name
|
||||
?? viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
.first(where: { viewModel.selectedSourceIds.contains($0.id) })?.name
|
||||
?? ""
|
||||
return HStack {
|
||||
Button {
|
||||
withAnimation(.snappy) {
|
||||
viewModel.selectedCategory = nil
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
viewModel.selectedBreakdown = .category
|
||||
}
|
||||
ChartHaptics.bump()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(String(format: String(localized: "chart_filtered_prefix"), name))
|
||||
.font(.footnote.weight(.medium))
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.appPrimary.opacity(0.12)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone swipe between charts
|
||||
|
||||
/// Chart types in the same grouped order as the title switcher menu.
|
||||
@@ -650,19 +739,6 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var chartPageIndicator: some View {
|
||||
let ordered = orderedChartTypes
|
||||
let current = ordered.firstIndex(of: viewModel.selectedChartType) ?? 0
|
||||
return HStack(spacing: 5) {
|
||||
ForEach(ordered.indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25))
|
||||
.frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5)
|
||||
}
|
||||
}
|
||||
.animation(.snappy, value: current)
|
||||
}
|
||||
|
||||
/// Stocks-style segmented period control above the chart (the chart switcher
|
||||
/// lives in the title menu, filters in the toolbar).
|
||||
@ViewBuilder
|
||||
@@ -692,6 +768,34 @@ struct ChartsContainerView: View {
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
|
||||
/// Drill-down: tapping a category/source in Allocation or Performance jumps
|
||||
/// to Evolution filtered to it (premium). The active-filter chip is the way
|
||||
/// back.
|
||||
private func drillDown(to name: String) {
|
||||
guard viewModel.isPremium else {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
return
|
||||
}
|
||||
ChartHaptics.bump()
|
||||
withAnimation(.snappy) {
|
||||
if viewModel.selectedBreakdown == .source {
|
||||
if let source = viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
.first(where: { $0.name == name }) {
|
||||
viewModel.selectedCategory = nil
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds = [source.id]
|
||||
}
|
||||
} else if let category = viewModel.availableCategories(for: viewModel.selectedChartType)
|
||||
.first(where: { $0.name == name }) {
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
viewModel.selectedCategory = category
|
||||
}
|
||||
viewModel.selectChart(.evolution)
|
||||
}
|
||||
}
|
||||
|
||||
private func periodLabel(_ months: Int) -> String {
|
||||
if months < 12 {
|
||||
return "\(months)M"
|
||||
@@ -719,14 +823,22 @@ struct ChartsContainerView: View {
|
||||
EvolutionChartView(
|
||||
data: viewModel.evolutionData,
|
||||
categoryData: viewModel.categoryEvolutionData,
|
||||
goals: goalsViewModel.goals
|
||||
goals: goalsViewModel.goals,
|
||||
contributions: viewModel.contributionsData,
|
||||
isPremium: viewModel.isPremium,
|
||||
onExpandRange: { viewModel.selectedTimeRange = .all },
|
||||
onPremiumNeeded: {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
)
|
||||
case .allocation:
|
||||
VStack(spacing: 20) {
|
||||
AllocationPieChart(
|
||||
data: viewModel.allocationData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown),
|
||||
onDrillDown: { name in drillDown(to: name) }
|
||||
)
|
||||
|
||||
if !viewModel.allocationEvolutionData.isEmpty {
|
||||
@@ -736,7 +848,8 @@ struct ChartsContainerView: View {
|
||||
case .performance:
|
||||
PerformanceBarChart(
|
||||
data: viewModel.performanceData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
||||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category",
|
||||
onDrillDown: { name in drillDown(to: name) }
|
||||
)
|
||||
case .contributions:
|
||||
ContributionsChartView(data: viewModel.contributionsData)
|
||||
@@ -875,6 +988,10 @@ struct EvolutionChartView: View {
|
||||
@State private var zoom = ChartZoomModel()
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
var contributions: [(date: Date, amount: Decimal)] = []
|
||||
var isPremium: Bool = true
|
||||
var onExpandRange: (() -> Void)? = nil
|
||||
var onPremiumNeeded: (() -> Void)? = nil
|
||||
@Environment(\.chartImageExport) private var chartImageExport
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
@@ -883,6 +1000,7 @@ struct EvolutionChartView: View {
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = false
|
||||
@State private var showContributions = false
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
@@ -955,7 +1073,23 @@ struct EvolutionChartView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
if chartMode == .total && !contributions.isEmpty {
|
||||
HStack {
|
||||
contributionsToggle
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
chartSection
|
||||
if chartMode == .total, isPremium, let insight = rangeInsight {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
Text(insight)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if !data.isEmpty && chartMode == .total {
|
||||
ChartStatsRow(stats: evolutionStats)
|
||||
}
|
||||
@@ -966,6 +1100,69 @@ struct EvolutionChartView: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
/// One contextual takeaway recomputed for the visible window (premium).
|
||||
private var rangeInsight: String? {
|
||||
let series = visibleData
|
||||
guard series.count >= 3 else { return nil }
|
||||
var bestDate: Date?
|
||||
var bestPct = -Double.infinity
|
||||
for i in 1..<series.count {
|
||||
let prev = NSDecimalNumber(decimal: series[i - 1].value).doubleValue
|
||||
let cur = NSDecimalNumber(decimal: series[i].value).doubleValue
|
||||
guard prev > 0 else { continue }
|
||||
let pct = (cur - prev) / prev * 100
|
||||
if pct > bestPct {
|
||||
bestPct = pct
|
||||
bestDate = series[i].date
|
||||
}
|
||||
}
|
||||
guard let bestDate, bestPct.isFinite else { return nil }
|
||||
return String(
|
||||
format: String(localized: "chart_insight_best_month"),
|
||||
Self.headerRangeFormatter.string(from: bestDate),
|
||||
String(format: "%+.1f%%", bestPct)
|
||||
)
|
||||
}
|
||||
|
||||
/// Premium overlay: cumulative contributed capital vs market value — shows
|
||||
/// at a glance what is *your money* and what is growth.
|
||||
private var contributionsToggle: some View {
|
||||
Button {
|
||||
if isPremium {
|
||||
withAnimation(.snappy) { showContributions.toggle() }
|
||||
ChartHaptics.tick()
|
||||
} else {
|
||||
onPremiumNeeded?()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if !isPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
Text(String(localized: "chart_overlay_contributions"))
|
||||
.font(.caption2.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundColor(showContributions ? .appSecondary : .secondary)
|
||||
.background(
|
||||
Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var cumulativeContributions: [(date: Date, value: Double)] {
|
||||
var running = 0.0
|
||||
return contributions
|
||||
.sorted { $0.date < $1.date }
|
||||
.map { item in
|
||||
running += NSDecimalNumber(decimal: item.amount).doubleValue
|
||||
return (item.date, running)
|
||||
}
|
||||
}
|
||||
|
||||
private var evolutionStats: [ChartStat] {
|
||||
guard !data.isEmpty else { return [] }
|
||||
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
|
||||
@@ -983,23 +1180,65 @@ struct EvolutionChartView: View {
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: Live header
|
||||
//
|
||||
// The numbers live HERE, not on the chart: while scrubbing it shows the
|
||||
// exact point (value, date, delta vs window start); at rest it summarizes
|
||||
// the visible window. This is what lets the chart itself stay clean.
|
||||
|
||||
/// Data restricted to the currently visible window (zoom/brush).
|
||||
private var visibleData: [(date: Date, value: Decimal)] {
|
||||
guard let window = zoom.visibleWindow(dates: data.map(\.date)) else { return data }
|
||||
let filtered = data.filter { window.contains($0.date) }
|
||||
return filtered.isEmpty ? data : filtered
|
||||
}
|
||||
|
||||
private static let headerRangeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
let series = visibleData
|
||||
let current = (chartMode == .total ? selectedDataPoint : nil)
|
||||
?? series.last.map { (date: $0.date, value: $0.value) }
|
||||
let firstValue = series.first.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
|
||||
let currentValue = current.map { NSDecimalNumber(decimal: $0.value).doubleValue } ?? 0
|
||||
let deltaPct = firstValue > 0 ? (currentValue - firstValue) / firstValue * 100 : 0
|
||||
let subtitle: String = {
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
return selected.date.monthYearString
|
||||
}
|
||||
guard let first = series.first?.date, let last = series.last?.date else { return "" }
|
||||
return "\(Self.headerRangeFormatter.string(from: first)) – \(Self.headerRangeFormatter.string(from: last))"
|
||||
}()
|
||||
|
||||
return HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text((current?.value ?? 0).compactCurrencyString)
|
||||
.font(.system(.title2, design: .rounded).weight(.bold))
|
||||
.contentTransition(.numericText())
|
||||
if series.count > 1 {
|
||||
Text(String(format: "%+.1f%%", deltaPct))
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule().fill((deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
@@ -1009,6 +1248,7 @@ struct EvolutionChartView: View {
|
||||
.disabled(goals.isEmpty)
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
}
|
||||
.animation(.snappy, value: selectedDataPoint?.date)
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
@@ -1077,6 +1317,10 @@ struct EvolutionChartView: View {
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
// Haptic tick when the scrub snaps to a new point.
|
||||
if selectedDataPoint?.date != closest.date {
|
||||
ChartHaptics.tick()
|
||||
}
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
@@ -1088,7 +1332,12 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||||
zoom: $zoom,
|
||||
onExpandRange: onExpandRange
|
||||
)
|
||||
// Performance: GPU rendering for smoother scrolling on older devices
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
}
|
||||
@@ -1097,24 +1346,34 @@ struct EvolutionChartView: View {
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
// Widget-parity styling: gradient stroke, soft area fill, and NO
|
||||
// permanent dots/bubbles on iPhone — the endpoint dot marks "now"
|
||||
// and exact values live in the live header while scrubbing.
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
series: .value("Series", "total")
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round))
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(30)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
if !labelsCompact {
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(30)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,6 +1390,28 @@ struct EvolutionChartView: View {
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
|
||||
if labelsCompact, let last = data.last {
|
||||
PointMark(
|
||||
x: .value("Date", last.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: last.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.cyan)
|
||||
.symbolSize(55)
|
||||
}
|
||||
|
||||
if showContributions {
|
||||
ForEach(cumulativeContributions, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Contributed", item.value),
|
||||
series: .value("Series", "contributions")
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.6, dash: [5, 4]))
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(categoryData) { item in
|
||||
AreaMark(
|
||||
@@ -1241,7 +1522,11 @@ struct ContributionsChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map { NSDecimalNumber(decimal: $0.amount).doubleValue },
|
||||
zoom: $zoom
|
||||
)
|
||||
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
@@ -1348,7 +1633,11 @@ struct RollingReturnChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map(\.value),
|
||||
zoom: $zoom
|
||||
)
|
||||
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
||||
ChartDataTable(
|
||||
rows: rollingTableRows,
|
||||
@@ -1513,7 +1802,11 @@ struct CashflowStackedChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map { NSDecimalNumber(decimal: $0.contributions + $0.netPerformance).doubleValue },
|
||||
zoom: $zoom
|
||||
)
|
||||
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,11 @@ struct DrawdownChart: View {
|
||||
}
|
||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||
.frame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map(\.drawdown),
|
||||
zoom: $zoom
|
||||
)
|
||||
|
||||
// Statistics
|
||||
HStack(spacing: 20) {
|
||||
@@ -321,7 +325,11 @@ struct VolatilityChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map(\.volatility),
|
||||
zoom: $zoom
|
||||
)
|
||||
|
||||
ChartStatsRow(stats: [
|
||||
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
|
||||
|
||||
@@ -4,6 +4,16 @@ import Charts
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
var title: String = "Performance by Category"
|
||||
/// Drill-down: tapping a row jumps to Evolution filtered to it.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
/// On iPhone, vertical bars collide once there are more than a handful of
|
||||
/// categories (labels + annotations fight for width). Horizontal bars give
|
||||
/// every name a full line.
|
||||
private var useHorizontalBars: Bool {
|
||||
horizontalSizeClass != .regular && data.count > 6
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -19,60 +29,16 @@ struct PerformanceBarChart: View {
|
||||
}
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
if useHorizontalBars {
|
||||
horizontalChart
|
||||
} else {
|
||||
verticalChart
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Legend / Details
|
||||
// Legend / Details — rows drill down when the owner wires it up.
|
||||
VStack(spacing: 8) {
|
||||
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
legendRow(item)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@@ -89,6 +55,118 @@ struct PerformanceBarChart: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var verticalChart: some View {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
}
|
||||
|
||||
private var horizontalChart: some View {
|
||||
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("CAGR", item.cagr),
|
||||
y: .value("Category", item.category)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: CGFloat(data.count) * 34 + 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
|
||||
let row = HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
|
||||
if onDrillDown != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
row.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
private var perfStats: [ChartStat] {
|
||||
guard !data.isEmpty else { return [] }
|
||||
let values = data.map { $0.cagr }
|
||||
|
||||
Reference in New Issue
Block a user