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
148 lines
5.4 KiB
Swift
148 lines
5.4 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Stat definition
|
|
struct ChartStat {
|
|
let label: String
|
|
let value: String
|
|
let color: Color
|
|
}
|
|
|
|
// MARK: - Stats summary row (horizontal scroll of chips)
|
|
struct ChartStatsRow: View {
|
|
let stats: [ChartStat]
|
|
/// On regular width the charts container surfaces these stats in the KPI
|
|
/// header above the chart — hide the in-card duplicate row. Charts without a
|
|
/// header equivalent (e.g. Year vs Year) opt back in.
|
|
var showsOnRegularWidth = false
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
var body: some View {
|
|
if horizontalSizeClass != .regular || showsOnRegularWidth {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(stats.indices, id: \.self) { i in
|
|
VStack(alignment: .center, spacing: 3) {
|
|
Text(stats[i].label)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Text(stats[i].value)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(stats[i].color)
|
|
}
|
|
.frame(minWidth: 60)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemGray6))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Data table row model
|
|
struct ChartDataTableRow: Identifiable {
|
|
let id = UUID()
|
|
let label: String
|
|
let value: String
|
|
let deltaPrev: String?
|
|
let deltaFirst: String?
|
|
let isPrevPositive: Bool
|
|
let isFirstPositive: Bool
|
|
}
|
|
|
|
// MARK: - Expandable data table
|
|
struct ChartDataTable: View {
|
|
let rows: [ChartDataTableRow]
|
|
let valueHeader: String
|
|
let deltaPrevHeader: String?
|
|
let deltaFirstHeader: String?
|
|
|
|
@State private var isExpanded = false
|
|
private let previewCount = 5
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
tableHeader
|
|
Divider()
|
|
let displayed = isExpanded ? rows : Array(rows.suffix(previewCount))
|
|
ForEach(displayed) { row in
|
|
tableDataRow(row)
|
|
if row.id != displayed.last?.id {
|
|
Divider().opacity(0.35)
|
|
}
|
|
}
|
|
if rows.count > previewCount {
|
|
Divider().opacity(0.35)
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(isExpanded ? "Show less" : "Show all \(rows.count)")
|
|
.font(.caption)
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(Color.appPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
.background(Color(.systemGray6))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
private var tableHeader: some View {
|
|
HStack(spacing: 0) {
|
|
Text("Date").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
.frame(width: 72, alignment: .trailing)
|
|
if let h = deltaPrevHeader {
|
|
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
.frame(width: 62, alignment: .trailing)
|
|
}
|
|
if let h = deltaFirstHeader {
|
|
Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
.frame(width: 62, alignment: .trailing)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func tableDataRow(_ row: ChartDataTableRow) -> some View {
|
|
HStack(spacing: 0) {
|
|
Text(row.label).font(.caption2).foregroundStyle(.primary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(row.value).font(.caption2.weight(.medium))
|
|
.frame(width: 72, alignment: .trailing)
|
|
if let dp = row.deltaPrev, deltaPrevHeader != nil {
|
|
Text(dp).font(.caption2.weight(.medium))
|
|
.foregroundStyle(row.isPrevPositive ? Color.positiveGreen : Color.negativeRed)
|
|
.frame(width: 62, alignment: .trailing)
|
|
}
|
|
if let df = row.deltaFirst, deltaFirstHeader != nil {
|
|
Text(df).font(.caption2.weight(.medium))
|
|
.foregroundStyle(row.isFirstPositive ? Color.positiveGreen : Color.negativeRed)
|
|
.frame(width: 62, alignment: .trailing)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 5)
|
|
}
|
|
}
|
|
|
|
// MARK: - Compact date formatter
|
|
private let tableMonthFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.setLocalizedDateFormatFromTemplate("MMMy")
|
|
return f
|
|
}()
|
|
|
|
func chartTableDateLabel(_ date: Date) -> String {
|
|
tableMonthFormatter.string(from: date)
|
|
}
|