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
@@ -0,0 +1,36 @@
import Foundation
import Combine
@MainActor
class AppUpdateService: ObservableObject {
static let shared = AppUpdateService()
@Published var updateAvailable = false
@Published var latestVersion: String?
private init() {}
func checkForUpdate() {
guard let bundleId = Bundle.main.bundleIdentifier else { return }
let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
guard let url = URL(string: urlString) else { return }
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let first = results.first,
let appStoreVersion = first["version"] as? String,
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
else { return }
if appStoreVersion.compare(currentVersion, options: .numeric) == .orderedDescending {
self.updateAvailable = true
self.latestVersion = appStoreVersion
}
} catch {
// silently fail
}
}
}
}
@@ -655,6 +655,184 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
]
}
// MARK: - CSV Column Mapping
/// Parsed preview data for the mapping UI
struct CSVPreview {
let rows: [[String]]
let columnCount: Int
func headers(hasHeaderRow: Bool) -> [String] {
if hasHeaderRow, let first = rows.first {
return first
}
return (0..<columnCount).map { "Column \($0 + 1)" }
}
func sampleRow(hasHeaderRow: Bool) -> [String]? {
let start = hasHeaderRow ? 1 : 0
return rows.count > start ? rows[start] : nil
}
}
func previewCSV(_ content: String) -> CSVPreview {
let rows = parseCSVRows(content)
let columnCount = rows.first?.count ?? 0
return CSVPreview(rows: rows, columnCount: columnCount)
}
// Maps each app field to a CSV column index or a constant value
struct CSVMappingConfig {
// -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index
static let notMapped = -1
static let constant = -2
static let useToday = -3
var hasHeaderRow: Bool = true
var sourceIndex: Int = notMapped
var sourceConstant: String = ""
var valueIndex: Int = notMapped
var dateIndex: Int = useToday // default: use today
var categoryIndex: Int = constant
var categoryConstant: String = "Other" // default category name when constant
var contributionIndex: Int = notMapped
var notesIndex: Int = notMapped
var isValid: Bool {
let sourceOk: Bool = {
if sourceIndex == Self.constant { return !sourceConstant.isEmpty }
return sourceIndex >= 0
}()
let valueOk = valueIndex >= 0
return sourceOk && valueOk
}
}
func importCSVWithMapping(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> ImportResult {
let preview = previewCSV(content)
let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
return applyImport(accounts, context: CoreDataStack.shared.viewContext)
}
func importCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
let totalSnapshots = accounts.reduce(0) { t, a in
t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } }
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(accounts, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
private func parseCSVWithMapping(
preview: CSVPreview,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> [ImportedAccount] {
let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows
let fallbackAccount = defaultAccountName ?? "Personal"
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in dataRows {
// Source name
let sourceName: String
if mapping.sourceIndex == CSVMappingConfig.constant {
guard !mapping.sourceConstant.isEmpty else { continue }
sourceName = mapping.sourceConstant
} else if mapping.sourceIndex >= 0 {
guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue }
sourceName = v
} else {
continue
}
// Value
guard mapping.valueIndex >= 0,
let valStr = row.safeValue(at: mapping.valueIndex),
let value = parseDecimal(valStr) else { continue }
// Date
let date: Date
if mapping.dateIndex == CSVMappingConfig.useToday {
date = Calendar.current.startOfDay(for: Date())
} else if mapping.dateIndex >= 0,
let dateStr = row.safeValue(at: mapping.dateIndex),
let parsed = parseDate(dateStr) {
date = parsed
} else {
date = Calendar.current.startOfDay(for: Date())
}
// Category
let categoryName: String
if mapping.categoryIndex == CSVMappingConfig.constant {
categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant
} else if mapping.categoryIndex >= 0,
let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty {
categoryName = v
} else {
categoryName = "Other"
}
// Contribution
let contribution: Decimal? = mapping.contributionIndex >= 0
? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal)
: nil
// Notes
let notes: String? = mapping.notesIndex >= 0
? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 }
: nil
let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes)
grouped[fallbackAccount, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {
@@ -31,6 +31,9 @@ class NotificationService: ObservableObject {
await MainActor.run {
self.isAuthorized = granted
}
if granted {
scheduleMonthlyPerformanceSummary()
}
return granted
} catch {
print("Notification authorization error: \(error)")
@@ -155,6 +158,18 @@ extension NotificationService {
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
// Handle batch update deep link from monthly check-in
if let action = userInfo["action"] as? String, action == "batchUpdate" {
NotificationCenter.default.post(name: .openBatchUpdate, object: nil)
return
}
// Handle openDashboard deep link from monthly summary notification
if let action = userInfo["action"] as? String, action == "openDashboard" {
NotificationCenter.default.post(name: .openDashboard, object: nil)
return
}
guard let sourceIdString = userInfo["sourceId"] as? String,
let sourceId = UUID(uuidString: sourceIdString) else {
return
@@ -174,6 +189,9 @@ extension NotificationService {
extension Notification.Name {
static let openSourceDetail = Notification.Name("openSourceDetail")
static let didResetData = Notification.Name("didResetData")
static let openBatchUpdate = Notification.Name("openBatchUpdate")
static let openDashboard = Notification.Name("openDashboard")
static let openQuickUpdate = Notification.Name("openQuickUpdate")
}
// MARK: - Re-engagement Notifications
@@ -214,6 +232,7 @@ extension NotificationService {
content.title = String(localized: "monthly_checkin_notification_title")
content.body = String(localized: "monthly_checkin_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
var components = DateComponents()
components.day = 1
@@ -230,6 +249,56 @@ extension NotificationService {
}
}
}
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
func scheduleMonthlyPerformanceSummary() {
guard isAuthorized else { return }
let identifier = "monthly_summary"
center.getPendingNotificationRequests { [weak self] requests in
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "monthly_summary_notification_title")
content.body = String(localized: "monthly_summary_notification_body")
content.sound = .default
content.userInfo = ["action": "openDashboard"]
var components = DateComponents()
components.day = 5
components.hour = 9
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
self.center.add(request) { error in
if let error = error {
print("Monthly summary notification error: \(error)")
}
}
}
}
/// Fires a one-time local notification celebrating goal achievement.
func scheduleGoalAchievedNotification(goalName: String) {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "goal_achieved_notification_title")
content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName)
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let id = "goal_achieved_\(UUID().uuidString)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Goal achieved notification error: \(error)")
}
}
}
}
// MARK: - Background Refresh