1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad

- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed)
  con diálogo de propagación al editar: solo este / adelante / atrás / todos
  (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged)
- 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba
  chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos;
  ahora agrupa por mes calendario crudo
- 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para
  recrear el StateObject al cambiar de selección
- 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs
  arriba / detalle debajo
- 145: card de contribución mensual en SourceDetailView
- Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-06-30 16:51:03 +02:00
parent 54dfd8a8e3
commit 7a18dd8360
48 changed files with 2854 additions and 608 deletions
@@ -0,0 +1,360 @@
import SwiftUI
struct CSVMappingView: View {
let csvContent: String
let onComplete: (ImportService.CSVMappingConfig) -> Void
@Environment(\.dismiss) private var dismiss
@State private var config = ImportService.CSVMappingConfig()
@State private var preview: ImportService.CSVPreview?
var body: some View {
NavigationStack {
Form {
// CSV Preview section
if let preview, let sample = preview.sampleRow(hasHeaderRow: config.hasHeaderRow) {
Section(String(localized: "csv_preview_section")) {
Toggle("Has Header Row", isOn: $config.hasHeaderRow)
.onChange(of: config.hasHeaderRow) { _, _ in resetMappings() }
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 16) {
ForEach(
Array(zip(
preview.headers(hasHeaderRow: config.hasHeaderRow),
sample
).enumerated()),
id: \.offset
) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
Text(pair.1.isEmpty ? "" : pair.1)
.font(.caption)
.lineLimit(2)
}
.frame(minWidth: 60, alignment: .leading)
}
}
.padding(.vertical, 4)
}
}
}
// Required fields
Section(String(localized: "csv_required_section")) {
MappingRow(
label: String(localized: "csv_field_source"),
index: $config.sourceIndex,
constant: $config.sourceConstant,
headers: currentHeaders,
supportsConstant: true,
supportsUseToday: false,
canSkip: false
)
MappingRow(
label: String(localized: "csv_field_value"),
index: $config.valueIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: false
)
}
// Date section
Section {
MappingRow(
label: String(localized: "csv_field_date"),
index: $config.dateIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: true,
canSkip: false
)
} header: {
Text("Date")
} footer: {
if config.dateIndex == ImportService.CSVMappingConfig.useToday {
Text(String(localized: "csv_no_date_hint"))
}
}
// Optional fields
Section(String(localized: "csv_optional_section")) {
MappingRowWithConstant(
label: String(localized: "csv_field_category"),
index: $config.categoryIndex,
constant: $config.categoryConstant,
headers: currentHeaders
)
MappingRow(
label: String(localized: "csv_field_contribution"),
index: $config.contributionIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: true
)
MappingRow(
label: String(localized: "csv_field_notes"),
index: $config.notesIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: true
)
}
}
.navigationTitle("Map Columns")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Import") {
onComplete(config)
dismiss()
}
.fontWeight(.semibold)
.disabled(!config.isValid)
}
}
.onAppear {
let p = ImportService.shared.previewCSV(csvContent)
self.preview = p
autoDetectColumns(from: p)
}
}
.presentationDetents([.large])
}
private var currentHeaders: [String] {
preview?.headers(hasHeaderRow: config.hasHeaderRow) ?? []
}
private func resetMappings() {
guard let preview else { return }
autoDetectColumns(from: preview)
}
/// Try to auto-detect columns by matching common header names
private func autoDetectColumns(from preview: ImportService.CSVPreview) {
guard config.hasHeaderRow else { return }
let headers = preview.headers(hasHeaderRow: true)
let lower = headers.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
for (i, h) in lower.enumerated() {
if config.sourceIndex == ImportService.CSVMappingConfig.notMapped &&
(h == "source" || h == "name" || h == "fuente" || h == "nombre") {
config.sourceIndex = i
}
if config.valueIndex == ImportService.CSVMappingConfig.notMapped &&
(h.hasPrefix("value") || h == "amount" || h == "valor" || h == "importe") {
config.valueIndex = i
}
if config.dateIndex == ImportService.CSVMappingConfig.useToday &&
(h == "date" || h == "fecha" || h.hasPrefix("date")) {
config.dateIndex = i
}
if config.categoryIndex == ImportService.CSVMappingConfig.constant &&
(h == "category" || h == "categoría" || h == "categoria" || h == "type") {
config.categoryIndex = i
}
if config.contributionIndex == ImportService.CSVMappingConfig.notMapped &&
(h.hasPrefix("contribution") || h == "aportación" || h == "aportacion") {
config.contributionIndex = i
}
if config.notesIndex == ImportService.CSVMappingConfig.notMapped &&
(h == "notes" || h == "notas" || h == "note" || h == "comments") {
config.notesIndex = i
}
}
}
}
// MARK: - Mapping Row (column or constant/useToday/skip)
private struct MappingRow: View {
let label: String
@Binding var index: Int
@Binding var constant: String
let headers: [String]
let supportsConstant: Bool
let supportsUseToday: Bool
let canSkip: Bool
private var selectionLabel: String {
if index == ImportService.CSVMappingConfig.useToday {
return String(localized: "csv_use_today")
}
if index == ImportService.CSVMappingConfig.constant {
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
}
if index == ImportService.CSVMappingConfig.notMapped {
return String(localized: "csv_no_column")
}
return headers[safe: index] ?? "Column \(index + 1)"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Menu {
// Column options
if !headers.isEmpty {
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
Button { index = i } label: {
Label(h, systemImage: index == i ? "checkmark" : "")
}
}
Divider()
}
if supportsUseToday {
Button {
index = ImportService.CSVMappingConfig.useToday
} label: {
Label(
String(localized: "csv_use_today"),
systemImage: index == ImportService.CSVMappingConfig.useToday ? "checkmark" : ""
)
}
}
if supportsConstant {
Button {
index = ImportService.CSVMappingConfig.constant
} label: {
Label(
String(localized: "csv_enter_value"),
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
)
}
}
if canSkip {
Button {
index = ImportService.CSVMappingConfig.notMapped
} label: {
Label(
String(localized: "csv_no_column"),
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
)
}
}
} label: {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
if index == ImportService.CSVMappingConfig.constant && supportsConstant {
TextField(String(localized: "csv_enter_value"), text: $constant)
.textFieldStyle(.roundedBorder)
.font(.subheadline)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Mapping Row With Constant (column, constant value, or skip)
private struct MappingRowWithConstant: View {
let label: String
@Binding var index: Int
@Binding var constant: String
let headers: [String]
private var selectionLabel: String {
if index == ImportService.CSVMappingConfig.constant {
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
}
if index == ImportService.CSVMappingConfig.notMapped {
return String(localized: "csv_no_column")
}
return headers[safe: index] ?? "Column \(index + 1)"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Menu {
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
Button { index = i } label: {
Label(h, systemImage: index == i ? "checkmark" : "")
}
}
Divider()
Button {
index = ImportService.CSVMappingConfig.constant
} label: {
Label(
String(localized: "csv_enter_value"),
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
)
}
Button {
index = ImportService.CSVMappingConfig.notMapped
} label: {
Label(
String(localized: "csv_no_column"),
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
)
}
} label: {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
if index == ImportService.CSVMappingConfig.constant {
TextField(String(localized: "csv_enter_value"), text: $constant)
.textFieldStyle(.roundedBorder)
.font(.subheadline)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Array safe subscript
private extension Array {
subscript(safe index: Int) -> Element? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}
@@ -0,0 +1,271 @@
import SwiftUI
import CoreData
// MARK: - Categories List View
struct CategoriesView: View {
@StateObject private var categoryRepository = CategoryRepository()
@State private var showingAddCategory = false
@State private var editingCategory: Category?
@State private var showingDeleteError = false
var body: some View {
List {
if categoryRepository.categories.isEmpty {
Text(String(localized: "categories_empty"))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
} else {
ForEach(categoryRepository.categories) { category in
CategoryRow(category: category)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
editingCategory = category
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.appSecondary)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if category.sourceCount == 0 {
Button(role: .destructive) {
categoryRepository.deleteCategory(category)
} label: {
Label("Delete", systemImage: "trash")
}
} else {
Button {
showingDeleteError = true
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.gray)
}
}
}
.onMove { from, to in
var reordered = categoryRepository.categories
reordered.move(fromOffsets: from, toOffset: to)
categoryRepository.updateSortOrder(reordered)
}
}
}
.navigationTitle("Categories")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddCategory = true
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.sheet(isPresented: $showingAddCategory) {
CategoryEditorView()
}
.sheet(item: $editingCategory) { category in
CategoryEditorView(category: category)
}
.alert(
"Cannot Delete",
isPresented: $showingDeleteError
) {
Button("OK", role: .cancel) { showingDeleteError = false }
} message: {
Text(String(localized: "category_has_sources_warning"))
}
}
}
// MARK: - Category Row
private struct CategoryRow: View {
let category: Category
var body: some View {
Group {
if category.isDeleted || category.isFault {
EmptyView()
} else {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(category.color.opacity(0.2))
.frame(width: 40, height: 40)
Image(systemName: category.icon)
.font(.system(size: 16))
.foregroundColor(category.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(category.name)
.font(.body)
Text(category.sourceCount == 1 ? "1 source" : "\(category.sourceCount) sources")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
}
}
}
}
// MARK: - Category Editor View
struct CategoryEditorView: View {
let category: Category?
@Environment(\.dismiss) private var dismiss
@StateObject private var categoryRepository = CategoryRepository()
@State private var name = ""
@State private var selectedColor = "#10B981"
@State private var selectedIcon = "chart.pie.fill"
@State private var didLoad = false
init(category: Category? = nil) {
self.category = category
}
private let colors: [String] = [
"#10B981", "#3B82F6", "#8B5CF6", "#F59E0B", "#EF4444", "#EC4899",
"#14B8A6", "#6B7280", "#F97316", "#06B6D4", "#84CC16", "#64748B"
]
private let icons: [String] = [
"chart.line.uptrend.xyaxis", "chart.bar.fill", "chart.pie.fill",
"banknote.fill", "building.columns.fill", "house.fill",
"bitcoinsign.circle.fill", "person.fill", "briefcase.fill",
"dollarsign.circle.fill", "creditcard.fill", "globe",
"cart.fill", "star.fill", "heart.fill", "ellipsis.circle.fill",
"folder.fill", "lock.fill", "leaf.fill", "flame.fill"
]
var body: some View {
NavigationStack {
Form {
Section("Category Name") {
TextField(String(localized: "category_name_placeholder"), text: $name)
.autocorrectionDisabled()
}
Section("Color") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) {
ForEach(colors, id: \.self) { hex in
ColorSwatch(hex: hex, isSelected: selectedColor == hex)
.onTapGesture { selectedColor = hex }
}
}
.padding(.vertical, 4)
}
Section("Icon") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 5), spacing: 12) {
ForEach(icons, id: \.self) { icon in
IconSwatch(icon: icon, colorHex: selectedColor, isSelected: selectedIcon == icon)
.onTapGesture { selectedIcon = icon }
}
}
.padding(.vertical, 4)
}
// Preview
Section("Preview") {
HStack(spacing: 12) {
ZStack {
Circle()
.fill((Color(hex: selectedColor) ?? .blue).opacity(0.2))
.frame(width: 44, height: 44)
Image(systemName: selectedIcon)
.font(.system(size: 18))
.foregroundColor(Color(hex: selectedColor) ?? .blue)
}
Text(name.isEmpty ? String(localized: "category_name_placeholder") : name)
.foregroundColor(name.isEmpty ? .secondary : .primary)
}
}
}
.navigationTitle(category == nil ? "Add Category" : "Edit Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { save() }
.fontWeight(.semibold)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
guard let category, !didLoad else { return }
name = category.name
selectedColor = category.colorHex
selectedIcon = category.icon
didLoad = true
}
}
.presentationDetents([.large])
}
private func save() {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
if let category {
categoryRepository.updateCategory(category, name: trimmed, colorHex: selectedColor, icon: selectedIcon)
} else {
categoryRepository.createCategory(name: trimmed, colorHex: selectedColor, icon: selectedIcon)
}
dismiss()
}
}
// MARK: - Color Swatch
private struct ColorSwatch: View {
let hex: String
let isSelected: Bool
var body: some View {
let color = Color(hex: hex) ?? .blue
Circle()
.fill(color)
.frame(width: 36, height: 36)
.overlay(Circle().strokeBorder(isSelected ? Color.primary : Color.clear, lineWidth: 2))
}
}
// MARK: - Icon Swatch
private struct IconSwatch: View {
let icon: String
let colorHex: String
let isSelected: Bool
var body: some View {
let color = Color(hex: colorHex) ?? .blue
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? color.opacity(0.2) : Color(.systemGray6))
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(isSelected ? color : .secondary)
}
}
}
#Preview {
NavigationStack {
CategoriesView()
}
}
@@ -706,7 +706,7 @@ struct SettingsView: View {
private var longTermSection: some View {
Section {
Toggle("Calm Mode", isOn: $calmModeEnabled)
Toggle("Focus Mode", isOn: $calmModeEnabled)
Toggle("Show Forecast", isOn: $showForecast)
@@ -723,7 +723,7 @@ struct SettingsView: View {
} header: {
Text("Long-Term Focus")
} footer: {
Text("Calm Mode hides short-term noise and advanced charts. Show Forecast controls whether prediction data appears in portfolio and charts.")
Text("Focus Mode shows returns since your last check-in instead of daily fluctuations, and hides advanced technical charts. Turn it off for full access to all metrics and chart types.")
}
}