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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user