Calm 2.0 Fase 3: Home iPad columna única, Goals con anillos, Journal timeline, Sources polish (build 53)
Home iPad/Mac (feedback del usuario: 'me gusta más el iPhone'): - Fuera el grid de 2 columnas (acoplaba alturas de fila y dejaba huecos entre cards de tamaños distintos) → columna única centrada a 720pt, patrón Things/Day One. Eliminado el drag-drop del grid, columnSpan y el overlay full-screen (~120 líneas); el orden/visibilidad sigue en Customize. Goals: - ProgressRing nuevo (anillo estilo Fitness con porcentaje centrado y color semántico: verde=logrado, ámbar=retrasado, acento=en camino) en GoalRowView y en la card de Goals del Home. GoalProgressBar queda solo en la share card. Journal: - Momentum & Streaks vive ahora en la cabecera del Journal (la historia del hábito es narrativa, no dashboard). - Filas timeline: círculo de mood como ancla visual (tint semántico por mood), mes + estrellas + preview de nota. Sources: - Badge 'Needs update' como chip ámbar visible; valor en tipografía rounded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -12,8 +12,6 @@ struct DashboardView: View {
|
||||
@State private var showingCustomize = false
|
||||
@State private var sectionConfigs = DashboardLayoutStore.load()
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@State private var fullScreenSection: DashboardSection? = nil
|
||||
@State private var dragTargetID: String? = nil
|
||||
@State private var showingQuickUpdate = false
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@@ -53,14 +51,7 @@ struct DashboardView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
if horizontalSizeClass == .regular, let section = fullScreenSection {
|
||||
iPadFullScreenOverlay(for: section)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||
.zIndex(10)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: fullScreenSection)
|
||||
.navigationTitle("Home")
|
||||
.refreshable {
|
||||
viewModel.refreshData()
|
||||
@@ -174,135 +165,19 @@ struct DashboardView: View {
|
||||
|
||||
// MARK: - iPad Layout Helpers
|
||||
|
||||
/// Hero and check-in render full-width above the grid (state + action zones);
|
||||
/// only context cards flow into the 2-column grid.
|
||||
private var iPadGridSections: [DashboardSectionConfig] {
|
||||
visibleSections.filter {
|
||||
$0.id != DashboardSection.totalValue.id && $0.id != DashboardSection.monthlyCheckIn.id
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iPadDashboardLayout: some View {
|
||||
// Total Portfolio Value — always full width, never in the grid
|
||||
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
||||
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
||||
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
||||
|
||||
if let checkInConfig = visibleSections.first(where: { $0.id == DashboardSection.monthlyCheckIn.id }) {
|
||||
sectionView(for: checkInConfig)
|
||||
}
|
||||
|
||||
if !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
|
||||
// Other cards — 2-column grid with drag-and-drop
|
||||
if !iPadGridSections.isEmpty {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible())],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(iPadGridSections) { config in
|
||||
sectionView(for: config)
|
||||
.gridCellColumns(config.columnSpan)
|
||||
.overlay {
|
||||
if dragTargetID == config.id {
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
.draggable(config.id)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let fromID = items.first else { return false }
|
||||
return handleIPadDrop(fromID: fromID, toID: config.id)
|
||||
} isTargeted: { isTargeted in
|
||||
dragTargetID = isTargeted ? config.id : nil
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
toggleColumnSpan(for: config.id)
|
||||
} label: {
|
||||
Label(
|
||||
config.columnSpan == 2 ? "Normal width" : "Expand to full width",
|
||||
systemImage: config.columnSpan == 2 ? "rectangle" : "rectangle.expand.diagonal"
|
||||
)
|
||||
}
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
fullScreenSection = DashboardSection(rawValue: config.id)
|
||||
}
|
||||
} label: {
|
||||
Label("View full screen", systemImage: "arrow.up.left.and.arrow.down.right")
|
||||
}
|
||||
}
|
||||
VStack(spacing: 16) {
|
||||
ForEach(visibleSections) { config in
|
||||
sectionView(for: config)
|
||||
if config.id == DashboardSection.monthlyCheckIn.id,
|
||||
!viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iPadFullScreenOverlay(for section: DashboardSection) -> some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.opacity(0.97)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
.font(.title3.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
fullScreenSection = nil
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
||||
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
||||
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
||||
|
||||
if let config = sectionConfigs.first(where: { $0.id == section.id }) {
|
||||
sectionView(for: DashboardSectionConfig(id: config.id, isVisible: true, isCollapsed: false))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: 800)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIPadDrop(fromID: String, toID: String) -> Bool {
|
||||
guard fromID != toID,
|
||||
let fromIndex = sectionConfigs.firstIndex(where: { $0.id == fromID }),
|
||||
let toIndex = sectionConfigs.firstIndex(where: { $0.id == toID }) else { return false }
|
||||
withAnimation {
|
||||
sectionConfigs.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
|
||||
}
|
||||
DashboardLayoutStore.save(sectionConfigs)
|
||||
dragTargetID = nil
|
||||
return true
|
||||
}
|
||||
|
||||
private func toggleColumnSpan(for id: String) {
|
||||
guard let index = sectionConfigs.firstIndex(where: { $0.id == id }) else { return }
|
||||
withAnimation {
|
||||
sectionConfigs[index].columnSpan = sectionConfigs[index].columnSpan == 2 ? 1 : 2
|
||||
}
|
||||
DashboardLayoutStore.save(sectionConfigs)
|
||||
.frame(maxWidth: 720)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -1464,52 +1339,51 @@ struct GoalsSummaryCard: View {
|
||||
isBehind: paceStatus?.isBehind ?? false,
|
||||
isAchieved: isAchieved
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
HStack(spacing: 14) {
|
||||
ProgressRing(
|
||||
progress: progressProvider(goal),
|
||||
tint: isAchieved ? .appSuccess : (paceStatus?.isBehind == true ? .appWarning : .appPrimary),
|
||||
size: 48,
|
||||
lineWidth: 5
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(goal.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
progress: progressProvider(goal),
|
||||
currentValue: currentValue,
|
||||
targetValue: goal.targetDecimal
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
HStack(spacing: 4) {
|
||||
Text(currentValue.compactCurrencyString)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text("of \(goal.targetDecimal.compactCurrencyString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target: \(targetDate.mediumDateString)")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
|
||||
}
|
||||
if let etaText = etaProvider(goal) {
|
||||
Text(etaText)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .appWarning : .positiveGreen)
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(progress: progressProvider(goal), tint: .appSecondary, iconColor: .appSecondary)
|
||||
|
||||
HStack {
|
||||
Text(currentValue.compactCurrencyString)
|
||||
.font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.compactCurrencyString)")
|
||||
Spacer()
|
||||
Button {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
progress: progressProvider(goal),
|
||||
currentValue: currentValue,
|
||||
targetValue: goal.targetDecimal
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target: \(targetDate.mediumDateString)")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
|
||||
}
|
||||
|
||||
if let etaText = etaProvider(goal) {
|
||||
Text(etaText)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user