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
@@ -34,6 +34,10 @@ struct ImportDataView: View {
@State private var newAccountName = ""
@State private var accountErrorMessage: String?
// CSV column mapping flow
@State private var pendingCSVContent: String?
@State private var showingCSVMapping = false
private let accountRepository = AccountRepository()
private var availableAccounts: [Account] {
@@ -127,6 +131,13 @@ struct ImportDataView: View {
) { result in
handleImport(result)
}
.sheet(isPresented: $showingCSVMapping) {
if let content = pendingCSVContent {
CSVMappingView(csvContent: content) { mapping in
performMappedCSVImport(content: content, mapping: mapping)
}
}
}
.alert(
"Import Complete",
isPresented: Binding(
@@ -366,12 +377,8 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
handleImportContent(content)
}
private func handleImportContent(_ content: String) {
// For non-Premium users or when only one account exists, use the Default account
// For Premium users with multiple accounts, respect their selection
private func resolvedAccountName() -> String {
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
let allowMultipleAccounts = false // Always import into a single account
var defaultAccountName = accountStore.accounts.first(where: { $0.isDefaultAccount })?.name
?? accountStore.selectedAccount?.name
?? accountStore.accounts.first?.name
@@ -379,23 +386,64 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
if useAccountSelection {
if accountSelection == .new {
accountErrorMessage = validateNewAccountName()
guard accountErrorMessage == nil else { return }
let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines)
let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let account = accountRepository.createAccount(
name: trimmed,
currency: currency,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1
)
defaultAccountName = account.name
if !trimmed.isEmpty {
let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let account = accountRepository.createAccount(
name: trimmed,
currency: currency,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1
)
defaultAccountName = account.name
}
} else {
defaultAccountName = selectedAccountName ?? defaultAccountName
}
}
return defaultAccountName
}
private func handleImportContent(_ content: String) {
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
if useAccountSelection && accountSelection == .new {
accountErrorMessage = validateNewAccountName()
guard accountErrorMessage == nil else { return }
}
// CSV show column mapping sheet first
if selectedFormat == .csv {
pendingCSVContent = content
showingCSVMapping = true
return
}
// JSON import directly
runImport(content: content, format: .json, defaultAccountName: resolvedAccountName())
}
private func performMappedCSVImport(content: String, mapping: ImportService.CSVMappingConfig) {
let defaultAccountName = resolvedAccountName()
isImporting = true
importProgress = 0
importStatus = "Parsing file"
Task {
let importResult = await ImportService.shared.importCSVWithMappingAsync(
content: content,
mapping: mapping,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
finishImport(importResult)
}
}
private func runImport(content: String, format: ImportService.ImportFormat, defaultAccountName: String) {
isImporting = true
importProgress = 0
importStatus = "Parsing file"
@@ -403,27 +451,30 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
Task {
let importResult = await ImportService.shared.importDataAsync(
content: content,
format: selectedFormat,
allowMultipleAccounts: allowMultipleAccounts,
format: format,
allowMultipleAccounts: false,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
finishImport(importResult)
}
}
isImporting = false
private func finishImport(_ importResult: ImportService.ImportResult) {
isImporting = false
if importResult.errors.isEmpty {
var message = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
if importResult.snapshotsUpdated > 0 {
message += " Updated \(importResult.snapshotsUpdated) existing snapshots."
}
resultMessage = message
tabSelection.selectedTab = 0
dismiss()
} else {
errorMessage = importResult.errors.joined(separator: "\n")
if importResult.errors.isEmpty {
var message = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
if importResult.snapshotsUpdated > 0 {
message += " Updated \(importResult.snapshotsUpdated) existing snapshots."
}
resultMessage = message
tabSelection.selectedTab = 0
dismiss()
} else {
errorMessage = importResult.errors.joined(separator: "\n")
}
}
@@ -15,6 +15,7 @@ struct SettingsView: View {
@AppStorage("lockOnBackground") private var lockOnBackground = false
@ObservedObject private var cloudStack = CoreDataStack.shared
@ObservedObject private var updateService = AppUpdateService.shared
@State private var showingPinSetup = false
@State private var showingPinChange = false
@@ -39,6 +40,9 @@ struct SettingsView: View {
AppBackground()
List {
if updateService.updateAvailable {
updateAvailableSection
}
brandSection
// Premium Section
premiumSection
@@ -181,6 +185,33 @@ struct SettingsView: View {
if viewModel.backupsEnabled {
viewModel.refreshBackups()
}
updateService.checkForUpdate()
}
}
}
// MARK: - Update Available Section
private var updateAvailableSection: some View {
Section {
Link(destination: URL(string: "https://apps.apple.com/app/id6741412965")!) {
HStack {
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.appPrimary)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "update_available_title"))
.font(.subheadline.weight(.semibold))
if let v = updateService.latestVersion {
Text(String(format: String(localized: "update_available_body"), v))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
}
}
}
@@ -460,6 +491,12 @@ struct SettingsView: View {
}
}
NavigationLink {
CategoriesView()
} label: {
Label("Categories", systemImage: "tag")
}
Button {
if viewModel.canExport {
viewModel.showingExportOptions = true