Primera build enviada
This commit is contained in:
@@ -18,7 +18,18 @@ struct AccountEditorView: View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Account name", text: $name)
|
||||
if isEditingDefaultAccount {
|
||||
HStack {
|
||||
Text(Account.defaultAccountName)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
TextField("Account name", text: $name)
|
||||
}
|
||||
Picker("Currency", selection: $currencyCode) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
@@ -30,6 +41,8 @@ struct AccountEditorView: View {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.negativeRed)
|
||||
} else if isEditingDefaultAccount {
|
||||
Text("The Default account name cannot be changed.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +80,16 @@ struct AccountEditorView: View {
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private var isEditingDefaultAccount: Bool {
|
||||
account?.isDefaultAccount == true
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
if isEditingDefaultAccount {
|
||||
return true // Can only edit currency/inputMode for Default account
|
||||
}
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
return !trimmed.isEmpty && !isDuplicateName(trimmed)
|
||||
return !trimmed.isEmpty && errorMessage == nil
|
||||
}
|
||||
|
||||
private func validateName() {
|
||||
@@ -78,17 +98,24 @@ struct AccountEditorView: View {
|
||||
errorMessage = nil
|
||||
return
|
||||
}
|
||||
errorMessage = isDuplicateName(trimmed) ? "An account with this name already exists." : nil
|
||||
}
|
||||
|
||||
private func isDuplicateName(_ trimmed: String) -> Bool {
|
||||
let normalized = trimmed.lowercased()
|
||||
return accountRepository.accounts.contains { existing in
|
||||
if let account, existing.id == account.id {
|
||||
return false
|
||||
// Check if trying to use reserved "Default" name for a non-default account
|
||||
if trimmed.lowercased() == Account.defaultAccountName.lowercased() {
|
||||
let isCreatingNew = account == nil
|
||||
let isEditingNonDefault = account != nil && !account!.isDefaultAccount
|
||||
if isCreatingNew || isEditingNonDefault {
|
||||
errorMessage = "The name '\(Account.defaultAccountName)' is reserved."
|
||||
return
|
||||
}
|
||||
return (existing.name).trimmingCharacters(in: .whitespaces).lowercased() == normalized
|
||||
}
|
||||
|
||||
// Check for duplicate names
|
||||
if !accountRepository.isNameAvailable(trimmed, excludingAccountId: account?.safeId) {
|
||||
errorMessage = "An account with this name already exists."
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func loadAccount() {
|
||||
@@ -102,15 +129,17 @@ struct AccountEditorView: View {
|
||||
}
|
||||
|
||||
private func saveAccount() {
|
||||
validateName()
|
||||
guard errorMessage == nil else { return }
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
if !isEditingDefaultAccount {
|
||||
validateName()
|
||||
guard errorMessage == nil else { return }
|
||||
}
|
||||
let trimmedName = isEditingDefaultAccount ? Account.defaultAccountName : name.trimmingCharacters(in: .whitespaces)
|
||||
let enforcedFrequency: NotificationFrequency = .monthly
|
||||
let enforcedCustomMonths = 1
|
||||
if let account {
|
||||
accountRepository.updateAccount(
|
||||
account,
|
||||
name: trimmedName,
|
||||
name: isEditingDefaultAccount ? nil : trimmedName, // Don't update name for Default account
|
||||
currency: currencyCode,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: enforcedFrequency,
|
||||
|
||||
@@ -8,6 +8,11 @@ struct AccountsView: View {
|
||||
@State private var selectedAccount: Account?
|
||||
@State private var showingPaywall = false
|
||||
@State private var accountToDelete: Account?
|
||||
@State private var showingDeleteBlocked = false
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
accountRepository.accounts.filter { $0.safeId != nil }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -15,37 +20,47 @@ struct AccountsView: View {
|
||||
|
||||
List {
|
||||
Section {
|
||||
ForEach(accountRepository.accounts) { account in
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
let canDelete = accountRepository.canDeleteAccount(account)
|
||||
Button {
|
||||
selectedAccount = account
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(account.name)
|
||||
.font(.headline)
|
||||
HStack(spacing: 4) {
|
||||
Text(account.name)
|
||||
.font(.headline)
|
||||
if account.isDefaultAccount {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
}
|
||||
}
|
||||
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
accountToDelete = account
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
if canDelete {
|
||||
Button(role: .destructive) {
|
||||
accountToDelete = account
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Accounts")
|
||||
} footer: {
|
||||
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals." : "Free users can create one account.")
|
||||
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals. The Default account cannot be deleted." : "Free users can create one account.")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
@@ -61,14 +76,20 @@ struct AccountsView: View {
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
guard let accountToDelete else { return }
|
||||
if accountStore.selectedAccount?.id == accountToDelete.id {
|
||||
accountStore.selectAllAccounts()
|
||||
if accountRepository.canDeleteAccount(accountToDelete) {
|
||||
accountRepository.deleteAccount(accountToDelete)
|
||||
} else {
|
||||
showingDeleteBlocked = true
|
||||
}
|
||||
accountRepository.deleteAccount(accountToDelete)
|
||||
self.accountToDelete = nil
|
||||
}
|
||||
} message: {
|
||||
Text("This will remove the account and unlink its sources.")
|
||||
Text("This will remove the account and all its sources and snapshots.")
|
||||
}
|
||||
.alert("Cannot Delete Account", isPresented: $showingDeleteBlocked) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("The Default account cannot be deleted. You must keep at least one account.")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
|
||||
@@ -260,13 +260,13 @@ struct ChartsContainerView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(accountStore.accounts) { account in
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
Button {
|
||||
accountStore.selectAccount(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(account.name)
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,10 @@ struct ChartsContainerView: View {
|
||||
Image(systemName: "person.2.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
accountStore.accounts.filter { $0.safeId != nil }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Button
|
||||
|
||||
@@ -89,6 +89,9 @@ struct DashboardView: View {
|
||||
.sheet(isPresented: $showingCustomize) {
|
||||
DashboardCustomizeView(configs: $sectionConfigs)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,13 +110,13 @@ struct DashboardView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(accountStore.accounts) { account in
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
Button {
|
||||
accountStore.selectAccount(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(account.name)
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
@@ -124,6 +127,10 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
accountStore.accounts.filter { $0.safeId != nil }
|
||||
}
|
||||
|
||||
private var visibleSections: [DashboardSectionConfig] {
|
||||
sectionConfigs.filter { $0.isVisible }
|
||||
}
|
||||
@@ -144,7 +151,16 @@ struct DashboardView: View {
|
||||
changeLabel: calmModeEnabled ? "since last update" : "today",
|
||||
isPositive: calmModeEnabled
|
||||
? viewModel.latestPortfolioChange.absolute >= 0
|
||||
: viewModel.isDayChangePositive
|
||||
: viewModel.isDayChangePositive,
|
||||
forecast: viewModel.portfolioForecast,
|
||||
isPremium: iapService.isPremium,
|
||||
onUnlockTap: {
|
||||
viewModel.showingPaywall = true
|
||||
},
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||
isYearPositive: viewModel.isYearChangePositive,
|
||||
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||
)
|
||||
}
|
||||
case .monthlyCheckIn:
|
||||
@@ -243,6 +259,13 @@ struct TotalValueCard: View {
|
||||
let changeText: String
|
||||
let changeLabel: String
|
||||
let isPositive: Bool
|
||||
var forecast: PortfolioForecast?
|
||||
var isPremium: Bool = false
|
||||
var onUnlockTap: (() -> Void)?
|
||||
var yearChange: String?
|
||||
var sinceInceptionChange: String?
|
||||
var isYearPositive: Bool = true
|
||||
var isSinceInceptionPositive: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
@@ -264,6 +287,77 @@ struct TotalValueCard: View {
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
|
||||
// YoY and Since Inception returns
|
||||
if yearChange != nil || sinceInceptionChange != nil {
|
||||
HStack(spacing: 16) {
|
||||
if let yearChange = yearChange {
|
||||
VStack(spacing: 2) {
|
||||
Text("YoY")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(yearChange)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
if let sinceInceptionChange = sinceInceptionChange {
|
||||
VStack(spacing: 2) {
|
||||
Text("Since inception")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(sinceInceptionChange)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Forecast section
|
||||
if let forecast = forecast {
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.3))
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if isPremium {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.caption)
|
||||
Text("12-month forecast")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
Text(forecast.formattedForecastValue)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(forecast.formattedGrowthPercentage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(forecast.isPositiveGrowth ? .white : .white.opacity(0.8))
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
onUnlockTap?()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
Text("Unlock 12-month forecast")
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.2))
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
@@ -36,6 +36,10 @@ struct ImportDataView: View {
|
||||
|
||||
private let accountRepository = AccountRepository()
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
accountStore.accounts.filter { $0.safeId != nil }
|
||||
}
|
||||
|
||||
init(importContext: ImportContext = .settings) {
|
||||
self.importContext = importContext
|
||||
}
|
||||
@@ -146,8 +150,17 @@ struct ImportDataView: View {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
if selectedAccountId == nil {
|
||||
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
|
||||
// Ensure selectedAccountId is valid and exists in availableAccounts
|
||||
let validIds = Set(availableAccounts.compactMap { $0.safeId })
|
||||
if selectedAccountId == nil || !validIds.contains(selectedAccountId!) {
|
||||
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
|
||||
}
|
||||
}
|
||||
.onChange(of: availableAccounts) { _, newAccounts in
|
||||
// Re-validate selectedAccountId when accounts change
|
||||
let validIds = Set(newAccounts.compactMap { $0.safeId })
|
||||
if let currentId = selectedAccountId, !validIds.contains(currentId) {
|
||||
selectedAccountId = newAccounts.first?.safeId
|
||||
}
|
||||
}
|
||||
.onChange(of: accountSelection) { _, _ in
|
||||
@@ -219,18 +232,17 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
}
|
||||
|
||||
private var shouldShowAccountSelection: Bool {
|
||||
importContext == .onboarding
|
||||
// Show account selection when there are multiple accounts (Premium) or during onboarding
|
||||
iapService.isPremium && availableAccounts.count > 1 || importContext == .onboarding
|
||||
}
|
||||
|
||||
private var importFooterText: String {
|
||||
if importContext == .onboarding {
|
||||
if shouldShowAccountSelection {
|
||||
return iapService.isPremium
|
||||
? "Import will be added to the selected account."
|
||||
: "Free users import into the existing account."
|
||||
: "Free users import into the Default account."
|
||||
}
|
||||
return iapService.isPremium
|
||||
? "Accounts are imported as provided."
|
||||
: "Free users import into the selected account."
|
||||
return "Data will be imported into your Default account."
|
||||
}
|
||||
|
||||
private var accountSection: some View {
|
||||
@@ -245,8 +257,8 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
|
||||
if accountSelection == .existing {
|
||||
Picker("Import into", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
Text(account.name).tag(Optional(account.safeId))
|
||||
}
|
||||
}
|
||||
.disabled(isImporting)
|
||||
@@ -273,9 +285,9 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
}
|
||||
|
||||
private var selectedAccountName: String? {
|
||||
accountStore.accounts.first { $0.id == selectedAccountId }?.name
|
||||
availableAccounts.first { $0.safeId == selectedAccountId }?.name
|
||||
?? accountStore.selectedAccount?.name
|
||||
?? accountStore.accounts.first?.name
|
||||
?? availableAccounts.first?.name
|
||||
}
|
||||
|
||||
private func validateNewAccountName() -> String? {
|
||||
@@ -355,12 +367,17 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
}
|
||||
|
||||
private func handleImportContent(_ content: String) {
|
||||
let shouldForceSingleAccount = importContext == .onboarding
|
||||
let allowMultipleAccounts = iapService.isPremium && !shouldForceSingleAccount
|
||||
var defaultAccountName = accountStore.selectedAccount?.name
|
||||
?? accountStore.accounts.first?.name
|
||||
// For non-Premium users or when only one account exists, use the Default account
|
||||
// For Premium users with multiple accounts, respect their selection
|
||||
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
|
||||
let allowMultipleAccounts = false // Always import into a single account
|
||||
|
||||
if shouldForceSingleAccount && iapService.isPremium {
|
||||
var defaultAccountName = accountStore.accounts.first(where: { $0.isDefaultAccount })?.name
|
||||
?? accountStore.selectedAccount?.name
|
||||
?? accountStore.accounts.first?.name
|
||||
?? Account.defaultAccountName
|
||||
|
||||
if useAccountSelection {
|
||||
if accountSelection == .new {
|
||||
accountErrorMessage = validateNewAccountName()
|
||||
guard accountErrorMessage == nil else { return }
|
||||
@@ -375,7 +392,7 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
)
|
||||
defaultAccountName = account.name
|
||||
} else {
|
||||
defaultAccountName = selectedAccountName
|
||||
defaultAccountName = selectedAccountName ?? defaultAccountName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +414,11 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
isImporting = false
|
||||
|
||||
if importResult.errors.isEmpty {
|
||||
resultMessage = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SettingsView: View {
|
||||
private let iapService: IAPService
|
||||
@@ -67,6 +68,9 @@ struct SettingsView: View {
|
||||
.sheet(isPresented: $viewModel.showingExportOptions) {
|
||||
ExportOptionsSheet(viewModel: viewModel)
|
||||
}
|
||||
.sheet(item: $viewModel.shareItem) { shareItem in
|
||||
ActivityView(activityItems: [shareItem.url])
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingImportSheet) {
|
||||
ImportDataView()
|
||||
}
|
||||
@@ -578,6 +582,22 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity View
|
||||
|
||||
struct ActivityView: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
let applicationActivities: [UIActivity]? = nil
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(
|
||||
activityItems: activityItems,
|
||||
applicationActivities: applicationActivities
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
// MARK: - Export Options Sheet
|
||||
|
||||
struct ExportOptionsSheet: View {
|
||||
@@ -587,59 +607,75 @@ struct ExportOptionsSheet: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Button {
|
||||
viewModel.exportData(format: .csv)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "tablecells")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.frame(width: 30)
|
||||
if viewModel.isExporting {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView(value: viewModel.exportProgress)
|
||||
.progressViewStyle(.linear)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("CSV")
|
||||
.font(.headline)
|
||||
Text("Compatible with Excel, Google Sheets")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.exportStatus)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
Text("Exporting...")
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Button {
|
||||
viewModel.exportData(format: .csv)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "tablecells")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("CSV")
|
||||
.font(.headline)
|
||||
Text("Compatible with Excel, Google Sheets")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.exportData(format: .json)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.foregroundColor(.appPrimary)
|
||||
.frame(width: 30)
|
||||
Button {
|
||||
viewModel.exportData(format: .json)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.foregroundColor(.appPrimary)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("JSON")
|
||||
.font(.headline)
|
||||
Text("Full data structure for backup")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading) {
|
||||
Text("JSON")
|
||||
.font(.headline)
|
||||
Text("Full data structure for backup")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Select Format")
|
||||
}
|
||||
} header: {
|
||||
Text("Select Format")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Export Data")
|
||||
.navigationTitle(viewModel.isExporting ? "Exporting" : "Export Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.disabled(viewModel.isExporting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.interactiveDismissDisabled(viewModel.isExporting)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,19 +10,28 @@ struct AddSourceView: View {
|
||||
@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 accountStore.accounts.count > 1 {
|
||||
if availableAccounts.count > 1 {
|
||||
Section {
|
||||
Picker("Account", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
Text(account.name).tag(Optional(account.safeId))
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedAccountId) { _, _ in
|
||||
validateSourceName(name)
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
@@ -32,6 +41,9 @@ struct AddSourceView: View {
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
.textContentType(.organizationName)
|
||||
.onChange(of: name) { _, newValue in
|
||||
validateSourceName(newValue)
|
||||
}
|
||||
|
||||
Button {
|
||||
showingCategoryPicker = true
|
||||
@@ -61,6 +73,11 @@ struct AddSourceView: View {
|
||||
}
|
||||
} header: {
|
||||
Text("Source Information")
|
||||
} footer: {
|
||||
if let error = duplicateError {
|
||||
Text(error)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial Value (Optional)
|
||||
@@ -107,7 +124,7 @@ struct AddSourceView: View {
|
||||
selectedCategory = categoryRepository.categories.first
|
||||
}
|
||||
if selectedAccountId == nil {
|
||||
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
|
||||
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +133,21 @@ struct AddSourceView: View {
|
||||
// MARK: - Validation
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||
!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
|
||||
@@ -177,7 +208,7 @@ struct AddSourceView: View {
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return accountStore.accounts.first { $0.id == id }
|
||||
return availableAccounts.first { $0.safeId == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,17 +279,17 @@ struct EditSourceView: View {
|
||||
self.source = source
|
||||
_name = State(initialValue: source.name)
|
||||
_selectedCategory = State(initialValue: source.category)
|
||||
_selectedAccountId = State(initialValue: source.account?.id)
|
||||
_selectedAccountId = State(initialValue: source.account?.safeId)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if accountStore.accounts.count > 1 {
|
||||
if availableAccounts.count > 1 {
|
||||
Section {
|
||||
Picker("Account", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
Text(account.name).tag(Optional(account.safeId))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -320,6 +351,10 @@ struct EditSourceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
accountStore.accounts.filter { $0.safeId != nil }
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||
}
|
||||
@@ -343,7 +378,7 @@ struct EditSourceView: View {
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return accountStore.accounts.first { $0.id == id }
|
||||
return availableAccounts.first { $0.safeId == id }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,42 +424,48 @@ struct SourceDetailView: View {
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
|
||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
// Use LazyVStack for better performance with many snapshots
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||
VStack(spacing: 0) {
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteSnapshot(snapshot)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteSnapshot(snapshot)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.visibleSnapshots.last?.id {
|
||||
Divider()
|
||||
if snapshot.id != viewModel.visibleSnapshots.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.snapshots.count > 10 {
|
||||
Text("+ \(viewModel.snapshots.count - 10) more snapshots")
|
||||
// Show "more snapshots" only for free users who have limited history
|
||||
if viewModel.isHistoryLimited && viewModel.hiddenSnapshotCount > 0 {
|
||||
Text("+ \(viewModel.hiddenSnapshotCount) older snapshots hidden")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@@ -234,13 +234,13 @@ struct SourceListView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(accountStore.accounts) { account in
|
||||
ForEach(availableAccounts, id: \.objectID) { account in
|
||||
Button {
|
||||
accountStore.selectAccount(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(account.name)
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,10 @@ struct SourceListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
accountStore.accounts.filter { $0.safeId != nil }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Row View
|
||||
|
||||
Reference in New Issue
Block a user