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:
alexandrev-tibco
2026-07-09 11:11:04 +02:00
parent 7044cfd441
commit ed737bc290
13 changed files with 203 additions and 259 deletions
+10 -10
View File
@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -582,7 +582,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -739,7 +739,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -774,7 +774,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -805,7 +805,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -829,7 +829,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -852,7 +852,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -875,7 +875,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -899,7 +899,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
@@ -927,7 +927,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 52;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
@@ -488,3 +488,4 @@
"guided_streak" = "%d Monate in Folge";
"guided_share_month" = "Diesen Monat teilen";
"guided_done_cta" = "Fertig";
"journal_no_note" = "Noch keine Notiz.";
@@ -489,3 +489,4 @@
"guided_streak" = "%d months in a row";
"guided_share_month" = "Share this month";
"guided_done_cta" = "Done";
"journal_no_note" = "No note yet.";
@@ -440,3 +440,4 @@
"guided_streak" = "%d meses seguidos";
"guided_share_month" = "Compartir este mes";
"guided_done_cta" = "Hecho";
"journal_no_note" = "Sin nota todavía.";
@@ -488,3 +488,4 @@
"guided_streak" = "%d mois d'affilée";
"guided_share_month" = "Partager ce mois";
"guided_done_cta" = "Terminé";
"journal_no_note" = "Pas encore de note.";
@@ -488,3 +488,4 @@
"guided_streak" = "%d mesi di fila";
"guided_share_month" = "Condividi questo mese";
"guided_done_cta" = "Fatto";
"journal_no_note" = "Ancora nessuna nota.";
@@ -488,3 +488,4 @@
"guided_streak" = "%dか月連続";
"guided_share_month" = "今月をシェア";
"guided_done_cta" = "完了";
"journal_no_note" = "まだメモがありません。";
@@ -488,3 +488,4 @@
"guided_streak" = "%d meses seguidos";
"guided_share_month" = "Compartilhar este mês";
"guided_done_cta" = "Concluído";
"journal_no_note" = "Ainda sem nota.";
@@ -0,0 +1,30 @@
import SwiftUI
/// Fitness-style progress ring: track + rounded-cap arc + centered percentage.
/// Color is semantic (pace/achievement), passed by the caller.
struct ProgressRing: View {
let progress: Double
var tint: Color = .appPrimary
var size: CGFloat = 56
var lineWidth: CGFloat = 6
private var clamped: Double { min(max(progress, 0), 1) }
var body: some View {
ZStack {
Circle()
.stroke(tint.opacity(0.15), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: clamped)
.stroke(tint, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.6), value: clamped)
Text("\(Int((clamped * 100).rounded()))%")
.font(.system(size: size * 0.26, weight: .bold, design: .rounded))
.foregroundColor(tint)
.minimumScaleFactor(0.7)
}
.frame(width: size, height: size)
.accessibilityLabel(Text("\(Int((clamped * 100).rounded()))%"))
}
}
@@ -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)
}
}
}
+48 -49
View File
@@ -244,62 +244,61 @@ struct GoalRowView: View {
}
}
/// Semantic ring color: green when achieved or ahead, orange when behind,
/// accent while simply on the way.
private var ringTint: Color {
if isAchieved { return .appSuccess }
if paceStatus?.isBehind == true { return .appWarning }
return .appPrimary
}
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: onEdit) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(goal.name)
.font(.headline)
.foregroundColor(isAchieved ? .appSecondary : .primary)
if isAchieved {
Text("Achieved")
.font(.caption2.weight(.bold))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appSecondary)
.clipShape(Capsule())
HStack(spacing: 16) {
ProgressRing(progress: progress, tint: ringTint)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(goal.name)
.font(.headline)
if isAchieved {
Text("Achieved")
.font(.caption2.weight(.bold))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appSuccess)
.clipShape(Capsule())
}
}
HStack(spacing: 4) {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)")
.font(.caption)
.foregroundColor(targetDateColor)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption.weight(.semibold))
.foregroundColor(
isAchieved ? .appSuccess : (paceStatus.isBehind ? .appWarning : .positiveGreen)
)
}
}
GoalProgressBar(
progress: progress,
tint: isAchieved ? .appSuccess : .appSecondary,
iconColor: isAchieved ? .appSuccess : .appSecondary
)
HStack {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
Spacer()
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)")
.font(.caption)
.foregroundColor(targetDateColor)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption.weight(.semibold))
.foregroundColor(
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1)
)
.cornerRadius(12)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
@@ -63,6 +63,17 @@ struct JournalView: View {
AppBackground()
List {
// Calm 2.0: the habit story (streaks, achievements) lives in
// the Journal this is the emotional home of the ritual.
if !viewModel.monthlyNotes.isEmpty && searchText.isEmpty {
Section {
MomentumStreaksCard()
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listSectionSeparator(.hidden)
}
Section("Monthly Check-ins") {
if filteredMonthlyNotes.isEmpty {
if searchText.isEmpty {
@@ -183,36 +194,42 @@ struct JournalView: View {
}
}
/// Timeline-style row: the mood is the visual anchor of each month.
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(entry.date.monthYearString)
.font(.subheadline.weight(.semibold))
Spacer()
HStack(alignment: .top, spacing: 12) {
ZStack {
Circle()
.fill((entry.mood?.tint ?? Color.secondary).opacity(0.14))
.frame(width: 40, height: 40)
Image(systemName: entry.mood?.iconName ?? "book.closed")
.font(.system(size: 16))
.foregroundColor(entry.mood?.tint ?? .secondary)
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.date.monthYearString)
.font(.subheadline.weight(.semibold))
Spacer()
if entry.rating != nil {
starRow(rating: entry.rating)
.font(.caption2)
}
}
if let mood = entry.mood {
moodPill(mood)
} else {
Text("Mood not set")
.font(.caption)
.foregroundColor(.secondary)
Text(mood.title)
.font(.caption.weight(.medium))
.foregroundColor(mood.tint)
}
}
HStack(spacing: 6) {
starRow(rating: entry.rating)
if entry.rating == nil {
Text("No rating")
.font(.caption)
.foregroundColor(.secondary)
}
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
Text(entry.note.isEmpty ? "No note yet." : entry.note)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
.padding(.vertical, 4)
.padding(.vertical, 6)
}
private func starRow(rating: Int?) -> some View {
@@ -339,6 +356,19 @@ struct MonthlyNoteEditorView: View {
}
}
extension MonthlyCheckInMood {
/// Semantic tint per mood the timeline's color language.
var tint: Color {
switch self {
case .energized: return .orange
case .confident: return .positiveGreen
case .balanced: return .teal
case .cautious: return .yellow
case .stressed: return .negativeRed
}
}
}
#Preview {
NavigationStack {
JournalView()
@@ -503,9 +503,13 @@ struct SourceRowView: View {
.foregroundColor(.secondary)
if source.needsUpdate {
Label("Needs update", systemImage: "bell.badge.fill")
.font(.caption2)
Label("Needs update", systemImage: "clock.fill")
.font(.caption2.weight(.semibold))
.foregroundColor(.appWarning)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appWarning.opacity(0.12))
.clipShape(Capsule())
}
}
}
@@ -515,7 +519,7 @@ struct SourceRowView: View {
// Value
VStack(alignment: .trailing, spacing: 4) {
Text(source.latestValue.compactCurrencyString)
.font(.subheadline.weight(.semibold))
.font(.system(.subheadline, design: .rounded).weight(.semibold))
HStack(spacing: 2) {
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")