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:
alexandrev-tibco
2026-02-11 23:37:11 +01:00
parent daaca95913
commit 4761e2e5c8
19 changed files with 1118 additions and 90 deletions
@@ -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))
}
}
}
}
+88 -8
View File
@@ -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)