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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user