1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import Charts
|
||||
|
||||
struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
var title: String = "Asset Allocation"
|
||||
var showsTargetsComparison: Bool = true
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
|
||||
@@ -12,7 +14,7 @@ struct AllocationPieChart: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Asset Allocation")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
@@ -98,7 +100,9 @@ struct AllocationPieChart: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
if showsTargetsComparison {
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
}
|
||||
} else {
|
||||
Text("No allocation data available")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -23,7 +23,6 @@ struct ChartsContainerView: View {
|
||||
|
||||
// Time Range Selector
|
||||
if viewModel.selectedChartType != .allocation &&
|
||||
viewModel.selectedChartType != .performance &&
|
||||
viewModel.selectedChartType != .riskReturn {
|
||||
timeRangeSelector
|
||||
}
|
||||
@@ -34,6 +33,17 @@ struct ChartsContainerView: View {
|
||||
categoryFilter
|
||||
}
|
||||
|
||||
// Source Filter
|
||||
if viewModel.selectedChartType == .evolution {
|
||||
sourceFilter
|
||||
}
|
||||
|
||||
// Breakdown Selector
|
||||
if viewModel.selectedChartType == .allocation ||
|
||||
viewModel.selectedChartType == .performance {
|
||||
breakdownSelector
|
||||
}
|
||||
|
||||
// Chart Content
|
||||
chartContent
|
||||
}
|
||||
@@ -101,7 +111,7 @@ struct ChartsContainerView: View {
|
||||
|
||||
private var timeRangeSelector: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.TimeRange.allCases) { range in
|
||||
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
||||
Button {
|
||||
viewModel.selectedTimeRange = range
|
||||
} label: {
|
||||
@@ -158,6 +168,7 @@ struct ChartsContainerView: View {
|
||||
ForEach(availableCategories) { category in
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
viewModel.selectedSource = nil
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
@@ -186,6 +197,91 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Filter
|
||||
|
||||
@ViewBuilder
|
||||
private var sourceFilter: some View {
|
||||
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
if !availableSources.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if availableSources.count > 1 {
|
||||
Button {
|
||||
viewModel.selectedSource = nil
|
||||
} label: {
|
||||
Text("All Sources")
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedSource == nil
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedSource == nil
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(availableSources, id: \.id) { source in
|
||||
Button {
|
||||
viewModel.selectedSource = source
|
||||
viewModel.selectedCategory = nil
|
||||
} label: {
|
||||
Text(source.name)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedSource?.id == source.id
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedSource?.id == source.id
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Breakdown Selector
|
||||
|
||||
private var breakdownSelector: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||||
Button {
|
||||
viewModel.selectedBreakdown = mode
|
||||
} label: {
|
||||
Text(mode.rawValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
viewModel.selectedBreakdown == mode
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedBreakdown == mode
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Content
|
||||
|
||||
@ViewBuilder
|
||||
@@ -204,9 +300,16 @@ struct ChartsContainerView: View {
|
||||
goals: goalsViewModel.goals
|
||||
)
|
||||
case .allocation:
|
||||
AllocationPieChart(data: viewModel.allocationData)
|
||||
AllocationPieChart(
|
||||
data: viewModel.allocationData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
||||
)
|
||||
case .performance:
|
||||
PerformanceBarChart(data: viewModel.performanceData)
|
||||
PerformanceBarChart(
|
||||
data: viewModel.performanceData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
||||
)
|
||||
case .contributions:
|
||||
ContributionsChartView(data: viewModel.contributionsData)
|
||||
case .rollingReturn:
|
||||
|
||||
@@ -3,10 +3,11 @@ import Charts
|
||||
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
var title: String = "Performance by Category"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
|
||||
@@ -160,7 +160,18 @@ struct DashboardView: View {
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||
isYearPositive: viewModel.isYearChangePositive,
|
||||
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0,
|
||||
onShareTap: {
|
||||
ShareService.shared.sharePortfolioValue(
|
||||
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
||||
changeText: calmModeEnabled
|
||||
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
||||
: viewModel.portfolioSummary.formattedDayChange,
|
||||
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
case .monthlyCheckIn:
|
||||
@@ -210,11 +221,12 @@ struct DashboardView: View {
|
||||
CategoryBreakdownCard(categories: viewModel.topCategories)
|
||||
}
|
||||
case .goals:
|
||||
let homeGoals = goalsViewModel.goals.filter { !GoalsViewModel.isAchieved(progress: goalsViewModel.progress(for: $0)) }
|
||||
if config.isCollapsed {
|
||||
CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active")
|
||||
CompactCard(title: "Goals", subtitle: "\(homeGoals.count) active")
|
||||
} else {
|
||||
GoalsSummaryCard(
|
||||
goals: goalsViewModel.goals,
|
||||
goals: homeGoals,
|
||||
progressProvider: goalsViewModel.progress(for:),
|
||||
currentValueProvider: goalsViewModel.totalValue(for:),
|
||||
paceStatusProvider: goalsViewModel.paceStatus(for:),
|
||||
@@ -266,12 +278,24 @@ struct TotalValueCard: View {
|
||||
var sinceInceptionChange: String?
|
||||
var isYearPositive: Bool = true
|
||||
var isSinceInceptionPositive: Bool = true
|
||||
var onShareTap: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Total Portfolio Value")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
ZStack(alignment: .trailing) {
|
||||
Text("Total Portfolio Value")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
if let onShareTap {
|
||||
Button(action: onShareTap) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
@@ -423,13 +447,6 @@ struct MonthlyCheckInCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
AchievementsView(referenceDate: Date())
|
||||
} label: {
|
||||
Image(systemName: "trophy.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
if let reminderDate {
|
||||
Button {
|
||||
let title = String(
|
||||
@@ -1053,6 +1070,12 @@ struct GoalsSummaryCard: View {
|
||||
ForEach(goals.prefix(2)) { goal in
|
||||
let currentValue = currentValueProvider(goal)
|
||||
let paceStatus = paceStatusProvider(goal)
|
||||
let isAchieved = GoalsViewModel.isAchieved(progress: progressProvider(goal))
|
||||
let targetUrgency = GoalsViewModel.urgencyLevel(
|
||||
targetDate: goal.targetDate,
|
||||
isBehind: paceStatus?.isBehind ?? false,
|
||||
isAchieved: isAchieved
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
@@ -1083,6 +1106,12 @@ struct GoalsSummaryCard: View {
|
||||
.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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MonthlyCheckInView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
||||
let referenceDate: Date
|
||||
@@ -13,6 +14,8 @@ struct MonthlyCheckInView: View {
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
@State private var addingSource: InvestmentSource?
|
||||
@State private var didApplyDuplicate = false
|
||||
@State private var showAchievementSatisfactionDialog = false
|
||||
@State private var showAppStoreReviewAlert = false
|
||||
|
||||
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
|
||||
self.referenceDate = referenceDate
|
||||
@@ -56,7 +59,7 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
|
||||
private var canAddNewCheckIn: Bool {
|
||||
lastCompletionDate == nil || checkInProgress >= 0.7
|
||||
true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -119,11 +122,36 @@ struct MonthlyCheckInView: View {
|
||||
.onChange(of: selectedMood) { _, newValue in
|
||||
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"How much are you enjoying Portfolio Journal?",
|
||||
isPresented: $showAchievementSatisfactionDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(1...5, id: \.self) { value in
|
||||
Button("\(value) Star\(value > 1 ? "s" : "")") {
|
||||
if value == 5 {
|
||||
showAppStoreReviewAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Not Now", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Congrats on your new achievement! Your feedback helps us improve.")
|
||||
}
|
||||
.alert("Would you like to leave an App Store review?", isPresented: $showAppStoreReviewAlert) {
|
||||
Button("Not Now", role: .cancel) {}
|
||||
Button("Write a Review") {
|
||||
ReviewPromptService.shared.markStoreReviewCompleted()
|
||||
openURL(ReviewPromptService.appStoreWriteReviewURL())
|
||||
}
|
||||
} message: {
|
||||
Text("Thanks for the 5 stars. It really helps other investors discover the app.")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This Month")
|
||||
Text(monthLabel)
|
||||
.font(.headline)
|
||||
|
||||
if let date = lastCompletionDate {
|
||||
@@ -162,6 +190,7 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
let previousUnlockedAchievementKeys = unlockedAchievementKeys()
|
||||
let now = Date()
|
||||
let completionDate = referenceDate.isSameMonth(as: now)
|
||||
? now
|
||||
@@ -169,6 +198,12 @@ struct MonthlyCheckInView: View {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
|
||||
ReviewPromptService.shared.recordMonthlyCheckInCompleted()
|
||||
viewModel.refresh()
|
||||
let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys)
|
||||
if ReviewPromptService.shared.shouldAskForAchievementSatisfaction(
|
||||
newlyUnlockedAchievementKeys: newlyUnlockedAchievementKeys
|
||||
) {
|
||||
showAchievementSatisfactionDialog = true
|
||||
}
|
||||
} label: {
|
||||
Text("Mark Check-in Complete")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -178,12 +213,6 @@ struct MonthlyCheckInView: View {
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.disabled(!canAddNewCheckIn)
|
||||
|
||||
if !canAddNewCheckIn {
|
||||
Text("Editing stays open. New check-ins unlock after 70% of the month.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
@@ -191,6 +220,27 @@ struct MonthlyCheckInView: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var monthLabel: String {
|
||||
Self.monthLabel(for: referenceDate, relativeTo: Date(), locale: .current)
|
||||
}
|
||||
|
||||
private func unlockedAchievementKeys() -> Set<String> {
|
||||
Set(
|
||||
MonthlyCheckInStore
|
||||
.achievementStatuses(referenceDate: referenceDate)
|
||||
.filter(\.isUnlocked)
|
||||
.map(\.id)
|
||||
)
|
||||
}
|
||||
|
||||
static func monthLabel(for date: Date, relativeTo referenceDate: Date, locale: Locale) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "LLLL yyyy"
|
||||
formatter.locale = locale
|
||||
let effectiveMonth = MonthlyCheckInStore.effectiveMonth(for: date, relativeTo: referenceDate)
|
||||
return formatter.string(from: effectiveMonth)
|
||||
}
|
||||
|
||||
private var reflectionCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
@@ -457,11 +507,7 @@ struct MonthlyCheckInView: View {
|
||||
|
||||
private func shareMonthlyCheckIn() {
|
||||
let summary = viewModel.monthlySummary
|
||||
let text = ShareService.buildMonthlyCheckInShareText(
|
||||
summary: summary,
|
||||
appName: appDisplayName
|
||||
)
|
||||
ShareService.shared.shareText(text)
|
||||
ShareService.shared.shareMonthlyCheckIn(summary: summary, appName: appDisplayName)
|
||||
}
|
||||
|
||||
private var appDisplayName: String {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MonthlyCheckInShareCardView: View {
|
||||
let summary: MonthlySummary
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(summary.periodLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
Text("Monthly Check-in")
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
metricRow("Starting", summary.formattedStartingValue)
|
||||
metricRow("Ending", summary.formattedEndingValue)
|
||||
metricRow("Contributions", summary.formattedContributions)
|
||||
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
||||
|
||||
Spacer(minLength: 0)
|
||||
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 300)
|
||||
.background(LinearGradient.appPrimaryGradient)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioValueShareCardView: View {
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
let changeLabel: String
|
||||
let yearChange: String?
|
||||
let sinceInceptionChange: String?
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Portfolio Snapshot")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
Text("Total Portfolio Value")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("\(changeText) \(changeLabel)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
if let yearChange {
|
||||
metricRow("YoY", yearChange)
|
||||
}
|
||||
|
||||
if let sinceInceptionChange {
|
||||
metricRow("Since inception", sinceInceptionChange)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 290)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appSecondary, Color.appPrimary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func metricRow(_ title: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Powered by")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let qrCodeImage {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
} else {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.title3)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ struct GoalsView: View {
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
@State private var showAchievedGoals = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -12,11 +13,15 @@ struct GoalsView: View {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
if filteredGoals.isEmpty {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
hiddenAchievedState
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(viewModel.goals) { goal in
|
||||
ForEach(filteredGoals) { goal in
|
||||
GoalRowView(
|
||||
goal: goal,
|
||||
progress: viewModel.progress(for: goal),
|
||||
@@ -48,6 +53,12 @@ struct GoalsView: View {
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") {
|
||||
showAchievedGoals.toggle()
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
@@ -78,6 +89,11 @@ struct GoalsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredGoals: [Goal] {
|
||||
guard !showAchievedGoals else { return viewModel.goals }
|
||||
return viewModel.goals.filter { !viewModel.isAchieved($0) }
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
@@ -93,6 +109,22 @@ struct GoalsView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
private var hiddenAchievedState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "party.popper")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.appSecondary)
|
||||
Text("All visible goals are achieved")
|
||||
.font(.headline)
|
||||
Text("Tap \"Show Achieved\" to review completed goals.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalRowView: View {
|
||||
@@ -105,14 +137,53 @@ struct GoalRowView: View {
|
||||
|
||||
@State private var showingShareOptions = false
|
||||
|
||||
private var isAchieved: Bool {
|
||||
GoalsViewModel.isAchieved(progress: progress)
|
||||
}
|
||||
|
||||
private var targetUrgency: GoalUrgencyLevel {
|
||||
GoalsViewModel.urgencyLevel(
|
||||
targetDate: goal.targetDate,
|
||||
isBehind: paceStatus?.isBehind ?? false,
|
||||
isAchieved: isAchieved
|
||||
)
|
||||
}
|
||||
|
||||
private var targetDateColor: Color {
|
||||
switch targetUrgency {
|
||||
case .normal:
|
||||
return .secondary
|
||||
case .warning:
|
||||
return .appWarning
|
||||
case .critical:
|
||||
return .negativeRed
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Button(action: onEdit) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
||||
GoalProgressBar(
|
||||
progress: progress,
|
||||
tint: isAchieved ? .appSuccess : .appSecondary,
|
||||
iconColor: isAchieved ? .appSuccess : .appSecondary
|
||||
)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
@@ -126,16 +197,25 @@ struct GoalRowView: View {
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(targetDateColor)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
.foregroundColor(
|
||||
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user