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