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