Files
InvestmentTrackerApp/PortfolioJournal/Views/Sources/AddSourceView.swift
T
alexandrev-tibco 10f6d0ca20 Release 1.2.1: iCloud sync improvements + ASO multilingual metadata
iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:54:35 +01:00

560 lines
19 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("Source Name", 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)
}
}
// 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
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()
}
}
}
}
private func saveSnapshot() {
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 {
repository.createSnapshot(
for: source,
date: viewModel.date,
value: value,
contribution: viewModel.contribution,
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
)
}
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
dismiss()
}
}
#Preview {
AddSourceView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}