680255f69d
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab. - Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho regular, stack colapsable en compacto) con selección nativa de List. - Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos en regular, sheet en compacto). - Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:), sin ignoresSafeArea en contenido interactivo. - Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
423 lines
16 KiB
Swift
423 lines
16 KiB
Swift
import SwiftUI
|
||
import Charts
|
||
import UIKit
|
||
|
||
// MARK: - Time-series chart zoom (window model)
|
||
//
|
||
// Shared VISUAL zoom for every chart with a Date X axis: pinch anchored on the
|
||
// window center, native horizontal pan once zoomed, double-tap to reset.
|
||
//
|
||
// The DATA range is a separate, global concern: the range brush
|
||
// (`ChartRangeBrush`) lives in the period bar and drives
|
||
// `ChartsViewModel.customRange`, filtering every chart at once. Keeping it out
|
||
// of the chart content also keeps it out of the chart-swipe gesture's reach.
|
||
|
||
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
|
||
struct ChartZoomModel {
|
||
/// Visible X-domain length in seconds; nil = show everything.
|
||
var spanSeconds: TimeInterval?
|
||
/// Start of the visible window (chart scroll position).
|
||
var startX: Date = .distantPast
|
||
/// (span, center) captured when a pinch begins.
|
||
var pinchAnchor: (span: TimeInterval, center: Date)?
|
||
|
||
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
|
||
/// 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.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 reset() {
|
||
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 window behavior to a Chart with a Date X axis.
|
||
@ViewBuilder
|
||
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
|
||
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
|
||
}
|
||
}
|
||
|
||
private struct ZoomableTimeSeriesModifier: ViewModifier {
|
||
let dates: [Date]
|
||
@Binding var zoom: ChartZoomModel
|
||
|
||
private var fullSpan: TimeInterval {
|
||
guard let min = dates.min(), let max = dates.max(), max > min else {
|
||
return ChartZoomModel.minSpan
|
||
}
|
||
return max.timeIntervalSince(min)
|
||
}
|
||
|
||
func body(content: Content) -> some View {
|
||
Group {
|
||
if let span = zoom.spanSeconds {
|
||
content
|
||
.chartScrollableAxes(.horizontal)
|
||
.chartXVisibleDomain(length: span)
|
||
.chartScrollPosition(x: $zoom.startX)
|
||
} else {
|
||
content
|
||
}
|
||
}
|
||
.simultaneousGesture(pinchGesture)
|
||
.simultaneousGesture(doubleTapGesture)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
private var doubleTapGesture: some Gesture {
|
||
TapGesture(count: 2)
|
||
.onEnded {
|
||
guard zoom.isZoomed else { return }
|
||
withAnimation(.snappy) { zoom.reset() }
|
||
ChartHaptics.bump()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Range brush (full-history sparkline + month-snapping handles)
|
||
//
|
||
// Index-based over the months that actually have data. Bound to a
|
||
// ClosedRange<Int> selection owned by the caller (the charts container maps it
|
||
// to ChartsViewModel.customRange). Selecting everything = "no filter".
|
||
|
||
struct ChartRangeBrush: View {
|
||
let dates: [Date]
|
||
let values: [Double]
|
||
@Binding var selection: ClosedRange<Int>
|
||
|
||
@State private var dragStartIndices: (lo: Int, hi: Int)?
|
||
@State private var dragTarget: DragTarget?
|
||
@State private var lastTickedIndex: Int = -1
|
||
@State private var showingPickers = false
|
||
|
||
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
|
||
/// One unified gesture instead of per-element gestures: with a narrow
|
||
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
|
||
/// window and moving it was impossible.
|
||
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
|
||
|
||
private static let labelFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.locale = .autoupdatingCurrent
|
||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||
return f
|
||
}()
|
||
|
||
private var clampedSelection: (lo: Int, hi: Int) {
|
||
guard !dates.isEmpty else { return (0, 0) }
|
||
let lo = max(0, min(selection.lowerBound, dates.count - 1))
|
||
let hi = max(lo, min(selection.upperBound, dates.count - 1))
|
||
return (lo, hi)
|
||
}
|
||
|
||
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, selection: $selection)
|
||
.presentationDetents([.height(320)])
|
||
}
|
||
}
|
||
|
||
private var rangeLabel: some View {
|
||
let (lo, hi) = clampedSelection
|
||
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))
|
||
.foregroundStyle(Color.appPrimary)
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: 8, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func brushBody(width: CGFloat) -> some View {
|
||
let (lo, hi) = clampedSelection
|
||
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)
|
||
|
||
// Handles (visual only — hit-testing lives in the unified gesture)
|
||
handle(at: xLo)
|
||
handle(at: xHi)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.gesture(unifiedGesture(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(height: 44)
|
||
.offset(x: x - 2.5)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
// 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())
|
||
}
|
||
|
||
// MARK: Unified gesture
|
||
//
|
||
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
|
||
// anywhere else (inside the window OR on the dimmed history) slides the
|
||
// whole window keeping its width — select Jan–Mar, then swipe to land on
|
||
// Jun–Aug. Narrow windows stay movable because the edge grip only wins in
|
||
// its 18pt zone (from outside when the window is too narrow to share).
|
||
|
||
private func unifiedGesture(width: CGFloat) -> some Gesture {
|
||
DragGesture(minimumDistance: 1)
|
||
.onChanged { value in
|
||
let (lo, hi) = dragStartIndices ?? clampedSelection
|
||
if dragStartIndices == nil {
|
||
dragStartIndices = (lo, hi)
|
||
dragTarget = target(
|
||
forX: value.startLocation.x,
|
||
xLo: xPosition(index: lo, width: width),
|
||
xHi: xPosition(index: hi, width: width)
|
||
)
|
||
}
|
||
guard let target = dragTarget else { return }
|
||
var newLo = lo
|
||
var newHi = hi
|
||
switch target {
|
||
case .lowerHandle:
|
||
newLo = min(index(atX: value.location.x, width: width), hi)
|
||
case .upperHandle:
|
||
newHi = max(index(atX: value.location.x, width: width), lo)
|
||
case .moveWindow:
|
||
let span = hi - lo
|
||
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
|
||
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
|
||
newHi = newLo + span
|
||
}
|
||
let ticked = (target == .upperHandle) ? newHi : newLo
|
||
if ticked != lastTickedIndex {
|
||
lastTickedIndex = ticked
|
||
ChartHaptics.tick()
|
||
}
|
||
selection = newLo...newHi
|
||
}
|
||
.onEnded { _ in
|
||
dragStartIndices = nil
|
||
dragTarget = nil
|
||
lastTickedIndex = -1
|
||
}
|
||
}
|
||
|
||
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
|
||
let grip: CGFloat = 18
|
||
let windowWidth = xHi - xLo
|
||
if windowWidth >= grip * 3 {
|
||
// Wide window: edges grab from either side of the handle.
|
||
if abs(x - xLo) <= grip { return .lowerHandle }
|
||
if abs(x - xHi) <= grip { return .upperHandle }
|
||
} else {
|
||
// Narrow window: the inside belongs to MOVE; edges only from outside.
|
||
if x < xLo && xLo - x <= grip { return .lowerHandle }
|
||
if x > xHi && x - xHi <= grip { return .upperHandle }
|
||
}
|
||
return .moveWindow
|
||
}
|
||
}
|
||
|
||
/// 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 selection: ClosedRange<Int>
|
||
@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") {
|
||
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
|
||
ChartHaptics.bump()
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
fromIndex = max(0, min(selection.lowerBound, dates.count - 1))
|
||
toIndex = max(fromIndex, min(selection.upperBound, dates.count - 1))
|
||
}
|
||
.onChange(of: fromIndex) { _, new in
|
||
if new > toIndex { toIndex = new }
|
||
}
|
||
.onChange(of: toIndex) { _, new in
|
||
if new < fromIndex { fromIndex = new }
|
||
}
|
||
}
|
||
|
||
private func picker(title: String, selection: Binding<Int>) -> some View {
|
||
VStack(spacing: 2) {
|
||
Text(title)
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
Picker(title, selection: selection) {
|
||
ForEach(dates.indices, id: \.self) { i in
|
||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||
}
|
||
}
|
||
.pickerStyle(.wheel)
|
||
}
|
||
}
|
||
}
|