Release 1.4.0 (build 31): retención, quick update, streak, what's new

- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes)
- 1B: Badge de racha mensual en Dashboard (streak counter)
- 1C: Empty states mejorados en Goals y Journal con CTAs claros
- 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla
- 2B: Notificación batch update redirige al Quick Update sheet
- 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update
- 3A: App Store version check banner en Settings
- 3C: What's New sheet en primer launch de versión nueva
- Localización completa en 7 idiomas para todas las nuevas strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-21 23:09:36 +02:00
parent 773da6800b
commit b48a47ce10
41 changed files with 2659 additions and 483 deletions
@@ -390,6 +390,7 @@ struct AddSnapshotView: View {
let snapshot: Snapshot?
@StateObject private var viewModel: SnapshotFormViewModel
@State private var showingDuplicateAlert = false
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
self.source = source
@@ -515,10 +516,47 @@ struct AddSnapshotView: View {
viewModel.checkClipboard()
}
}
.alert(
String(localized: "snapshot_duplicate_title"),
isPresented: $showingDuplicateAlert
) {
Button(String(localized: "snapshot_duplicate_replace"), role: .destructive) {
performSave()
}
Button(String(localized: "snapshot_duplicate_add")) {
performSave()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(String(localized: "snapshot_duplicate_message"))
}
}
}
private func saveSnapshot() {
guard snapshot == nil else {
performSave()
return
}
// Check for existing snapshot in the same month
let calendar = Calendar.current
let selectedMonth = calendar.dateComponents([.year, .month], from: viewModel.date)
let repo = SnapshotRepository()
let existing = repo.fetchSnapshots(for: source, limitedToMonths: nil)
let hasDuplicate = existing.contains { snap in
let snapMonth = calendar.dateComponents([.year, .month], from: snap.date)
return snapMonth == selectedMonth
}
if hasDuplicate {
showingDuplicateAlert = true
} else {
performSave()
}
}
private func performSave() {
guard let value = viewModel.value else { return }
let repository = SnapshotRepository()
@@ -536,6 +574,11 @@ struct AddSnapshotView: View {
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
} else {
// Capture pre-save goal state for achievement detection
let goalRepo = GoalRepository()
let sourceRepo = InvestmentSourceRepository()
let prevAchievedIds = goalsAchievedBeforeSave(goalRepo: goalRepo, sourceRepo: sourceRepo)
repository.createSnapshot(
for: source,
date: viewModel.date,
@@ -543,6 +586,9 @@ struct AddSnapshotView: View {
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
)
// Check if any goals became achieved
checkGoalAchievements(goalRepo: goalRepo, sourceRepo: sourceRepo, prevAchievedIds: prevAchievedIds)
}
// Reschedule notification
@@ -553,6 +599,46 @@ struct AddSnapshotView: View {
dismiss()
}
private func goalsAchievedBeforeSave(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository) -> Set<UUID> {
let goals = goalRepo.goals.filter { $0.isActive }
var achieved = Set<UUID>()
for goal in goals {
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
achieved.insert(goal.id)
}
}
return achieved
}
private func checkGoalAchievements(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository, prevAchievedIds: Set<UUID>) {
// Reload sources to pick up new snapshot value
let goals = goalRepo.goals.filter { $0.isActive }
for goal in goals {
guard !prevAchievedIds.contains(goal.id) else { continue }
let currentValue: Decimal
if let accountId = goal.account?.safeId {
currentValue = sourceRepo.sources
.filter { $0.account?.id == accountId }
.reduce(Decimal.zero) { $0 + $1.latestValue }
} else {
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
let target = goal.targetDecimal
if target > 0 && currentValue / target >= 1 {
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
}
}
}
}
#Preview {