Fixes UI iPad + diagnóstico iCloud (build 57)
Feedback del usuario en iPad: - Allocation: gráfica de tarta → semicírculo estilo hemiciclo (parlamento). SemicircleAllocation con arcos dibujados a mano (SemicircleArc Shape), segmentos por cuota en 180°, total/selección en el centro, tap por segmento. - Charts de Analyze (Compare, Period vs Period, Year vs Year) ahora SÍ muestran KPIs en la cabecera de iPad como el resto: Compare (líder/rezagado por modo), Period (retorno final por periodo), YoY (retorno final por año seleccionado). - Momentum & Streaks en el sidebar del Journal (iPad, 320pt): variante compact — tiles en fila sin subtítulos que rompían a 3 líneas, sin bloque de logros. iCloud: - CKErrorPartialFailure (CKErrorDomain code 2) mostraba un mensaje opaco. CoreDataStack.hint(for:) recorre CKPartialErrorsByItemIDKey y clasifica el código dominante → hint accionable (esquema/quota/red). Settings muestra el hint legible sobre el detalle técnico. lastSyncErrorHint publicado. Strings ×7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -18,51 +18,14 @@ struct AllocationPieChart: View {
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// Pie Chart
|
||||
Chart(data, id: \.category) { item in
|
||||
SectorMark(
|
||||
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
innerRadius: .ratio(0.6),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(width: 180, height: 180)
|
||||
.overlay {
|
||||
// Center content
|
||||
VStack(spacing: 2) {
|
||||
if let selected = selectedSlice,
|
||||
let item = data.first(where: { $0.category == selected }) {
|
||||
Text(selected)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.headline)
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(total.compactCurrencyString)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
// Simple tap detection
|
||||
}
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
// Hemicycle (parliament-style) allocation gauge
|
||||
SemicircleAllocation(
|
||||
data: data,
|
||||
total: total,
|
||||
selectedSlice: $selectedSlice
|
||||
)
|
||||
.frame(width: 200, height: 128)
|
||||
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -117,6 +80,96 @@ struct AllocationPieChart: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Semicircle (hemicycle) allocation gauge
|
||||
|
||||
/// Parliament-style half-donut: segments sweep the top 180°, sized by share.
|
||||
struct SemicircleAllocation: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
let total: Decimal
|
||||
@Binding var selectedSlice: String?
|
||||
|
||||
/// Cumulative start/end fractions [0,1] for each segment across the 180° arc.
|
||||
private var segments: [(category: String, start: Double, end: Double, color: Color)] {
|
||||
guard total > 0 else { return [] }
|
||||
var acc = 0.0
|
||||
return data.map { item in
|
||||
let frac = NSDecimalNumber(decimal: item.value / total).doubleValue
|
||||
let start = acc
|
||||
acc += frac
|
||||
return (item.category, start, min(acc, 1), Color(hex: item.color) ?? .gray)
|
||||
}
|
||||
}
|
||||
|
||||
private var centerItem: (label: String, value: String, sub: String?)? {
|
||||
if let selected = selectedSlice, let item = data.first(where: { $0.category == selected }) {
|
||||
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
|
||||
return (selected, item.value.compactCurrencyString, String(format: "%.1f%%", pct))
|
||||
}
|
||||
return (String(localized: "allocation_total"), total.compactCurrencyString, nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let lineWidth = w * 0.16
|
||||
let radius = (w - lineWidth) / 2
|
||||
let center = CGPoint(x: w / 2, y: w / 2) // arc baseline at bottom of the square-ish area
|
||||
|
||||
ZStack {
|
||||
// Track
|
||||
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius)
|
||||
.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)
|
||||
.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)))
|
||||
.onTapGesture {
|
||||
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
|
||||
}
|
||||
}
|
||||
|
||||
// Center readout, sitting just under the arc
|
||||
if let c = centerItem {
|
||||
VStack(spacing: 1) {
|
||||
Text(c.label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(c.value)
|
||||
.font(.headline)
|
||||
if let sub = c.sub {
|
||||
Text(sub)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.position(x: center.x, y: center.y - lineWidth * 0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc along the TOP semicircle, parameterized by fraction [0,1] of the 180°
|
||||
/// sweep (0 = left/9 o'clock, 1 = right/3 o'clock, passing through the top).
|
||||
struct SemicircleArc: Shape {
|
||||
let startFraction: Double
|
||||
let endFraction: Double
|
||||
let radius: CGFloat
|
||||
var inset: Double = 0
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let 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)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation Targets Comparison
|
||||
|
||||
struct AllocationTargetsComparisonChart: View {
|
||||
|
||||
@@ -389,8 +389,42 @@ struct ChartsContainerView: View {
|
||||
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
|
||||
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
|
||||
]
|
||||
case .yearOverYear, .comparison, .simulator, .periodComparison:
|
||||
// These charts carry their own comparison summaries inside the card.
|
||||
case .comparison:
|
||||
let series = viewModel.comparisonData
|
||||
guard !series.isEmpty else { return [] }
|
||||
// Rank sources by their latest value in the current display mode.
|
||||
let ranked = series.compactMap { s -> (name: String, val: Double)? in
|
||||
guard let last = s.points.last?.value else { return nil }
|
||||
return (s.name, last)
|
||||
}.sorted { $0.val > $1.val }
|
||||
let unit = viewModel.comparisonDisplayMode == .absolute ? "" : "%"
|
||||
func fmt(_ v: Double) -> String { unit.isEmpty ? Decimal(v).compactCurrencyString : String(format: "%+.1f%%", v) }
|
||||
var stats: [ChartStat] = [ChartStat(label: "Series", value: "\(series.count)", color: .secondary)]
|
||||
if let best = ranked.first {
|
||||
stats.append(ChartStat(label: "Leader", value: "\(best.name) \(fmt(best.val))", color: .positiveGreen))
|
||||
}
|
||||
if ranked.count > 1, let worst = ranked.last {
|
||||
stats.append(ChartStat(label: "Laggard", value: "\(worst.name) \(fmt(worst.val))", color: .negativeRed))
|
||||
}
|
||||
return stats
|
||||
case .periodComparison:
|
||||
let series = viewModel.periodComparisonData
|
||||
guard !series.isEmpty else { return [] }
|
||||
return series.map { s in
|
||||
let end = s.points.last?.returnPct ?? 0
|
||||
return ChartStat(label: s.label, value: String(format: "%+.1f%%", end),
|
||||
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)
|
||||
}
|
||||
case .simulator:
|
||||
// The simulator carries its own live sliders and comparison inline.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +772,9 @@ struct MonthlyCheckInCard: View {
|
||||
// MARK: - Momentum & Streaks Card
|
||||
|
||||
struct MomentumStreaksCard: View {
|
||||
/// In narrow containers (the Journal sidebar on iPad) the 3-across stat tiles
|
||||
/// and achievement detail don't fit — stack tiles in a grid and drop detail.
|
||||
var compact = false
|
||||
@State private var stats: MonthlyCheckInStats = .empty
|
||||
|
||||
var body: some View {
|
||||
@@ -791,22 +794,30 @@ struct MomentumStreaksCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
statTile(
|
||||
title: "Streak",
|
||||
value: "\(stats.currentStreak)x",
|
||||
subtitle: "On-time in a row"
|
||||
)
|
||||
statTile(
|
||||
title: "Best",
|
||||
value: "\(stats.bestStreak)x",
|
||||
subtitle: "Personal best"
|
||||
)
|
||||
statTile(
|
||||
title: "Avg early",
|
||||
value: formattedDaysText(for: stats.averageDaysBeforeDeadline),
|
||||
subtitle: "vs deadline"
|
||||
)
|
||||
if compact {
|
||||
HStack(spacing: 8) {
|
||||
compactStatTile(title: "Streak", value: "\(stats.currentStreak)x")
|
||||
compactStatTile(title: "Best", value: "\(stats.bestStreak)x")
|
||||
compactStatTile(title: "Avg early", value: formattedDaysText(for: stats.averageDaysBeforeDeadline))
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
statTile(
|
||||
title: "Streak",
|
||||
value: "\(stats.currentStreak)x",
|
||||
subtitle: "On-time in a row"
|
||||
)
|
||||
statTile(
|
||||
title: "Best",
|
||||
value: "\(stats.bestStreak)x",
|
||||
subtitle: "Personal best"
|
||||
)
|
||||
statTile(
|
||||
title: "Avg early",
|
||||
value: formattedDaysText(for: stats.averageDaysBeforeDeadline),
|
||||
subtitle: "vs deadline"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProgressView(value: stats.onTimeRate, total: 1)
|
||||
@@ -839,7 +850,7 @@ struct MomentumStreaksCard: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if !stats.achievements.isEmpty {
|
||||
if !stats.achievements.isEmpty && !compact {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "achievements_title"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -887,6 +898,25 @@ struct MomentumStreaksCard: View {
|
||||
stats = MonthlyCheckInStore.stats(referenceDate: Date())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func compactStatTile(title: String, value: String) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value)
|
||||
.font(.headline)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statTile(title: String, value: String, subtitle: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
||||
@@ -67,7 +67,7 @@ struct JournalView: View {
|
||||
// the Journal — this is the emotional home of the ritual.
|
||||
if !viewModel.monthlyNotes.isEmpty && searchText.isEmpty {
|
||||
Section {
|
||||
MomentumStreaksCard()
|
||||
MomentumStreaksCard(compact: isPad)
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
@@ -427,13 +427,18 @@ struct SettingsView: View {
|
||||
|
||||
// CloudKit error (if any)
|
||||
if let error = cloudStack.lastSyncError {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("CloudKit error", systemImage: "exclamationmark.icloud")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.red)
|
||||
if let hint = cloudStack.lastSyncErrorHint {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user