Feedback iPad: allocation grande + %, Home 2 columnas, YoY KPIs, achievements fix, iCloud calmado (build 58)
Allocation: - Semicírculo responsive que llena su mitad de la fila (grande en iPad, no minúsculo en una esquina) + porcentaje dentro de cada segmento >=7%. SemicircleArc acepta center explícito; labels en el ángulo medio del arco. Home iPad: - Ya no columna única estrecha a 720. Hero + check-in a ancho completo, resto de cards en DOS columnas masonry independientes (sin acoplar alturas → sin gaps del grid). Cap 1100pt. Year vs Year KPIs: - Retorno del año actual + diferencia (pp) vs año anterior + mejor/peor año histórico. Usa forecastEndValue para el año en curso. Fixes: - 'View all achievements' en Journal no hacía nada: NavigationLink inline (frágil según contexto de navegación) → sheet fiable en cualquier sitio. - iCloud: CKErrorPartialFailure tras una sync que funciona ya NO se pinta en rojo alarmante — nota calmada 'Sincronizando con iCloud' + detalle técnico en disclosure. Rojo solo si nunca ha sincronizado y es fallo total. syncErrorIsCritical + strings ×7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -7,6 +7,11 @@ struct AllocationPieChart: View {
|
||||
var showsTargetsComparison: Bool = true
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private var chartHeight: CGFloat {
|
||||
horizontalSizeClass == .regular ? 190 : 130
|
||||
}
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
@@ -18,17 +23,20 @@ struct AllocationPieChart: View {
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
// Hemicycle (parliament-style) allocation gauge
|
||||
HStack(alignment: .center, spacing: 24) {
|
||||
// Hemicycle (parliament-style) allocation gauge — fills its
|
||||
// half of the row so it scales up on iPad instead of sitting
|
||||
// tiny in a corner.
|
||||
SemicircleAllocation(
|
||||
data: data,
|
||||
total: total,
|
||||
selectedSlice: $selectedSlice
|
||||
)
|
||||
.frame(width: 200, height: 128)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: chartHeight)
|
||||
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(data, id: \.category) { item in
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
@@ -40,18 +48,18 @@ struct AllocationPieChart: View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
.frame(width: 11, height: 11)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -110,26 +118,45 @@ struct SemicircleAllocation: View {
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let lineWidth = w * 0.16
|
||||
// The semicircle is 2:1 (width:height). Fit it to whichever
|
||||
// dimension binds so it scales up on iPad without clipping.
|
||||
let w = min(geo.size.width, geo.size.height * 2)
|
||||
let lineWidth = w * 0.17
|
||||
let radius = (w - lineWidth) / 2
|
||||
let center = CGPoint(x: w / 2, y: w / 2) // arc baseline at bottom of the square-ish area
|
||||
let center = CGPoint(x: geo.size.width / 2, y: (geo.size.height + w / 2) / 2)
|
||||
|
||||
ZStack {
|
||||
// Track
|
||||
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius)
|
||||
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius, center: center)
|
||||
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
|
||||
ForEach(segments, id: \.category) { seg in
|
||||
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, inset: 0.006)
|
||||
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center, inset: 0.006)
|
||||
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius).stroke(style: StrokeStyle(lineWidth: lineWidth)))
|
||||
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center).stroke(style: StrokeStyle(lineWidth: lineWidth)))
|
||||
.onTapGesture {
|
||||
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
|
||||
}
|
||||
}
|
||||
|
||||
// Percentage labels on the arc band (only where the slice is wide
|
||||
// enough to fit the text).
|
||||
ForEach(segments, id: \.category) { seg in
|
||||
let frac = seg.end - seg.start
|
||||
if frac >= 0.07 {
|
||||
let mid = (seg.start + seg.end) / 2
|
||||
let angle = (180 + mid * 180) * .pi / 180
|
||||
let pos = CGPoint(x: center.x + radius * CoreGraphics.cos(angle),
|
||||
y: center.y + radius * CoreGraphics.sin(angle))
|
||||
Text("\(Int((frac * 100).rounded()))%")
|
||||
.font(.system(size: lineWidth * 0.42, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.position(pos)
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
// Center readout, sitting just under the arc
|
||||
if let c = centerItem {
|
||||
VStack(spacing: 1) {
|
||||
@@ -145,7 +172,7 @@ struct SemicircleAllocation: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.position(x: center.x, y: center.y - lineWidth * 0.2)
|
||||
.position(x: center.x, y: center.y - lineWidth * 0.25)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,14 +185,15 @@ struct SemicircleArc: Shape {
|
||||
let startFraction: Double
|
||||
let endFraction: Double
|
||||
let radius: CGFloat
|
||||
var center: CGPoint? = nil
|
||||
var inset: Double = 0
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let center = CGPoint(x: rect.width / 2, y: rect.width / 2)
|
||||
let c = center ?? CGPoint(x: rect.width / 2, y: rect.width / 2)
|
||||
let start = Angle.degrees(180 + (startFraction + inset) * 180)
|
||||
let end = Angle.degrees(180 + (endFraction - inset) * 180)
|
||||
var p = Path()
|
||||
p.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: false)
|
||||
p.addArc(center: c, radius: radius, startAngle: start, endAngle: end, clockwise: false)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,13 +416,40 @@ struct ChartsContainerView: View {
|
||||
color: end >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
case .yearOverYear:
|
||||
let years = viewModel.yearOverYearData.filter { viewModel.yoySelectedYears.contains($0.year) }
|
||||
guard !years.isEmpty else { return [] }
|
||||
return years.map { ys in
|
||||
let end = ys.values.compactMap { $0.isNaN ? nil : $0 }.last ?? 0
|
||||
return ChartStat(label: "\(ys.year)", value: String(format: "%+.1f%%", end),
|
||||
color: end >= 0 ? .positiveGreen : .negativeRed)
|
||||
let all = viewModel.yearOverYearData.sorted { $0.year < $1.year }
|
||||
guard let latest = all.last else { return [] }
|
||||
func endReturn(_ ys: ChartsViewModel.YearSeries) -> Double {
|
||||
ys.forecastEndValue ?? (ys.values.compactMap { $0.isNaN ? nil : $0 }.last ?? 0)
|
||||
}
|
||||
let latestEnd = endReturn(latest)
|
||||
var stats: [ChartStat] = [
|
||||
ChartStat(label: "\(latest.year)", value: String(format: "%+.1f%%", latestEnd),
|
||||
color: latestEnd >= 0 ? .positiveGreen : .negativeRed)
|
||||
]
|
||||
// Current year vs previous year (end-of-year return difference).
|
||||
if all.count >= 2 {
|
||||
let prev = all[all.count - 2]
|
||||
let diff = latestEnd - endReturn(prev)
|
||||
stats.append(ChartStat(
|
||||
label: String(format: String(localized: "yoy_vs_prev"), "\(prev.year)"),
|
||||
value: String(format: "%+.1f pp", diff),
|
||||
color: diff >= 0 ? .positiveGreen : .negativeRed))
|
||||
}
|
||||
// Best and worst full year on record.
|
||||
if all.count >= 2 {
|
||||
let ranked = all.map { ($0.year, endReturn($0)) }.sorted { $0.1 > $1.1 }
|
||||
if let best = ranked.first {
|
||||
stats.append(ChartStat(label: String(localized: "yoy_best_year"),
|
||||
value: "\(best.0) \(String(format: "%+.1f%%", best.1))",
|
||||
color: .positiveGreen))
|
||||
}
|
||||
if let worst = ranked.last, worst.0 != ranked.first?.0 {
|
||||
stats.append(ChartStat(label: String(localized: "yoy_worst_year"),
|
||||
value: "\(worst.0) \(String(format: "%+.1f%%", worst.1))",
|
||||
color: .negativeRed))
|
||||
}
|
||||
}
|
||||
return stats
|
||||
case .simulator:
|
||||
// The simulator carries its own live sliders and comparison inline.
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user