b48a47ce10
- 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>
649 lines
23 KiB
Swift
649 lines
23 KiB
Swift
import SwiftUI
|
|
|
|
struct AddSourceView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject var iapService: IAPService
|
|
@EnvironmentObject var accountStore: AccountStore
|
|
|
|
@State private var name = ""
|
|
@State private var selectedCategory: Category?
|
|
@State private var initialValue = ""
|
|
@State private var showingCategoryPicker = false
|
|
@State private var selectedAccountId: UUID?
|
|
@State private var duplicateError: String?
|
|
|
|
@StateObject private var categoryRepository = CategoryRepository()
|
|
@StateObject private var sourceRepository = InvestmentSourceRepository()
|
|
|
|
private var availableAccounts: [Account] {
|
|
accountStore.accounts.filter { $0.safeId != nil }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
if availableAccounts.count > 1 {
|
|
Section {
|
|
Picker("Account", selection: $selectedAccountId) {
|
|
ForEach(availableAccounts, id: \.objectID) { account in
|
|
Text(account.name).tag(Optional(account.safeId))
|
|
}
|
|
}
|
|
.onChange(of: selectedAccountId) { _, _ in
|
|
validateSourceName(name)
|
|
}
|
|
} header: {
|
|
Text("Account")
|
|
}
|
|
}
|
|
|
|
// Source Info
|
|
Section {
|
|
TextField(String(localized: "add_source_name_placeholder"), text: $name)
|
|
.textContentType(.organizationName)
|
|
.onChange(of: name) { _, newValue in
|
|
validateSourceName(newValue)
|
|
}
|
|
|
|
Button {
|
|
showingCategoryPicker = true
|
|
} label: {
|
|
HStack {
|
|
Text("Category")
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
if let category = selectedCategory {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: category.icon)
|
|
.foregroundColor(category.color)
|
|
Text(category.name)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
Text("Select")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Source Information")
|
|
} footer: {
|
|
if let error = duplicateError {
|
|
Text(error)
|
|
.foregroundColor(.negativeRed)
|
|
} else {
|
|
Text(String(localized: "add_source_name_footer"))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
// Initial Value (Optional)
|
|
Section {
|
|
HStack {
|
|
Text(currencySymbol)
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("0.00", text: $initialValue)
|
|
.keyboardType(.decimalPad)
|
|
}
|
|
} header: {
|
|
Text("Initial Value (Optional)")
|
|
} footer: {
|
|
Text("You can add snapshots later if you prefer.")
|
|
}
|
|
}
|
|
.navigationTitle("Add Source")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Add") {
|
|
saveSource()
|
|
}
|
|
.disabled(!isValid)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingCategoryPicker) {
|
|
CategoryPickerView(
|
|
selectedCategory: $selectedCategory,
|
|
categories: categoryRepository.categories
|
|
)
|
|
}
|
|
.onAppear {
|
|
categoryRepository.createDefaultCategoriesIfNeeded()
|
|
if selectedCategory == nil {
|
|
selectedCategory = categoryRepository.categories.first
|
|
}
|
|
if selectedAccountId == nil {
|
|
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Validation
|
|
|
|
private var isValid: Bool {
|
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil && duplicateError == nil
|
|
}
|
|
|
|
private func validateSourceName(_ newName: String) {
|
|
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
duplicateError = nil
|
|
return
|
|
}
|
|
|
|
if sourceRepository.sourceExists(name: trimmed, in: selectedAccount) {
|
|
duplicateError = "A source with this name already exists."
|
|
} else {
|
|
duplicateError = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
private func saveSource() {
|
|
guard let category = selectedCategory else { return }
|
|
|
|
let repository = InvestmentSourceRepository()
|
|
let source = repository.createSource(
|
|
name: name.trimmingCharacters(in: .whitespaces),
|
|
category: category,
|
|
account: selectedAccount
|
|
)
|
|
|
|
// Add initial snapshot if provided
|
|
if let value = parseDecimal(initialValue), value > 0 {
|
|
let snapshotRepository = SnapshotRepository()
|
|
snapshotRepository.createSnapshot(
|
|
for: source,
|
|
date: Date(),
|
|
value: value
|
|
)
|
|
}
|
|
|
|
// Schedule notification
|
|
NotificationService.shared.scheduleReminder(for: source)
|
|
|
|
// Log analytics
|
|
FirebaseService.shared.logSourceAdded(
|
|
categoryName: category.name,
|
|
sourceCount: repository.sourceCount
|
|
)
|
|
|
|
dismiss()
|
|
}
|
|
|
|
private func parseDecimal(_ string: String) -> Decimal? {
|
|
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
|
|
}
|
|
|
|
private var currencySymbol: String {
|
|
if let account = selectedAccount, let code = account.currencyCode {
|
|
return CurrencyFormatter.symbol(for: code)
|
|
}
|
|
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
|
}
|
|
|
|
private var currencyCode: String {
|
|
if let account = selectedAccount, let code = account.currencyCode {
|
|
return code
|
|
}
|
|
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
|
}
|
|
|
|
private var selectedAccount: Account? {
|
|
guard let id = selectedAccountId else { return nil }
|
|
return availableAccounts.first { $0.safeId == id }
|
|
}
|
|
}
|
|
|
|
// MARK: - Category Picker View
|
|
|
|
struct CategoryPickerView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Binding var selectedCategory: Category?
|
|
let categories: [Category]
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List(categories) { category in
|
|
Button {
|
|
selectedCategory = category
|
|
dismiss()
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(category.color.opacity(0.2))
|
|
.frame(width: 40, height: 40)
|
|
|
|
Image(systemName: category.icon)
|
|
.foregroundColor(category.color)
|
|
}
|
|
|
|
Text(category.name)
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
if selectedCategory?.id == category.id {
|
|
Image(systemName: "checkmark")
|
|
.foregroundColor(.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Select Category")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Edit Source View
|
|
|
|
struct EditSourceView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject var accountStore: AccountStore
|
|
let source: InvestmentSource
|
|
|
|
@State private var name: String
|
|
@State private var selectedCategory: Category?
|
|
@State private var showingCategoryPicker = false
|
|
@State private var selectedAccountId: UUID?
|
|
|
|
@StateObject private var categoryRepository = CategoryRepository()
|
|
|
|
init(source: InvestmentSource) {
|
|
self.source = source
|
|
_name = State(initialValue: source.name)
|
|
_selectedCategory = State(initialValue: source.category)
|
|
_selectedAccountId = State(initialValue: source.account?.safeId)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
if availableAccounts.count > 1 {
|
|
Section {
|
|
Picker("Account", selection: $selectedAccountId) {
|
|
ForEach(availableAccounts, id: \.objectID) { account in
|
|
Text(account.name).tag(Optional(account.safeId))
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Account")
|
|
}
|
|
}
|
|
|
|
Section {
|
|
TextField("Source Name", text: $name)
|
|
|
|
Button {
|
|
showingCategoryPicker = true
|
|
} label: {
|
|
HStack {
|
|
Text("Category")
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
if let category = selectedCategory {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: category.icon)
|
|
.foregroundColor(category.color)
|
|
Text(category.name)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Source")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") {
|
|
saveChanges()
|
|
}
|
|
.disabled(!isValid)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingCategoryPicker) {
|
|
CategoryPickerView(
|
|
selectedCategory: $selectedCategory,
|
|
categories: categoryRepository.categories
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var availableAccounts: [Account] {
|
|
accountStore.accounts.filter { $0.safeId != nil }
|
|
}
|
|
|
|
private var isValid: Bool {
|
|
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
|
}
|
|
|
|
private func saveChanges() {
|
|
guard let category = selectedCategory else { return }
|
|
|
|
let repository = InvestmentSourceRepository()
|
|
repository.updateSource(
|
|
source,
|
|
name: name.trimmingCharacters(in: .whitespaces),
|
|
category: category,
|
|
account: selectedAccount
|
|
)
|
|
|
|
// Reschedule notification
|
|
NotificationService.shared.scheduleReminder(for: source)
|
|
|
|
dismiss()
|
|
}
|
|
|
|
private var selectedAccount: Account? {
|
|
guard let id = selectedAccountId else { return nil }
|
|
return availableAccounts.first { $0.safeId == id }
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Snapshot View
|
|
|
|
struct AddSnapshotView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
let source: InvestmentSource
|
|
let snapshot: Snapshot?
|
|
|
|
@StateObject private var viewModel: SnapshotFormViewModel
|
|
@State private var showingDuplicateAlert = false
|
|
|
|
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
|
|
self.source = source
|
|
self.snapshot = snapshot
|
|
if let snapshot = snapshot {
|
|
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot)))
|
|
} else {
|
|
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
if snapshot == nil && viewModel.previousValue != nil {
|
|
Button {
|
|
viewModel.prefillFromPreviousSnapshot()
|
|
} label: {
|
|
Label("Duplicate Previous Snapshot", systemImage: "doc.on.doc")
|
|
}
|
|
}
|
|
|
|
DatePicker(
|
|
"Date",
|
|
selection: $viewModel.date,
|
|
in: ...Date(),
|
|
displayedComponents: .date
|
|
)
|
|
|
|
HStack {
|
|
Text(viewModel.currencySymbol)
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("Value", text: $viewModel.valueString)
|
|
.keyboardType(.decimalPad)
|
|
}
|
|
|
|
if let clipboardValue = viewModel.clipboardValue {
|
|
Button {
|
|
viewModel.applyClipboardValue()
|
|
} label: {
|
|
Label("Paste \(clipboardValue) from clipboard", systemImage: "doc.on.clipboard")
|
|
}
|
|
.tint(.appPrimary)
|
|
}
|
|
|
|
if viewModel.previousValue != nil {
|
|
Text(viewModel.previousValueString)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} header: {
|
|
Text("Snapshot Details")
|
|
}
|
|
|
|
// Change preview
|
|
if let change = viewModel.formattedChange,
|
|
let percentage = viewModel.formattedChangePercentage {
|
|
Section {
|
|
HStack {
|
|
Text("Change from previous")
|
|
Spacer()
|
|
Text("\(change) (\(percentage))")
|
|
.foregroundColor(
|
|
(viewModel.changeFromPrevious ?? 0) >= 0
|
|
? .positiveGreen
|
|
: .negativeRed
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if viewModel.inputMode == .detailed {
|
|
Section {
|
|
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
|
|
|
|
if viewModel.includeContribution {
|
|
HStack {
|
|
Text(viewModel.currencySymbol)
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("New capital added", text: $viewModel.contributionString)
|
|
.keyboardType(.decimalPad)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Contribution (Optional)")
|
|
} footer: {
|
|
Text("Track new capital added to separate it from investment growth.")
|
|
}
|
|
}
|
|
|
|
Section {
|
|
TextField("Notes", text: $viewModel.notes, axis: .vertical)
|
|
.lineLimit(3...6)
|
|
} header: {
|
|
Text("Notes (Optional)")
|
|
}
|
|
}
|
|
.navigationTitle(viewModel.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(viewModel.buttonTitle) {
|
|
saveSnapshot()
|
|
}
|
|
.disabled(!viewModel.isValid)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.onAppear {
|
|
viewModel.checkClipboard()
|
|
}
|
|
.onChange(of: scenePhase) { _, phase in
|
|
if phase == .active {
|
|
viewModel.checkClipboard()
|
|
}
|
|
}
|
|
.alert(
|
|
String(localized: "snapshot_duplicate_title"),
|
|
isPresented: $showingDuplicateAlert
|
|
) {
|
|
Button(String(localized: "snapshot_duplicate_replace"), role: .destructive) {
|
|
performSave()
|
|
}
|
|
Button(String(localized: "snapshot_duplicate_add")) {
|
|
performSave()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text(String(localized: "snapshot_duplicate_message"))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveSnapshot() {
|
|
guard snapshot == nil else {
|
|
performSave()
|
|
return
|
|
}
|
|
|
|
// Check for existing snapshot in the same month
|
|
let calendar = Calendar.current
|
|
let selectedMonth = calendar.dateComponents([.year, .month], from: viewModel.date)
|
|
let repo = SnapshotRepository()
|
|
let existing = repo.fetchSnapshots(for: source, limitedToMonths: nil)
|
|
let hasDuplicate = existing.contains { snap in
|
|
let snapMonth = calendar.dateComponents([.year, .month], from: snap.date)
|
|
return snapMonth == selectedMonth
|
|
}
|
|
|
|
if hasDuplicate {
|
|
showingDuplicateAlert = true
|
|
} else {
|
|
performSave()
|
|
}
|
|
}
|
|
|
|
private func performSave() {
|
|
guard let value = viewModel.value else { return }
|
|
|
|
let repository = SnapshotRepository()
|
|
if let snapshot = snapshot {
|
|
let contributionTextIsEmpty = viewModel.contributionString
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.isEmpty
|
|
repository.updateSnapshot(
|
|
snapshot,
|
|
date: viewModel.date,
|
|
value: value,
|
|
contribution: viewModel.contribution,
|
|
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
|
|
clearContribution: !viewModel.includeContribution || contributionTextIsEmpty,
|
|
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
)
|
|
} else {
|
|
// Capture pre-save goal state for achievement detection
|
|
let goalRepo = GoalRepository()
|
|
let sourceRepo = InvestmentSourceRepository()
|
|
let prevAchievedIds = goalsAchievedBeforeSave(goalRepo: goalRepo, sourceRepo: sourceRepo)
|
|
|
|
repository.createSnapshot(
|
|
for: source,
|
|
date: viewModel.date,
|
|
value: value,
|
|
contribution: viewModel.contribution,
|
|
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
|
|
)
|
|
|
|
// Check if any goals became achieved
|
|
checkGoalAchievements(goalRepo: goalRepo, sourceRepo: sourceRepo, prevAchievedIds: prevAchievedIds)
|
|
}
|
|
|
|
// Reschedule notification
|
|
NotificationService.shared.scheduleReminder(for: source)
|
|
|
|
// Log analytics
|
|
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
|
|
|
|
dismiss()
|
|
}
|
|
|
|
private func goalsAchievedBeforeSave(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository) -> Set<UUID> {
|
|
let goals = goalRepo.goals.filter { $0.isActive }
|
|
var achieved = Set<UUID>()
|
|
for goal in goals {
|
|
let currentValue: Decimal
|
|
if let accountId = goal.account?.safeId {
|
|
currentValue = sourceRepo.sources
|
|
.filter { $0.account?.id == accountId }
|
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
} else {
|
|
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
}
|
|
let target = goal.targetDecimal
|
|
if target > 0 && currentValue / target >= 1 {
|
|
achieved.insert(goal.id)
|
|
}
|
|
}
|
|
return achieved
|
|
}
|
|
|
|
private func checkGoalAchievements(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository, prevAchievedIds: Set<UUID>) {
|
|
// Reload sources to pick up new snapshot value
|
|
let goals = goalRepo.goals.filter { $0.isActive }
|
|
for goal in goals {
|
|
guard !prevAchievedIds.contains(goal.id) else { continue }
|
|
let currentValue: Decimal
|
|
if let accountId = goal.account?.safeId {
|
|
currentValue = sourceRepo.sources
|
|
.filter { $0.account?.id == accountId }
|
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
} else {
|
|
currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
}
|
|
let target = goal.targetDecimal
|
|
if target > 0 && currentValue / target >= 1 {
|
|
NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
AddSourceView()
|
|
.environmentObject(IAPService())
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|