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
@@ -13,6 +13,10 @@ struct DashboardView: View {
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@State private var pendingAlertDismissed = false
@State private var fullScreenSection: DashboardSection? = nil
@State private var dragTargetID: String? = nil
@State private var showingQuickUpdate = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init() {
_viewModel = StateObject(wrappedValue: DashboardViewModel())
@@ -25,6 +29,10 @@ struct DashboardView: View {
ScrollView {
VStack(spacing: 20) {
if viewModel.updateStreak >= 2 {
streakBadge
}
if viewModel.hasData {
if !pendingAlertDismissed && !viewModel.sourcesNeedingUpdate.isEmpty {
PendingUpdatesAlertBanner(
@@ -38,8 +46,12 @@ struct DashboardView: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
ForEach(visibleSections) { config in
sectionView(for: config)
if horizontalSizeClass == .regular {
iPadDashboardLayout
} else {
ForEach(visibleSections) { config in
sectionView(for: config)
}
}
} else {
EmptyDashboardView(
@@ -51,7 +63,14 @@ struct DashboardView: View {
}
.padding()
}
if horizontalSizeClass == .regular, let section = fullScreenSection {
iPadFullScreenOverlay(for: section)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(10)
}
}
.animation(.easeInOut(duration: 0.25), value: fullScreenSection)
.navigationTitle("Home")
.refreshable {
viewModel.refreshData()
@@ -62,6 +81,13 @@ struct DashboardView: View {
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingQuickUpdate = true
} label: {
Image(systemName: "plus.circle.fill")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
@@ -103,12 +129,34 @@ struct DashboardView: View {
.sheet(isPresented: $showingCustomize) {
DashboardCustomizeView(configs: $sectionConfigs)
}
.sheet(isPresented: $showingQuickUpdate) {
QuickUpdateView()
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.onReceive(NotificationCenter.default.publisher(for: .openQuickUpdate)) { _ in
showingQuickUpdate = true
}
}
}
private var streakBadge: some View {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
.font(.caption.weight(.semibold))
Text(String(format: String(localized: "streak_badge"), viewModel.updateStreak))
.font(.caption.weight(.semibold))
}
.foregroundColor(.orange)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.15))
.cornerRadius(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var accountFilterMenu: some View {
Menu {
Button {
@@ -149,6 +197,127 @@ struct DashboardView: View {
sectionConfigs.filter { $0.isVisible }
}
// MARK: - iPad Layout Helpers
private var iPadGridSections: [DashboardSectionConfig] {
visibleSections.filter { $0.id != DashboardSection.totalValue.id }
}
@ViewBuilder
private var iPadDashboardLayout: some View {
// Total Portfolio Value always full width, never in the grid
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
// Other cards 2-column grid with drag-and-drop
if !iPadGridSections.isEmpty {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 16
) {
ForEach(iPadGridSections) { config in
sectionView(for: config)
.gridCellColumns(config.columnSpan)
.overlay {
if dragTargetID == config.id {
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
.stroke(Color.appPrimary, lineWidth: 2)
}
}
.draggable(config.id)
.dropDestination(for: String.self) { items, _ in
guard let fromID = items.first else { return false }
return handleIPadDrop(fromID: fromID, toID: config.id)
} isTargeted: { isTargeted in
dragTargetID = isTargeted ? config.id : nil
}
.contextMenu {
Button {
toggleColumnSpan(for: config.id)
} label: {
Label(
config.columnSpan == 2 ? "Normal width" : "Expand to full width",
systemImage: config.columnSpan == 2 ? "rectangle" : "rectangle.expand.diagonal"
)
}
Button {
withAnimation(.easeInOut(duration: 0.25)) {
fullScreenSection = DashboardSection(rawValue: config.id)
}
} label: {
Label("View full screen", systemImage: "arrow.up.left.and.arrow.down.right")
}
}
}
}
}
}
@ViewBuilder
private func iPadFullScreenOverlay(for section: DashboardSection) -> some View {
ZStack {
Color(.systemBackground)
.opacity(0.97)
.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Text(section.title)
.font(.title3.weight(.semibold))
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.25)) {
fullScreenSection = nil
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundColor(.secondary)
}
}
.padding()
Divider()
ScrollView {
VStack(spacing: 20) {
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
if let config = sectionConfigs.first(where: { $0.id == section.id }) {
sectionView(for: DashboardSectionConfig(id: config.id, isVisible: true, isCollapsed: false))
}
}
.padding()
.frame(maxWidth: 800)
.frame(maxWidth: .infinity)
}
}
}
}
private func handleIPadDrop(fromID: String, toID: String) -> Bool {
guard fromID != toID,
let fromIndex = sectionConfigs.firstIndex(where: { $0.id == fromID }),
let toIndex = sectionConfigs.firstIndex(where: { $0.id == toID }) else { return false }
withAnimation {
sectionConfigs.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
}
DashboardLayoutStore.save(sectionConfigs)
dragTargetID = nil
return true
}
private func toggleColumnSpan(for id: String) {
guard let index = sectionConfigs.firstIndex(where: { $0.id == id }) else { return }
withAnimation {
sectionConfigs[index].columnSpan = sectionConfigs[index].columnSpan == 2 ? 1 : 2
}
DashboardLayoutStore.save(sectionConfigs)
}
@ViewBuilder
private func sectionView(for config: DashboardSectionConfig) -> some View {
if let section = DashboardSection(rawValue: config.id) {
@@ -271,6 +440,19 @@ struct DashboardView: View {
)
}
}
case .contributionsVsReturns:
let contrib = viewModel.portfolioSummary.totalContributions
let returns = viewModel.portfolioSummary.allTimeReturn
if contrib > 0 || returns != 0 {
if config.isCollapsed {
CompactCard(title: "Invested vs. Returns", subtitle: contrib.currencyString)
} else {
ContributionsVsReturnsCard(
totalContributions: contrib,
totalReturns: returns
)
}
}
}
} else {
EmptyView()
@@ -1262,6 +1444,72 @@ struct EmptyGoalsCard: View {
}
}
// MARK: - Contributions vs Returns Card
struct ContributionsVsReturnsCard: View {
let totalContributions: Decimal
let totalReturns: Decimal
private var total: Decimal { totalContributions + totalReturns }
private var contributionFraction: Double {
guard total > 0 else { return 0.5 }
return NSDecimalNumber(decimal: totalContributions / total).doubleValue
}
private var returnFraction: Double { 1 - contributionFraction }
private var isReturnPositive: Bool { totalReturns >= 0 }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: "contributions_vs_returns_title"))
.font(.headline)
// Bar
GeometryReader { geo in
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.appSecondary)
.frame(width: max(4, geo.size.width * CGFloat(contributionFraction)))
RoundedRectangle(cornerRadius: 4)
.fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed)
.frame(maxWidth: .infinity)
}
.frame(height: 16)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.frame(height: 16)
HStack {
HStack(spacing: 6) {
Circle().fill(Color.appSecondary).frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "contributions_vs_returns_invested"))
.font(.caption).foregroundColor(.secondary)
Text(totalContributions.currencyString)
.font(.subheadline.weight(.semibold))
}
}
Spacer()
HStack(spacing: 6) {
Circle().fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed).frame(width: 10, height: 10)
VStack(alignment: .trailing, spacing: 2) {
Text(String(localized: "contributions_vs_returns_returns"))
.font(.caption).foregroundColor(.secondary)
let prefix = totalReturns >= 0 ? "+" : ""
Text("\(prefix)\(totalReturns.currencyString)")
.font(.subheadline.weight(.semibold))
.foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
DashboardView()
.environmentObject(IAPService())
@@ -0,0 +1,127 @@
import SwiftUI
import CoreData
struct QuickUpdateView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var context
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
predicate: NSPredicate(format: "isActive == YES"),
animation: .default
) private var sources: FetchedResults<InvestmentSource>
@State private var values: [NSManagedObjectID: String] = [:]
@State private var isSaving = false
@State private var saveError: String?
var body: some View {
NavigationStack {
ZStack {
AppBackground()
if sources.isEmpty {
ContentUnavailableView(
String(localized: "quick_update_no_sources"),
systemImage: "list.bullet",
description: Text(String(localized: "quick_update_no_sources_body"))
)
} else {
List {
Section {
ForEach(sources) { source in
sourceRow(source)
}
} header: {
Text(String(localized: "quick_update_section_header"))
} footer: {
Text(String(localized: "quick_update_section_footer"))
}
}
.scrollContentBackground(.hidden)
}
}
.navigationTitle(String(localized: "quick_update_title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "quick_update_save")) {
saveAll()
}
.disabled(isSaving || filledValues.isEmpty)
.fontWeight(.semibold)
}
}
.alert("Error", isPresented: Binding(
get: { saveError != nil },
set: { if !$0 { saveError = nil } }
)) {
Button("OK", role: .cancel) { saveError = nil }
} message: {
Text(saveError ?? "")
}
}
}
@ViewBuilder
private func sourceRow(_ source: InvestmentSource) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(source.name)
.font(.subheadline.weight(.medium))
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
TextField(
String(localized: "quick_update_placeholder"),
text: binding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.subheadline)
}
}
private var filledValues: [NSManagedObjectID: String] {
values.filter { !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }
}
private func binding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func saveAll() {
isSaving = true
let now = Date()
for source in sources {
guard let raw = values[source.objectID],
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
let value = CurrencyFormatter.parseUserInput(raw) else { continue }
let snapshot = Snapshot(context: context)
snapshot.id = UUID()
snapshot.value = NSDecimalNumber(decimal: value)
snapshot.date = now
snapshot.source = source
}
do {
try context.save()
dismiss()
} catch {
saveError = error.localizedDescription
}
isSaving = false
}
}