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;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -739,7 +739,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -774,7 +774,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -805,7 +805,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -852,7 +852,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -899,7 +899,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||||
@@ -927,7 +927,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 52;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
|||||||
@@ -488,3 +488,4 @@
|
|||||||
"guided_streak" = "%d Monate in Folge";
|
"guided_streak" = "%d Monate in Folge";
|
||||||
"guided_share_month" = "Diesen Monat teilen";
|
"guided_share_month" = "Diesen Monat teilen";
|
||||||
"guided_done_cta" = "Fertig";
|
"guided_done_cta" = "Fertig";
|
||||||
|
"journal_no_note" = "Noch keine Notiz.";
|
||||||
|
|||||||
@@ -489,3 +489,4 @@
|
|||||||
"guided_streak" = "%d months in a row";
|
"guided_streak" = "%d months in a row";
|
||||||
"guided_share_month" = "Share this month";
|
"guided_share_month" = "Share this month";
|
||||||
"guided_done_cta" = "Done";
|
"guided_done_cta" = "Done";
|
||||||
|
"journal_no_note" = "No note yet.";
|
||||||
|
|||||||
@@ -440,3 +440,4 @@
|
|||||||
"guided_streak" = "%d meses seguidos";
|
"guided_streak" = "%d meses seguidos";
|
||||||
"guided_share_month" = "Compartir este mes";
|
"guided_share_month" = "Compartir este mes";
|
||||||
"guided_done_cta" = "Hecho";
|
"guided_done_cta" = "Hecho";
|
||||||
|
"journal_no_note" = "Sin nota todavía.";
|
||||||
|
|||||||
@@ -488,3 +488,4 @@
|
|||||||
"guided_streak" = "%d mois d'affilée";
|
"guided_streak" = "%d mois d'affilée";
|
||||||
"guided_share_month" = "Partager ce mois";
|
"guided_share_month" = "Partager ce mois";
|
||||||
"guided_done_cta" = "Terminé";
|
"guided_done_cta" = "Terminé";
|
||||||
|
"journal_no_note" = "Pas encore de note.";
|
||||||
|
|||||||
@@ -488,3 +488,4 @@
|
|||||||
"guided_streak" = "%d mesi di fila";
|
"guided_streak" = "%d mesi di fila";
|
||||||
"guided_share_month" = "Condividi questo mese";
|
"guided_share_month" = "Condividi questo mese";
|
||||||
"guided_done_cta" = "Fatto";
|
"guided_done_cta" = "Fatto";
|
||||||
|
"journal_no_note" = "Ancora nessuna nota.";
|
||||||
|
|||||||
@@ -488,3 +488,4 @@
|
|||||||
"guided_streak" = "%dか月連続";
|
"guided_streak" = "%dか月連続";
|
||||||
"guided_share_month" = "今月をシェア";
|
"guided_share_month" = "今月をシェア";
|
||||||
"guided_done_cta" = "完了";
|
"guided_done_cta" = "完了";
|
||||||
|
"journal_no_note" = "まだメモがありません。";
|
||||||
|
|||||||
@@ -488,3 +488,4 @@
|
|||||||
"guided_streak" = "%d meses seguidos";
|
"guided_streak" = "%d meses seguidos";
|
||||||
"guided_share_month" = "Compartilhar este mês";
|
"guided_share_month" = "Compartilhar este mês";
|
||||||
"guided_done_cta" = "Concluído";
|
"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 showingCustomize = false
|
||||||
@State private var sectionConfigs = DashboardLayoutStore.load()
|
@State private var sectionConfigs = DashboardLayoutStore.load()
|
||||||
@AppStorage("showForecast") private var showForecast = true
|
@AppStorage("showForecast") private var showForecast = true
|
||||||
@State private var fullScreenSection: DashboardSection? = nil
|
|
||||||
@State private var dragTargetID: String? = nil
|
|
||||||
@State private var showingQuickUpdate = false
|
@State private var showingQuickUpdate = false
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@@ -53,14 +51,7 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.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")
|
.navigationTitle("Home")
|
||||||
.refreshable {
|
.refreshable {
|
||||||
viewModel.refreshData()
|
viewModel.refreshData()
|
||||||
@@ -174,135 +165,19 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
// MARK: - iPad Layout Helpers
|
// 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
|
@ViewBuilder
|
||||||
private var iPadDashboardLayout: some View {
|
private var iPadDashboardLayout: some View {
|
||||||
// Total Portfolio Value — always full width, never in the grid
|
VStack(spacing: 16) {
|
||||||
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
ForEach(visibleSections) { config in
|
||||||
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
sectionView(for: config)
|
||||||
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
if config.id == DashboardSection.monthlyCheckIn.id,
|
||||||
|
!viewModel.insights.isEmpty {
|
||||||
if let checkInConfig = visibleSections.first(where: { $0.id == DashboardSection.monthlyCheckIn.id }) {
|
InsightsRow(insights: viewModel.insights)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.frame(maxWidth: 720)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
@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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -1464,52 +1339,51 @@ struct GoalsSummaryCard: View {
|
|||||||
isBehind: paceStatus?.isBehind ?? false,
|
isBehind: paceStatus?.isBehind ?? false,
|
||||||
isAchieved: isAchieved
|
isAchieved: isAchieved
|
||||||
)
|
)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
HStack(spacing: 14) {
|
||||||
HStack {
|
ProgressRing(
|
||||||
|
progress: progressProvider(goal),
|
||||||
|
tint: isAchieved ? .appSuccess : (paceStatus?.isBehind == true ? .appWarning : .appPrimary),
|
||||||
|
size: 48,
|
||||||
|
lineWidth: 5
|
||||||
|
)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(goal.name)
|
Text(goal.name)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
Spacer()
|
HStack(spacing: 4) {
|
||||||
Button {
|
Text(currentValue.compactCurrencyString)
|
||||||
GoalShareService.shared.shareGoal(
|
.font(.caption.weight(.semibold))
|
||||||
name: goal.name,
|
Text("of \(goal.targetDecimal.compactCurrencyString)")
|
||||||
progress: progressProvider(goal),
|
|
||||||
currentValue: currentValue,
|
|
||||||
targetValue: goal.targetDecimal
|
|
||||||
)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
.font(.caption)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
GoalProgressBar(progress: progressProvider(goal), tint: .appSecondary, iconColor: .appSecondary)
|
Button {
|
||||||
|
GoalShareService.shared.shareGoal(
|
||||||
HStack {
|
name: goal.name,
|
||||||
Text(currentValue.compactCurrencyString)
|
progress: progressProvider(goal),
|
||||||
.font(.caption.weight(.semibold))
|
currentValue: currentValue,
|
||||||
Spacer()
|
targetValue: goal.targetDecimal
|
||||||
Text("of \(goal.targetDecimal.compactCurrencyString)")
|
)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.appPrimary)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Button(action: onEdit) {
|
Button(action: onEdit) {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
HStack(spacing: 16) {
|
||||||
HStack {
|
ProgressRing(progress: progress, tint: ringTint)
|
||||||
Text(goal.name)
|
|
||||||
.font(.headline)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.foregroundColor(isAchieved ? .appSecondary : .primary)
|
HStack(spacing: 8) {
|
||||||
if isAchieved {
|
Text(goal.name)
|
||||||
Text("Achieved")
|
.font(.headline)
|
||||||
.font(.caption2.weight(.bold))
|
if isAchieved {
|
||||||
.foregroundColor(.white)
|
Text("Achieved")
|
||||||
.padding(.horizontal, 8)
|
.font(.caption2.weight(.bold))
|
||||||
.padding(.vertical, 4)
|
.foregroundColor(.white)
|
||||||
.background(Color.appSecondary)
|
.padding(.horizontal, 8)
|
||||||
.clipShape(Capsule())
|
.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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(12)
|
.padding(.vertical, 8)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ struct JournalView: View {
|
|||||||
AppBackground()
|
AppBackground()
|
||||||
|
|
||||||
List {
|
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") {
|
Section("Monthly Check-ins") {
|
||||||
if filteredMonthlyNotes.isEmpty {
|
if filteredMonthlyNotes.isEmpty {
|
||||||
if searchText.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 {
|
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
HStack {
|
ZStack {
|
||||||
Text(entry.date.monthYearString)
|
Circle()
|
||||||
.font(.subheadline.weight(.semibold))
|
.fill((entry.mood?.tint ?? Color.secondary).opacity(0.14))
|
||||||
Spacer()
|
.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 {
|
if let mood = entry.mood {
|
||||||
moodPill(mood)
|
Text(mood.title)
|
||||||
} else {
|
.font(.caption.weight(.medium))
|
||||||
Text("Mood not set")
|
.foregroundColor(mood.tint)
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
|
||||||
starRow(rating: entry.rating)
|
.font(.subheadline)
|
||||||
if entry.rating == nil {
|
.foregroundColor(.secondary)
|
||||||
Text("No rating")
|
.lineLimit(2)
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
JournalView()
|
JournalView()
|
||||||
|
|||||||
@@ -503,9 +503,13 @@ struct SourceRowView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
if source.needsUpdate {
|
if source.needsUpdate {
|
||||||
Label("Needs update", systemImage: "bell.badge.fill")
|
Label("Needs update", systemImage: "clock.fill")
|
||||||
.font(.caption2)
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundColor(.appWarning)
|
.foregroundColor(.appWarning)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.appWarning.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,7 +519,7 @@ struct SourceRowView: View {
|
|||||||
// Value
|
// Value
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
Text(source.latestValue.compactCurrencyString)
|
Text(source.latestValue.compactCurrencyString)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||||
|
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||||
|
|||||||
Reference in New Issue
Block a user