Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa7df50400 | |||
| 66dc854461 | |||
| f2f54a5ca3 | |||
| 3b1f62cc26 | |||
| 8084bc6a82 | |||
| 6e6f4c5489 | |||
| cf984dbf09 | |||
| f1aeafccf6 | |||
| d941ecf27b | |||
| 9c2a67853d | |||
| d38c6943e1 | |||
| 525911f67a | |||
| 326be6db04 | |||
| 648c2bd805 | |||
| 6887d05b01 | |||
| fd1094b4fc | |||
| 922710c53f | |||
| 1a3fce0df4 | |||
| eee0910680 | |||
| e4a93a5d4a | |||
| 20a73ea957 | |||
| 8d5f309f21 | |||
| 84d9240ee6 | |||
| 8d7cfc098b | |||
| df9309895a | |||
| b60e3041cb | |||
| cbcb1da0ac | |||
| fc05dfe2c6 | |||
| 047060f126 | |||
| 2ea32b5494 | |||
| d7149aad67 | |||
| 57280ca3ca | |||
| 9539803d74 | |||
| d50926df76 | |||
| 99dc855467 | |||
| 20b8a32ea3 | |||
| d87d9d3dd5 | |||
| f34460d97a | |||
| e916218271 | |||
| 7eb7cb81d4 | |||
| 137931b37c | |||
| 32e1fb1c9c | |||
| 95c689d2b8 | |||
| 2a5ffca48f | |||
| 1e6e3ef361 | |||
| 8f14d4cd06 | |||
| 41e2a22a64 | |||
| d8bd625327 | |||
| 7c6a29c821 | |||
| 6bdf2740c5 | |||
| 86c95a9e73 | |||
| 1abffdb7bb | |||
| 18f2609d87 | |||
| b76b8f81f0 | |||
| 4c0d2af564 | |||
| 08094483ed | |||
| ed737bc290 | |||
| 7044cfd441 | |||
| be7119f4b9 | |||
| ff67ea1e71 | |||
| 7b8dec196d | |||
| 0e0aec6bb9 | |||
| a830a5f40c | |||
| 19b1408c55 | |||
| a953af2da0 | |||
| cef5924f16 | |||
| 386f4ff265 | |||
| f7d4fdbe2d | |||
| 7c92ea36c0 | |||
| a0710a6f76 | |||
| db844d5e80 | |||
| d0a51d0162 | |||
| 197bddcd7e | |||
| 19e80c912b | |||
| 9d56d420aa | |||
| 6651e59e22 | |||
| 9927d21b86 | |||
| 51223f7079 | |||
| 7a18dd8360 | |||
| 54dfd8a8e3 | |||
| 12ab2d6009 | |||
| 270edeb536 | |||
| 0b01d6f563 | |||
| b48a47ce10 | |||
| 773da6800b | |||
| 8acac3d529 | |||
| a9be27fa3a | |||
| b67bcc71b8 | |||
| 94ed4d17eb | |||
| 10f6d0ca20 | |||
| 7cb5f92cf4 | |||
| b17d8866a4 | |||
| c94974546f | |||
| e3ec0ddb25 | |||
| b6314db314 | |||
| 472342fa67 | |||
| 015e718b39 | |||
| 28b662e5a6 | |||
| ce4bbd9676 | |||
| bc159507c8 | |||
| 02ddad9e26 | |||
| edb04efc86 | |||
| ace58e5b0f | |||
| 151eb0e662 | |||
| b4615ac558 | |||
| c99398c350 | |||
| 0dac19b109 | |||
| 9d2ed68dcc | |||
| 5dc9eb109f | |||
| aceed608ed | |||
| 7d1eb874d8 | |||
| 8fa66c1c70 | |||
| d71e98c60c | |||
| 14af7deeda | |||
| 5ceeb93d91 | |||
| 3a72a75e5c | |||
| 4761e2e5c8 | |||
| daaca95913 | |||
| 2437dd647f | |||
| d55b999bef | |||
| c9dab29612 | |||
| 0ddee49dd5 | |||
| edfd7a2a56 | |||
| b5ba6c47a8 | |||
| f97f8026bc | |||
| e328767c4a |
+37
@@ -0,0 +1,37 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Xcode / build artifacts
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcarchive
|
||||
*.ipa
|
||||
*.dSYM
|
||||
*.dSYM.zip
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
# Signing / credentials (no versionar)
|
||||
*.mobileprovision
|
||||
*.p8
|
||||
*.p12
|
||||
*.cer
|
||||
ExportOptions.plist
|
||||
|
||||
# fastlane generated
|
||||
fastlane/report.xml
|
||||
fastlane/README.md
|
||||
fastlane/Preview.html
|
||||
fastlane/test_output/
|
||||
|
||||
# Documentos personales / binarios que no son del proyecto
|
||||
Justificante_*.pdf
|
||||
*.pkg
|
||||
og-image.png
|
||||
|
||||
# ASO tooling artifacts
|
||||
Scripts/aso/.venv/
|
||||
Scripts/aso/_*.png
|
||||
Scripts/aso/pano_*/
|
||||
Scripts/aso/originals/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Portfolio Journal will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Snapshot View**: Currency and number formatting now respects device locale settings with fallback for mixed locale input
|
||||
- **Goal Share Button**: Share button in Goals view now works correctly (was being intercepted by row tap gesture)
|
||||
- **Widget Currency**: Widget now displays correct currency symbol from app settings instead of defaulting to EUR
|
||||
- **Goal Editor**: Currency symbol prefix now displays correctly and number parsing is locale-aware
|
||||
|
||||
### Enhanced
|
||||
- **Goal Sharing**:
|
||||
- Added privacy mode option to hide current value when sharing
|
||||
- Share card now displays app icon (BrandMark)
|
||||
- Share card now shows target date when set
|
||||
- Share card now shows estimated completion date when available
|
||||
- Fallback text sharing includes App Store link
|
||||
- Dynamic card height based on content
|
||||
@@ -0,0 +1,99 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Portfolio Journal is a native iOS investment portfolio tracker built with Swift and SwiftUI. It helps users track investments, monitor performance with charts and predictions, set financial goals, and maintain an investment journal.
|
||||
|
||||
**Target:** iOS 17.6+ (widget supports iOS 16.0+)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Open project in Xcode
|
||||
open PortfolioJournal.xcodeproj
|
||||
|
||||
# Build for Debug
|
||||
xcodebuild -scheme PortfolioJournal -configuration Debug build
|
||||
|
||||
# Build for Release
|
||||
xcodebuild -scheme PortfolioJournal -configuration Release build
|
||||
|
||||
# Run on simulator
|
||||
xcodebuild -scheme PortfolioJournal -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||
|
||||
# Clean build
|
||||
xcodebuild -scheme PortfolioJournal clean
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The app uses Clean Architecture with MVVM pattern:
|
||||
|
||||
```
|
||||
PortfolioJournal/
|
||||
├── App/ # Entry point: PortfolioJournalApp.swift, AppDelegate, ContentView
|
||||
├── Models/CoreData/ # Core Data entities and CoreDataStack singleton
|
||||
├── Repositories/ # Data access layer with @MainActor CRUD operations
|
||||
├── Services/ # Business logic (CalculationService, PredictionEngine, IAPService, etc.)
|
||||
├── ViewModels/ # MVVM view models for each feature
|
||||
├── Views/ # SwiftUI views organized by feature
|
||||
│ ├── Dashboard/ # Main dashboard with evolution charts
|
||||
│ ├── Charts/ # Financial visualizations (allocation, performance, drawdown)
|
||||
│ ├── Sources/ # Investment source management
|
||||
│ ├── Goals/ # Goal tracking and progress
|
||||
│ ├── Journal/ # Journal entries
|
||||
│ ├── Settings/ # App settings and import/export
|
||||
│ ├── Security/ # Face ID/PIN lock (AppLockView)
|
||||
│ └── Components/ # Shared UI components
|
||||
├── Utilities/ # Helpers: KeychainService, FreemiumValidator, formatters, extensions
|
||||
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
|
||||
|
||||
PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit)
|
||||
```
|
||||
|
||||
### Core Data Model
|
||||
|
||||
Key entities: `Account`, `InvestmentSource`, `Snapshot`, `Category`, `Goal`, `Asset`, `Transaction`, `AppSettings`, `PremiumStatus`
|
||||
|
||||
Data flows through `CoreDataStack` singleton which manages CloudKit sync and AppGroup shared container for widget access.
|
||||
|
||||
### Key Services
|
||||
|
||||
- **CalculationService**: Portfolio metrics, returns calculation, allocation analysis
|
||||
- **PredictionEngine**: Investment forecasting algorithms with caching
|
||||
- **IAPService**: StoreKit 2 in-app purchases
|
||||
- **AdMobService**: Google Mobile Ads integration
|
||||
- **ImportService/ExportService**: CSV data import/export
|
||||
- **AppLockService**: Biometric/PIN security via Keychain
|
||||
|
||||
## Dependencies
|
||||
|
||||
Managed via Swift Package Manager:
|
||||
- Firebase iOS SDK (v12.7.0+) - Analytics
|
||||
- Google Mobile Ads SDK
|
||||
|
||||
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, LocalAuthentication
|
||||
|
||||
## App Initialization Flow
|
||||
|
||||
```
|
||||
PortfolioJournalApp (@main)
|
||||
└── AppDelegate (Firebase, AdMob, Notifications init)
|
||||
└── ContentView
|
||||
├── OnboardingView (first launch)
|
||||
├── AppLockView (if security enabled)
|
||||
└── TabBar: Dashboard | Sources | Goals | Journal | Settings
|
||||
```
|
||||
|
||||
## Localization
|
||||
|
||||
Supported languages: English (`en.lproj`), Spanish (`es-ES.lproj`)
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Use `SampleDataService` to generate demo data for testing
|
||||
- Premium features are gated via `FreemiumValidator`
|
||||
- Widget shares data through AppGroup container
|
||||
- Sensitive data stored in Keychain via `KeychainService`
|
||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>teamID</key>
|
||||
<string>2825Q76T7H</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.9)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1229.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.217.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.97.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.2)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-25
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1229.0) sha256=4cdba3093cc518e1ffe9f0f35050953cb5fb4a79797e4c928ab1cd3ed369407b
|
||||
aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea
|
||||
aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20
|
||||
aws-sdk-s3 (1.217.0) sha256=6ea709272c666888b14e9c62345abd9a6a967759ae13667c28f01fde6823c24b
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
|
||||
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
|
||||
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
|
||||
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
|
||||
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
|
||||
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
|
||||
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
|
||||
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
|
||||
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
|
||||
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
|
||||
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.97.0) sha256=0f3859844872ec09b64dde3bff6dee84458eb61d664337402adcbb4ac912322a
|
||||
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
|
||||
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
|
||||
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
|
||||
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
|
||||
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
|
||||
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
|
||||
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
|
||||
google-cloud-storage (1.58.0) sha256=1bedc07a9c75af169e1ede1dd306b9f941f9ffa9e7095d0364c0803c468fdffd
|
||||
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
|
||||
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf
|
||||
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
||||
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
|
||||
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.8
|
||||
@@ -0,0 +1,135 @@
|
||||
# Portfolio Journal Makefile
|
||||
# Usage: make [target]
|
||||
#
|
||||
# Available targets:
|
||||
# test - Run all unit tests
|
||||
# test-unit - Run unit tests only
|
||||
# test-ui - Run UI tests only
|
||||
# test-coverage - Run tests with code coverage
|
||||
# build - Build the app for debug
|
||||
# build-release - Build the app for release
|
||||
# clean - Clean build artifacts
|
||||
# setup-tests - Set up test targets in Xcode project
|
||||
# help - Show this help message
|
||||
|
||||
.PHONY: test test-unit test-ui test-coverage build build-release clean setup-tests help
|
||||
|
||||
# Configuration
|
||||
PROJECT = PortfolioJournal.xcodeproj
|
||||
SCHEME = PortfolioJournal
|
||||
DEVICE = iPhone 17
|
||||
DESTINATION = platform=iOS Simulator,name=$(DEVICE)
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Portfolio Journal - Build & Test Commands"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Test targets:"
|
||||
@echo " test Run all unit tests"
|
||||
@echo " test-unit Run unit tests only"
|
||||
@echo " test-ui Run UI tests only"
|
||||
@echo " test-coverage Run tests with code coverage"
|
||||
@echo " test-quick Run tests without pretty output (faster)"
|
||||
@echo ""
|
||||
@echo "Build targets:"
|
||||
@echo " build Build the app for debug"
|
||||
@echo " build-release Build the app for release"
|
||||
@echo " clean Clean build artifacts"
|
||||
@echo ""
|
||||
@echo "Setup targets:"
|
||||
@echo " setup-tests Set up test targets in Xcode project"
|
||||
@echo " install-tools Install required development tools"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make test # Run all tests"
|
||||
@echo " make test DEVICE='iPhone 16' # Run tests on specific device"
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
@echo "Running all tests on $(DEVICE)..."
|
||||
@./Scripts/run_tests.sh --unit --device "$(DEVICE)"
|
||||
|
||||
# Run unit tests only
|
||||
test-unit:
|
||||
@echo "Running unit tests on $(DEVICE)..."
|
||||
@./Scripts/run_tests.sh --unit --device "$(DEVICE)"
|
||||
|
||||
# Run UI tests only
|
||||
test-ui:
|
||||
@echo "Running UI tests on $(DEVICE)..."
|
||||
@./Scripts/run_tests.sh --ui --device "$(DEVICE)"
|
||||
|
||||
# Run tests with coverage
|
||||
test-coverage:
|
||||
@echo "Running tests with coverage on $(DEVICE)..."
|
||||
@./Scripts/run_tests.sh --all --coverage --device "$(DEVICE)"
|
||||
|
||||
# Quick test run without xcpretty
|
||||
test-quick:
|
||||
@echo "Running quick tests on $(DEVICE)..."
|
||||
xcodebuild test \
|
||||
-project $(PROJECT) \
|
||||
-scheme $(SCHEME) \
|
||||
-destination "$(DESTINATION)" \
|
||||
-only-testing:PortfolioJournalTests \
|
||||
| grep -E "(Test Case|passed|failed|error:)" || true
|
||||
|
||||
# Build for debug
|
||||
build:
|
||||
@echo "Building for Debug..."
|
||||
xcodebuild \
|
||||
-project $(PROJECT) \
|
||||
-scheme $(SCHEME) \
|
||||
-configuration Debug \
|
||||
-destination "$(DESTINATION)" \
|
||||
build
|
||||
|
||||
# Build for release
|
||||
build-release:
|
||||
@echo "Building for Release..."
|
||||
xcodebuild \
|
||||
-project $(PROJECT) \
|
||||
-scheme $(SCHEME) \
|
||||
-configuration Release \
|
||||
build
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
xcodebuild \
|
||||
-project $(PROJECT) \
|
||||
-scheme $(SCHEME) \
|
||||
clean
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/PortfolioJournal-*
|
||||
|
||||
# Set up test targets
|
||||
setup-tests:
|
||||
@echo "Setting up test targets..."
|
||||
@if command -v ruby >/dev/null 2>&1; then \
|
||||
ruby Scripts/setup_tests.rb; \
|
||||
else \
|
||||
echo "Ruby not found. Please add test target manually in Xcode:"; \
|
||||
echo "1. File > New > Target > iOS Unit Testing Bundle"; \
|
||||
echo "2. Name it 'PortfolioJournalTests'"; \
|
||||
echo "3. Add test files from PortfolioJournalTests folder"; \
|
||||
fi
|
||||
|
||||
# Install development tools
|
||||
install-tools:
|
||||
@echo "Installing development tools..."
|
||||
@if command -v gem >/dev/null 2>&1; then \
|
||||
gem install xcpretty xcodeproj; \
|
||||
else \
|
||||
echo "RubyGems not found. Please install Ruby first."; \
|
||||
fi
|
||||
|
||||
# Pre-release checks
|
||||
pre-release: clean build-release test
|
||||
@echo ""
|
||||
@echo "✅ Pre-release checks passed!"
|
||||
@echo "Ready to submit to App Store."
|
||||
@@ -14,6 +14,8 @@
|
||||
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; };
|
||||
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; };
|
||||
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
|
||||
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; };
|
||||
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -24,6 +26,27 @@
|
||||
remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F;
|
||||
remoteInfo = PortfolioJournalWidgetExtension;
|
||||
};
|
||||
0E481F2C2F2E958100CF94C5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0E241E382F0DA93A00283E2F;
|
||||
remoteInfo = PortfolioJournal;
|
||||
};
|
||||
0E481F2D2F2E958100CF94C5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0E241E382F0DA93A00283E2F;
|
||||
remoteInfo = PortfolioJournal;
|
||||
};
|
||||
0EQUPD112F40000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0EQUPD022F40000000000001;
|
||||
remoteInfo = PortfolioJournalQuickUpdate;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -34,6 +57,7 @@
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */,
|
||||
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -47,6 +71,10 @@
|
||||
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||
0E53752E2F0FD09F00F31390 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; };
|
||||
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalQuickUpdate.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalQuickUpdateExtension.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -71,6 +99,13 @@
|
||||
);
|
||||
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
|
||||
};
|
||||
0EQUPD132F40000000000001 /* Exceptions for "PortfolioJournalQuickUpdate" folder in "PortfolioJournalQuickUpdate" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -91,6 +126,24 @@
|
||||
path = PortfolioJournalWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PortfolioJournalTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PortfolioJournalUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
0EQUPD132F40000000000001 /* Exceptions for "PortfolioJournalQuickUpdate" folder in "PortfolioJournalQuickUpdate" target */,
|
||||
);
|
||||
path = PortfolioJournalQuickUpdate;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -102,6 +155,7 @@
|
||||
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
|
||||
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
|
||||
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
|
||||
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -114,6 +168,27 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0ETEST0042F31000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EUITEST042F31000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EQUPD052F40000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -121,8 +196,12 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */,
|
||||
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */,
|
||||
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
|
||||
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
||||
0E241E3A2F0DA93A00283E2F /* Products */,
|
||||
);
|
||||
@@ -133,6 +212,9 @@
|
||||
children = (
|
||||
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */,
|
||||
0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */,
|
||||
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */,
|
||||
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
|
||||
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -158,11 +240,13 @@
|
||||
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
|
||||
0EQUPD122F40000000000001 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||
@@ -172,6 +256,7 @@
|
||||
0E53752A2F0FD08100F31390 /* FirebaseCore */,
|
||||
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
|
||||
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
|
||||
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
|
||||
);
|
||||
productName = PortfolioJournal;
|
||||
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
|
||||
@@ -199,6 +284,74 @@
|
||||
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
0ETEST0012F31000000000001 /* PortfolioJournalTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0ETEST0072F31000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalTests" */;
|
||||
buildPhases = (
|
||||
0ETEST0052F31000000000001 /* Sources */,
|
||||
0ETEST0042F31000000000001 /* Frameworks */,
|
||||
0ETEST0062F31000000000001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0ETEST0022F31000000000001 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
|
||||
);
|
||||
name = PortfolioJournalTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalTests;
|
||||
productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
0EUITEST012F31000000001 /* PortfolioJournalUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0EUITEST072F31000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalUITests" */;
|
||||
buildPhases = (
|
||||
0EUITEST052F31000000001 /* Sources */,
|
||||
0EUITEST042F31000000001 /* Frameworks */,
|
||||
0EUITEST062F31000000001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0EUITEST022F31000000001 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
);
|
||||
name = PortfolioJournalUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalUITests;
|
||||
productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 0EQUPD072F40000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalQuickUpdate" */;
|
||||
buildPhases = (
|
||||
0EQUPD042F40000000000001 /* Sources */,
|
||||
0EQUPD052F40000000000001 /* Frameworks */,
|
||||
0EQUPD062F40000000000001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
);
|
||||
name = PortfolioJournalQuickUpdate;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PortfolioJournalQuickUpdate;
|
||||
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -215,6 +368,17 @@
|
||||
0E241ECB2F0DAA3C00283E2F = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
0EQUPD022F40000000000001 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
0ETEST0012F31000000000001 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 0E241E382F0DA93A00283E2F;
|
||||
};
|
||||
0EUITEST012F31000000001 = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 0E241E382F0DA93A00283E2F;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */;
|
||||
@@ -239,6 +403,9 @@
|
||||
targets = (
|
||||
0E241E382F0DA93A00283E2F /* PortfolioJournal */,
|
||||
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */,
|
||||
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */,
|
||||
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
|
||||
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -258,6 +425,27 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0ETEST0062F31000000000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EUITEST062F31000000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EQUPD062F40000000000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -275,14 +463,73 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0ETEST0052F31000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EUITEST052F31000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EQUPD042F40000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
|
||||
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
|
||||
);
|
||||
name = "Upload dSYMs to Crashlytics";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
|
||||
targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */;
|
||||
};
|
||||
0ETEST0022F31000000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
|
||||
targetProxy = 0E481F2C2F2E958100CF94C5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
0EUITEST022F31000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
|
||||
targetProxy = 0E481F2D2F2E958100CF94C5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
0EQUPD122F40000000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
|
||||
targetProxy = 0EQUPD112F40000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -292,8 +539,9 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -311,7 +559,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -330,8 +578,11 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -349,7 +600,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -488,7 +739,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -501,7 +752,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -520,8 +771,10 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -534,7 +787,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -547,6 +800,156 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
0ETEST0082F31000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PortfolioJournal.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PortfolioJournal";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0ETEST0092F31000000000001 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PortfolioJournal.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PortfolioJournal";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
0EUITEST082F31000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = PortfolioJournal;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0EUITEST092F31000000001 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = PortfolioJournal;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
0EQUPD082F40000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0EQUPD092F40000000000001 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -577,6 +980,33 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
0ETEST0072F31000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0ETEST0082F31000000000001 /* Debug */,
|
||||
0ETEST0092F31000000000001 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
0EUITEST072F31000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0EUITEST082F31000000001 /* Debug */,
|
||||
0EUITEST092F31000000001 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
0EQUPD072F40000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalQuickUpdate" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
0EQUPD082F40000000000001 /* Debug */,
|
||||
0EQUPD092F40000000000001 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -614,6 +1044,11 @@
|
||||
package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
|
||||
productName = GoogleMobileAds;
|
||||
};
|
||||
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseCrashlytics;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
@@ -27,8 +27,31 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0ETEST0012F31000000000001"
|
||||
BuildableName = "PortfolioJournalTests.xctest"
|
||||
BlueprintName = "PortfolioJournalTests"
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0EUITEST012F31000000001"
|
||||
BuildableName = "PortfolioJournalUITests.xctest"
|
||||
BlueprintName = "PortfolioJournalUITests"
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -50,6 +73,20 @@
|
||||
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.Logging.stderr 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-InitializeCloudKitSchema"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.CloudKitDebug 3 "
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../PortfolioJournal/Configuration.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
|
||||
+5
@@ -9,6 +9,11 @@
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>PortfolioJournalQuickUpdate.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -2,6 +2,7 @@ import UIKit
|
||||
import UserNotifications
|
||||
import FirebaseCore
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCrashlytics
|
||||
import GoogleMobileAds
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
// MARK: - App Tab
|
||||
|
||||
enum AppTab: Int, Hashable, CaseIterable {
|
||||
case dashboard = 0, sources = 1, charts = 2, journal = 3, settings = 4
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .dashboard: String(localized: "tab_dashboard")
|
||||
case .sources: String(localized: "tab_sources")
|
||||
case .charts: String(localized: "tab_charts")
|
||||
case .journal: "Journal"
|
||||
case .settings: String(localized: "tab_settings")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .dashboard: "house.fill"
|
||||
case .sources: "list.bullet"
|
||||
case .charts: "chart.xyaxis.line"
|
||||
case .journal: "book.closed"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var adMobService: AdMobService
|
||||
@@ -11,21 +39,44 @@ struct ContentView: View {
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
||||
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var isUnlocked = false
|
||||
@State private var resolvedOnboardingCompleted: Bool?
|
||||
@State private var iCloudCheckDone = false
|
||||
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
|
||||
@State private var sidebarSelection: AppTab? = .dashboard
|
||||
@State private var showingWhatsNew = false
|
||||
|
||||
private var currentVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
}
|
||||
|
||||
private var lockEnabled: Bool {
|
||||
faceIdEnabled || pinEnabled
|
||||
}
|
||||
|
||||
/// True when a fresh install with iCloud available and sync not yet enabled.
|
||||
/// Only relevant before onboarding is completed.
|
||||
private var needsICloudCheck: Bool {
|
||||
guard resolvedOnboardingCompleted == false else { return false }
|
||||
guard !UserDefaults.standard.bool(forKey: "cloudSyncEnabled") else { return false }
|
||||
return FileManager.default.ubiquityIdentityToken != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if !isReadyForContent {
|
||||
AppLaunchLoadingView(messageKey: "loading_data")
|
||||
AppLaunchLoadingView(messageKey: loadingMessageKey)
|
||||
} else if needsICloudCheck && !iCloudCheckDone {
|
||||
// Fresh install with iCloud available: ask before showing onboarding
|
||||
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
|
||||
} else if resolvedOnboardingCompleted == false {
|
||||
OnboardingView(onboardingCompleted: $onboardingCompleted)
|
||||
} else if horizontalSizeClass == .regular {
|
||||
iPadMainContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
@@ -62,9 +113,51 @@ struct ContentView: View {
|
||||
}
|
||||
.task {
|
||||
await waitForDataAndResolveOnboarding()
|
||||
// Cumulative What's New: everything between the version the user
|
||||
// comes from and the one they just installed. Empty catalog diff
|
||||
// (e.g. patch release without entry) → no sheet.
|
||||
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion,
|
||||
!WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty {
|
||||
// Small delay to let the UI settle before showing sheet
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
showingWhatsNew = true
|
||||
}
|
||||
}
|
||||
.onChange(of: onboardingCompleted) { _, completed in
|
||||
resolvedOnboardingCompleted = completed
|
||||
// Fresh install: nothing is "new" for a user who just onboarded.
|
||||
if completed && lastSeenWhatsNewVersion.isEmpty {
|
||||
lastSeenWhatsNewVersion = currentVersion
|
||||
// Straight to the aha moment: open Add Source instead of
|
||||
// dropping the new user on an empty dashboard.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingWhatsNew, onDismiss: {
|
||||
lastSeenWhatsNewVersion = currentVersion
|
||||
}) {
|
||||
WhatsNewView(releases: WhatsNewCatalog.pendingReleases(
|
||||
since: lastSeenWhatsNewVersion,
|
||||
current: currentVersion
|
||||
))
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
|
||||
tabSelection.selectedTab = 1
|
||||
sidebarSelection = .sources
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
|
||||
tabSelection.selectedTab = 0
|
||||
sidebarSelection = .dashboard
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.host == "quickupdate" {
|
||||
tabSelection.selectedTab = 0
|
||||
sidebarSelection = .dashboard
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +171,31 @@ struct ContentView: View {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||
}
|
||||
|
||||
// If CloudKit is enabled and no local data yet, wait briefly for the
|
||||
// initial iCloud sync so data from other devices appears before we
|
||||
// decide whether to show onboarding.
|
||||
if UserDefaults.standard.bool(forKey: "cloudSyncEnabled") && !hasExistingData() {
|
||||
await MainActor.run { loadingMessageKey = "checking_icloud" }
|
||||
await waitForInitialCloudKitSync(timeout: 10)
|
||||
await MainActor.run { loadingMessageKey = "loading_data" }
|
||||
}
|
||||
|
||||
// Resolve onboarding state on main thread
|
||||
await MainActor.run {
|
||||
syncOnboardingState()
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls for existing data up to `timeout` seconds, returning as soon as
|
||||
/// any data appears. Used to wait for the initial CloudKit sync on launch.
|
||||
private func waitForInitialCloudKitSync(timeout: TimeInterval) async {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if hasExistingData() { return }
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
|
||||
}
|
||||
}
|
||||
|
||||
private func syncOnboardingState() {
|
||||
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
|
||||
var resolved = settings.onboardingCompleted || onboardingCompleted
|
||||
@@ -128,6 +240,49 @@ struct ContentView: View {
|
||||
return ((try? context.count(for: accountRequest)) ?? 0) > 0
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout
|
||||
|
||||
private var iPadMainContent: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
|
||||
Label(tab.title, systemImage: tab.icon)
|
||||
}
|
||||
.navigationTitle("Portfolio Journal")
|
||||
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
|
||||
} detail: {
|
||||
iPadDetailView(for: sidebarSelection ?? .dashboard)
|
||||
}
|
||||
.onChange(of: sidebarSelection) { _, tab in
|
||||
guard let tab else { return }
|
||||
if tabSelection.selectedTab != tab.rawValue {
|
||||
tabSelection.selectedTab = tab.rawValue
|
||||
}
|
||||
}
|
||||
.onChange(of: tabSelection.selectedTab) { _, value in
|
||||
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
|
||||
sidebarSelection = tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iPadDetailView(for tab: AppTab) -> some View {
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
bannerInsetView(DashboardView())
|
||||
case .sources:
|
||||
bannerInsetView(SourceListView(iapService: iapService))
|
||||
case .charts:
|
||||
bannerInsetView(ChartsContainerView(iapService: iapService))
|
||||
case .journal:
|
||||
bannerInsetView(JournalView())
|
||||
case .settings:
|
||||
bannerInsetView(SettingsView(iapService: iapService))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout
|
||||
|
||||
private var mainContent: some View {
|
||||
ZStack {
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
@@ -166,7 +321,7 @@ struct ContentView: View {
|
||||
|
||||
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
|
||||
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !iapService.isPremium {
|
||||
if !iapService.isPremium && adMobService.canShowAds {
|
||||
BannerAdView()
|
||||
.frame(height: AppConstants.UI.bannerAdHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -183,4 +338,5 @@ struct ContentView: View {
|
||||
.environmentObject(AdMobService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
.environmentObject(TabSelectionStore())
|
||||
.environmentObject(BalancePrivacyManager.shared)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import TipKit
|
||||
|
||||
/// Support for automated App Store screenshot capture. Activated by launching the app
|
||||
/// with the `--screenshots` argument (used by the UI-test capture flow). Only mutates
|
||||
/// state when that argument is present, so it never affects normal runs.
|
||||
enum ScreenshotMode {
|
||||
static var isActive: Bool { CommandLine.arguments.contains("--screenshots") }
|
||||
|
||||
/// Skip onboarding, biometric/PIN lock and the What's New sheet so the app opens
|
||||
/// straight into content. Called very early, before ContentView reads @AppStorage.
|
||||
static func applyDefaultsIfNeeded() {
|
||||
guard isActive else { return }
|
||||
let d = UserDefaults.standard
|
||||
d.set(true, forKey: "onboardingCompleted")
|
||||
d.set(false, forKey: "faceIdEnabled")
|
||||
d.set(false, forKey: "pinEnabled")
|
||||
d.set(false, forKey: "lockOnLaunch")
|
||||
d.set(false, forKey: "lockOnBackground")
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
d.set(version, forKey: "lastSeenWhatsNewVersion")
|
||||
// Unlock premium (DEBUG-only override) so captures/UI tests can exercise
|
||||
// the premium charts without a StoreKit purchase. `--no-premium` keeps it
|
||||
// off to test the free-tier experience (locked charts, teasers).
|
||||
d.set(!CommandLine.arguments.contains("--no-premium"), forKey: "debugPremiumOverride")
|
||||
}
|
||||
|
||||
/// Seed demo data (no-op if the store already has sources). Called once Core Data
|
||||
/// has finished loading so the view context is ready.
|
||||
static func seedIfNeeded() {
|
||||
guard isActive else { return }
|
||||
SampleDataService.shared.seedSampleData()
|
||||
makeCheckInPendingIfNeeded()
|
||||
}
|
||||
|
||||
/// `--pending-checkin`: strip the seeded data back to a "month not done yet"
|
||||
/// state (recent snapshots + this month's journal completion removed) so the
|
||||
/// guided check-in flow can be exercised/captured.
|
||||
private static func makeCheckInPendingIfNeeded() {
|
||||
guard CommandLine.arguments.contains("--pending-checkin") else { return }
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let cutoff = Calendar.current.date(byAdding: .day, value: -40, to: Date()) ?? Date()
|
||||
|
||||
let snapshotRequest = Snapshot.fetchRequest()
|
||||
snapshotRequest.predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
|
||||
(try? context.fetch(snapshotRequest))?.forEach(context.delete)
|
||||
|
||||
let journalRequest = JournalEntry.fetchRequest()
|
||||
journalRequest.predicate = NSPredicate(format: "completionTime >= %@", cutoff as NSDate)
|
||||
(try? context.fetch(journalRequest))?.forEach(context.delete)
|
||||
|
||||
try? context.save()
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct PortfolioJournalApp: App {
|
||||
@@ -7,14 +62,22 @@ struct PortfolioJournalApp: App {
|
||||
@StateObject private var adMobService: AdMobService
|
||||
@StateObject private var accountStore: AccountStore
|
||||
@StateObject private var tabSelection = TabSelectionStore()
|
||||
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
let coreDataStack = CoreDataStack.shared
|
||||
|
||||
init() {
|
||||
// Screenshot/UI-capture mode: skip onboarding, lock and What's New so the app
|
||||
// launches straight into content with demo data (see ScreenshotMode).
|
||||
ScreenshotMode.applyDefaultsIfNeeded()
|
||||
|
||||
// Clean up any duplicate objects from previous bugs before initializing stores
|
||||
CoreDataStack.shared.cleanupDuplicateObjects()
|
||||
|
||||
// Feature-discovery tips (Quick Update, OCR, Siri, chart sharing)
|
||||
try? Tips.configure([.displayFrequency(.daily)])
|
||||
|
||||
let iap = IAPService()
|
||||
_iapService = StateObject(wrappedValue: iap)
|
||||
_adMobService = StateObject(wrappedValue: AdMobService())
|
||||
@@ -30,10 +93,38 @@ struct PortfolioJournalApp: App {
|
||||
.environmentObject(adMobService)
|
||||
.environmentObject(accountStore)
|
||||
.environmentObject(tabSelection)
|
||||
.environmentObject(balancePrivacy)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
coreDataStack.refreshWidgetData()
|
||||
// Re-read all Core Data objects from the persistent store so that
|
||||
// iCloud changes made on other devices while this device was inactive
|
||||
// are reflected immediately without waiting for a remote-change notification.
|
||||
coreDataStack.refreshFromCloudKit()
|
||||
// Auto-backup: writes a lossless JSON snapshot on the configured cadence.
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
BackupService.shared.runScheduledBackupIfNeeded()
|
||||
}
|
||||
NotificationService.shared.scheduleReEngagementNotification()
|
||||
NotificationService.shared.scheduleMonthlyCheckIn()
|
||||
NotificationService.shared.scheduleStreakProtectionReminder()
|
||||
// Share-extension bridge: apply values captured from other apps and
|
||||
// refresh the source mirror the extension reads.
|
||||
if coreDataStack.isLoaded {
|
||||
SharedQuickUpdateSync.ingestPending()
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
}
|
||||
} else if newPhase == .background {
|
||||
guard iapService.isPremium else { return }
|
||||
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
|
||||
let retention = UserDefaults.standard.integer(forKey: "backupRetentionCount")
|
||||
let keepCount = [5, 10, 20].contains(retention) ? retention : 10
|
||||
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||
_ = BackupService.shared.createBackup(
|
||||
retentionCount: keepCount,
|
||||
includeICloud: includeICloud
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -7,9 +7,21 @@ public class Account: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Account>(entityName: "Account")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var createdAt: Date
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
|
||||
// CloudKit merge when an imported record materialises with a transient nil.
|
||||
// Same nil-tolerant accessor pattern already applied to Snapshot.
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var inputMode: String
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@@ -34,6 +46,11 @@ public class Account: NSManagedObject, Identifiable {
|
||||
|
||||
extension Account {
|
||||
static let defaultAccountName = "Default"
|
||||
|
||||
/// Deterministic UUID for the auto-created "Default" account, so every device
|
||||
/// creates it as the SAME CloudKit record instead of a per-device duplicate.
|
||||
/// See StableID.
|
||||
static let defaultAccountStableID = StableID.v5("account:default")
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -8,12 +8,25 @@ public class Category: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Category>(entityName: "Category")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var colorHex: String
|
||||
@NSManaged public var icon: String
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
@objc public var colorHex: String {
|
||||
get { safeValue(forKey: "colorHex", fallback: "#6B7280") }
|
||||
set { setManagedValue(newValue, forKey: "colorHex") }
|
||||
}
|
||||
@objc public var icon: String {
|
||||
get { safeValue(forKey: "icon", fallback: "questionmark") }
|
||||
set { setManagedValue(newValue, forKey: "icon") }
|
||||
}
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var allocationTarget: NSNumber?
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
@@ -40,7 +53,8 @@ extension Category {
|
||||
}
|
||||
|
||||
var sourceCount: Int {
|
||||
sources?.count ?? 0
|
||||
guard !isDeleted, !isFault else { return 0 }
|
||||
return sources?.count ?? 0
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
@@ -78,9 +92,17 @@ extension Category {
|
||||
("ETFs", "#EC4899", "chart.bar.fill")
|
||||
]
|
||||
|
||||
/// Deterministic UUID for a default category, so every device creates each
|
||||
/// default category as the SAME CloudKit record instead of a per-device
|
||||
/// duplicate. Only meaningful for the built-in default names. See StableID.
|
||||
static func stableID(for name: String) -> UUID {
|
||||
StableID.v5("category:\(name.lowercased())")
|
||||
}
|
||||
|
||||
static func createDefaultCategories(in context: NSManagedObjectContext) {
|
||||
for (index, categoryData) in defaultCategories.enumerated() {
|
||||
let category = Category(context: context)
|
||||
category.id = stableID(for: categoryData.name)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
|
||||
@@ -7,12 +7,24 @@ public class Goal: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Goal>(entityName: "Goal")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
@NSManaged public var targetAmount: NSDecimalNumber?
|
||||
@NSManaged public var targetDate: Date?
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
|
||||
// CloudKit merge when an imported record materialises with a transient nil.
|
||||
// Same nil-tolerant accessor pattern already applied to Snapshot.
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var account: Account?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
|
||||
@@ -7,16 +7,28 @@ public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var name: String
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var name: String {
|
||||
get { safeValue(forKey: "name", fallback: "") }
|
||||
set { setManagedValue(newValue, forKey: "name") }
|
||||
}
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var monthlyContribution: NSDecimalNumber?
|
||||
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
|
||||
// CloudKit merge when an imported record materialises with a transient nil.
|
||||
// Same nil-tolerant accessor pattern already applied to Snapshot.
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var category: Category?
|
||||
@NSManaged public var account: Account?
|
||||
@NSManaged public var snapshots: NSSet?
|
||||
@NSManaged public var transactions: NSSet?
|
||||
@NSManaged public var asset: Asset?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
@@ -28,6 +40,14 @@ public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
customFrequencyMonths = 1
|
||||
name = ""
|
||||
}
|
||||
|
||||
public override func awakeFromFetch() {
|
||||
super.awakeFromFetch()
|
||||
// Defensive: ensure id exists for legacy rows where id may be nil.
|
||||
if value(forKey: "id") == nil {
|
||||
setValue(UUID(), forKey: "id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Frequency
|
||||
@@ -96,13 +116,6 @@ extension InvestmentSource {
|
||||
snapshots?.count ?? 0
|
||||
}
|
||||
|
||||
/// Returns transactions sorted by date descending
|
||||
/// Performance note: This sorts on every call. For repeated access, cache the result.
|
||||
var transactionsArray: [Transaction] {
|
||||
let set = transactions as? Set<Transaction> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var frequency: NotificationFrequency {
|
||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
||||
}
|
||||
@@ -160,37 +173,6 @@ extension InvestmentSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalInvested: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
let amount = transaction.decimalAmount
|
||||
switch transaction.transactionType {
|
||||
case .buy:
|
||||
return result + amount
|
||||
case .sell:
|
||||
return result - amount
|
||||
default:
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalDividends: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance: Iterates transactions directly without sorting
|
||||
var totalFees: Decimal {
|
||||
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
|
||||
return set.reduce(Decimal.zero) { result, transaction in
|
||||
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Scheduling
|
||||
@@ -225,15 +207,4 @@ extension InvestmentSource {
|
||||
@objc(removeSnapshots:)
|
||||
@NSManaged public func removeFromSnapshots(_ values: NSSet)
|
||||
|
||||
@objc(addTransactionsObject:)
|
||||
@NSManaged public func addToTransactions(_ value: Transaction)
|
||||
|
||||
@objc(removeTransactionsObject:)
|
||||
@NSManaged public func removeFromTransactions(_ value: Transaction)
|
||||
|
||||
@objc(addTransactions:)
|
||||
@NSManaged public func addToTransactions(_ values: NSSet)
|
||||
|
||||
@objc(removeTransactions:)
|
||||
@NSManaged public func removeFromTransactions(_ values: NSSet)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(JournalEntry)
|
||||
public class JournalEntry: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<JournalEntry> {
|
||||
return NSFetchRequest<JournalEntry>(entityName: "JournalEntry")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID?
|
||||
@NSManaged public var monthKey: String?
|
||||
@NSManaged public var note: String?
|
||||
@NSManaged public var moodRaw: String?
|
||||
@NSManaged public var rating: Int16
|
||||
@NSManaged public var completionTime: Date?
|
||||
@NSManaged public var createdAt: Date?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
}
|
||||
|
||||
var mood: MonthlyCheckInMood? {
|
||||
get { moodRaw.flatMap { MonthlyCheckInMood(rawValue: $0) } }
|
||||
set { moodRaw = newValue?.rawValue }
|
||||
}
|
||||
|
||||
var ratingValue: Int? {
|
||||
get { rating > 0 ? Int(rating) : nil }
|
||||
set { rating = Int16(newValue.map { min(max(1, $0), 5) } ?? 0) }
|
||||
}
|
||||
|
||||
var completionDate: Date? {
|
||||
get { completionTime }
|
||||
set { completionTime = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import CoreData
|
||||
|
||||
// MARK: - Nil-tolerant accessors for CloudKit-synced attributes
|
||||
//
|
||||
// Attributes declared non-optional in Swift (`id: UUID`, `date: Date`, …) CAN
|
||||
// be nil at runtime: NSPersistentCloudKitContainer merges records from other
|
||||
// devices in stages, and deleted objects may still be referenced by live views.
|
||||
// Reading them through `@NSManaged` force-bridges nil and crashes
|
||||
// (`UUID/Date._unconditionallyBridgeFromObjectiveC`) — seen in production in
|
||||
// SwiftUI ForEach identity (nil id) and ChartsViewModel.monthlyTotals (nil
|
||||
// date). These helpers read the primitive value and fall back safely.
|
||||
extension NSManagedObject {
|
||||
/// Reads a primitive attribute, returning `fallback` when nil. With
|
||||
/// `healing: true` the fallback is also written back to the primitive
|
||||
/// (without dirtying the object) so identity stays stable for the session.
|
||||
func safeValue<T>(forKey key: String, fallback: @autoclosure () -> T, healing: Bool = false) -> T {
|
||||
willAccessValue(forKey: key)
|
||||
defer { didAccessValue(forKey: key) }
|
||||
if let value = primitiveValue(forKey: key) as? T {
|
||||
return value
|
||||
}
|
||||
let healed = fallback()
|
||||
if healing {
|
||||
setPrimitiveValue(healed, forKey: key)
|
||||
}
|
||||
return healed
|
||||
}
|
||||
|
||||
func setManagedValue<T>(_ value: T, forKey key: String) {
|
||||
willChangeValue(forKey: key)
|
||||
setPrimitiveValue(value, forKey: key)
|
||||
didChangeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -21,23 +21,25 @@
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||
<attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/>
|
||||
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="monthlyContribution" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
|
||||
@@ -75,7 +77,7 @@
|
||||
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
|
||||
</entity>
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
||||
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
|
||||
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
@@ -98,8 +100,18 @@
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isEstimated" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="JournalEntry" representedClassName="JournalEntry" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="monthKey" optional="YES" attributeType="String"/>
|
||||
<attribute name="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="moodRaw" optional="YES" attributeType="String"/>
|
||||
<attribute name="rating" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
|
||||
<attribute name="completionTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
</model>
|
||||
|
||||
@@ -7,12 +7,25 @@ public class Snapshot: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@objc public var id: UUID {
|
||||
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
|
||||
set { setManagedValue(newValue, forKey: "id") }
|
||||
}
|
||||
@objc public var date: Date {
|
||||
get { safeValue(forKey: "date", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "date") }
|
||||
}
|
||||
@NSManaged public var value: NSDecimalNumber?
|
||||
@NSManaged public var contribution: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
/// True for auto-generated (interpolated) snapshots that fill a gap between two
|
||||
/// real snapshots. Estimated snapshots are shown with a distinct marker and can
|
||||
/// be removed in bulk. Defaults to false (model default) for all real snapshots.
|
||||
@NSManaged public var isEstimated: Bool
|
||||
@objc public var createdAt: Date {
|
||||
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
|
||||
set { setManagedValue(newValue, forKey: "createdAt") }
|
||||
}
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
@@ -21,11 +34,35 @@ public class Snapshot: NSManagedObject, Identifiable {
|
||||
date = Date()
|
||||
createdAt = Date()
|
||||
}
|
||||
|
||||
public override func awakeFromFetch() {
|
||||
super.awakeFromFetch()
|
||||
// Defensive: ensure id exists for legacy rows where id may be nil.
|
||||
if value(forKey: "id") == nil {
|
||||
setValue(UUID(), forKey: "id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Snapshot {
|
||||
var safeId: UUID {
|
||||
if let existing = primitiveValue(forKey: "id") as? UUID {
|
||||
return existing
|
||||
}
|
||||
let newId = UUID()
|
||||
setPrimitiveValue(newId, forKey: "id")
|
||||
return newId
|
||||
}
|
||||
|
||||
var safeDate: Date {
|
||||
if let dateValue = primitiveValue(forKey: "date") as? Date {
|
||||
return dateValue
|
||||
}
|
||||
return Date()
|
||||
}
|
||||
|
||||
var decimalValue: Decimal {
|
||||
value?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Transaction)
|
||||
public class Transaction: NSManagedObject, Identifiable {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
|
||||
return NSFetchRequest<Transaction>(entityName: "Transaction")
|
||||
}
|
||||
|
||||
@NSManaged public var id: UUID
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var type: String
|
||||
@NSManaged public var shares: NSDecimalNumber?
|
||||
@NSManaged public var price: NSDecimalNumber?
|
||||
@NSManaged public var amount: NSDecimalNumber?
|
||||
@NSManaged public var notes: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var source: InvestmentSource?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
id = UUID()
|
||||
createdAt = Date()
|
||||
date = Date()
|
||||
type = TransactionType.buy.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
enum TransactionType: String, CaseIterable, Identifiable {
|
||||
case buy
|
||||
case sell
|
||||
case dividend
|
||||
case fee
|
||||
case transfer
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .buy: return "Buy"
|
||||
case .sell: return "Sell"
|
||||
case .dividend: return "Dividend"
|
||||
case .fee: return "Fee"
|
||||
case .transfer: return "Transfer"
|
||||
}
|
||||
}
|
||||
|
||||
var isInvestmentFlow: Bool {
|
||||
self == .buy || self == .sell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
extension Transaction {
|
||||
var decimalShares: Decimal {
|
||||
shares?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalPrice: Decimal {
|
||||
price?.decimalValue ?? Decimal.zero
|
||||
}
|
||||
|
||||
var decimalAmount: Decimal {
|
||||
if let amount = amount?.decimalValue, amount != 0 {
|
||||
return amount
|
||||
}
|
||||
return decimalShares * decimalPrice
|
||||
}
|
||||
|
||||
var transactionType: TransactionType {
|
||||
TransactionType(rawValue: type) ?? .buy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import CloudKit
|
||||
import Combine
|
||||
import WidgetKit
|
||||
|
||||
extension Notification.Name {
|
||||
static let cloudKitForceReload = Notification.Name("cloudKitForceReload")
|
||||
}
|
||||
|
||||
class CoreDataStack: ObservableObject {
|
||||
static let shared = CoreDataStack()
|
||||
|
||||
@@ -115,6 +119,44 @@ class CoreDataStack: ObservableObject {
|
||||
}
|
||||
|
||||
@Published private(set) var isLoaded = false
|
||||
@Published private(set) var lastImportDate: Date?
|
||||
@Published private(set) var lastExportDate: Date?
|
||||
@Published private(set) var isSyncing = false
|
||||
@Published private(set) var lastSyncError: String?
|
||||
/// Full recursive dump of the last sync error tree (leaf CKError codes, record
|
||||
/// types, retry-after, raw userInfo keys). Surfaced in the copyable diagnostics
|
||||
/// so we can see WHICH record CloudKit rejects, not just the opaque code 2.
|
||||
@Published private(set) var lastSyncErrorDetail: String?
|
||||
/// Human-readable, actionable explanation of the last sync error (nil if none).
|
||||
@Published private(set) var lastSyncErrorHint: String?
|
||||
/// True only when sync is genuinely broken (never synced + total failure).
|
||||
/// Partial failures after a working sync are informational, not alarming.
|
||||
@Published private(set) var syncErrorIsCritical = false
|
||||
|
||||
var lastSyncDate: Date? { lastImportDate }
|
||||
|
||||
var localSourceCount: Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
|
||||
return (try? viewContext.count(for: request)) ?? 0
|
||||
}
|
||||
|
||||
var localSnapshotCount: Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
|
||||
return (try? viewContext.count(for: request)) ?? 0
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Register CloudKit event observer BEFORE the container is created so we
|
||||
// never miss an import/export event that fires during store loading.
|
||||
if Self.cloudKitEnabled {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(cloudKitEventChanged(_:)),
|
||||
name: NSPersistentCloudKitContainer.eventChangedNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container: NSPersistentContainer
|
||||
@@ -131,12 +173,15 @@ class CoreDataStack: ObservableObject {
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
|
||||
// Always enable history tracking so data created before CloudKit was enabled
|
||||
// is visible to NSPersistentCloudKitContainer when sync is later turned on.
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
|
||||
if Self.cloudKitEnabled {
|
||||
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
||||
)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
}
|
||||
|
||||
container.persistentStoreDescriptions = [description]
|
||||
@@ -147,9 +192,43 @@ class CoreDataStack: ObservableObject {
|
||||
print("Core Data failed to load: \(error), \(error.userInfo)")
|
||||
} else {
|
||||
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
|
||||
#if DEBUG
|
||||
// Dev-only, opt-in: push the FULL model schema to the Development CloudKit
|
||||
// environment so optional fields that were never exported by a Debug run
|
||||
// (e.g. monthlyContribution on InvestmentSource, allocationTarget on Category)
|
||||
// get materialised — otherwise they can't be deployed to Production and every
|
||||
// Production export rejects those records (bare CKErrorPartialFailure).
|
||||
// Run ONCE from Xcode with launch arg -InitializeCloudKitSchema, watch for the
|
||||
// ✅ log, then deploy to Production in the CloudKit Console and remove the arg.
|
||||
if ProcessInfo.processInfo.arguments.contains("-InitializeCloudKitSchema"),
|
||||
let ckContainer = container as? NSPersistentCloudKitContainer {
|
||||
do {
|
||||
try ckContainer.initializeCloudKitSchema(options: [])
|
||||
print("✅ CloudKit schema initialized in Development. Now open the CloudKit Console → Deploy Schema Changes → Production.")
|
||||
} catch {
|
||||
print("❌ initializeCloudKitSchema failed: \(error)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoaded = true
|
||||
// Merge the pre-stable-UUID default duplicates. Safe now: the merge
|
||||
// reassigns children then deletes losers, and the relevant relationships
|
||||
// use the Nullify delete rule so a propagated parent-delete can never
|
||||
// cascade-wipe children (unlike the build-68 Cascade disaster).
|
||||
self?.mergeDefaultDuplicates()
|
||||
MonthlyCheckInStore.migrateIfNeeded()
|
||||
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
|
||||
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
|
||||
AllocationTargetStore.migrateIfNeeded(context: container.viewContext)
|
||||
// Seed demo data when running in screenshot capture mode.
|
||||
ScreenshotMode.seedIfNeeded()
|
||||
// Apply share-extension captures and publish the source mirror.
|
||||
Task { @MainActor in
|
||||
SharedQuickUpdateSync.ingestPending()
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,42 +256,44 @@ class CoreDataStack: ObservableObject {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Cleanup Duplicates
|
||||
|
||||
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
|
||||
/// This fixes data corruption from race conditions during object creation.
|
||||
func cleanupDuplicateObjects() {
|
||||
/// This fixes data corruption from race conditions during object creation, and also
|
||||
/// handles the case where a CloudKit first-time sync imports records that already exist
|
||||
/// locally (because they were created before CloudKit was enabled).
|
||||
@discardableResult
|
||||
func cleanupDuplicateObjects() -> Int {
|
||||
var totalRemoved = 0
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
// Clean up duplicate Goals
|
||||
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
|
||||
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||
// Clean up duplicate InvestmentSources
|
||||
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||
// Clean up duplicate Categories
|
||||
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||
// Clean up Snapshots first — before cascade rules from InvestmentSource fire,
|
||||
// so we deduplicate by UUID and not rely solely on cascade.
|
||||
totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
|
||||
totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
|
||||
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
return totalRemoved
|
||||
}
|
||||
|
||||
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) {
|
||||
@discardableResult
|
||||
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
|
||||
|
||||
guard let objects = try? context.fetch(request) else { return }
|
||||
guard let objects = try? context.fetch(request) else { return 0 }
|
||||
|
||||
var seenIds = Set<UUID>()
|
||||
var objectsToDelete: [NSManagedObject] = []
|
||||
|
||||
for object in objects {
|
||||
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
|
||||
|
||||
if seenIds.contains(objectId) {
|
||||
objectsToDelete.append(object)
|
||||
} else {
|
||||
@@ -221,11 +302,247 @@ class CoreDataStack: ObservableObject {
|
||||
}
|
||||
|
||||
if !objectsToDelete.isEmpty {
|
||||
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||
for object in objectsToDelete {
|
||||
context.delete(object)
|
||||
print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects")
|
||||
for object in objectsToDelete { context.delete(object) }
|
||||
}
|
||||
return objectsToDelete.count
|
||||
}
|
||||
|
||||
// MARK: - Logical Duplicate Cleanup (cross-device / cross-environment)
|
||||
|
||||
/// Removes *logical* duplicates: records that represent the same real-world
|
||||
/// entity but carry DIFFERENT UUIDs. These appear when the same data is
|
||||
/// created independently on two devices — or in the Development vs Production
|
||||
/// CloudKit environments — and then merged by CloudKit, which keys on the
|
||||
/// record name (our UUID) and therefore never recognises them as the same,
|
||||
/// keeping both. The classic trigger here: `createDefaultAccountIfNeeded` and
|
||||
/// `ensureDefaultCategoriesExist` seed defaults on every device at launch
|
||||
/// *before* the first import arrives, so each device contributes its own
|
||||
/// "Default" account + 8 categories → they pile up (8 accounts, 23 categories).
|
||||
///
|
||||
/// The UUID-based `cleanupDuplicateObjects()` cannot catch these because the
|
||||
/// UUIDs differ. Here we group by a stable *business key* and keep a single
|
||||
/// deterministic winner (earliest `createdAt`, tie-broken by lowest UUID —
|
||||
/// both synced fields, so every device independently picks the SAME survivor
|
||||
/// and the deletes converge instead of racing each other into data loss),
|
||||
/// reassigning children onto the survivor before deleting the losers.
|
||||
@discardableResult
|
||||
func cleanupLogicalDuplicates() -> Int {
|
||||
var removed = 0
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
// Parents first: re-point children onto the surviving parent so the
|
||||
// child keys (which embed the parent's id) stay stable for later passes.
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Account",
|
||||
childRelationships: ["sources", "goals"],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return name.isEmpty ? "" : "account|\(name)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Category",
|
||||
childRelationships: ["sources"],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return name.isEmpty ? "" : "category|\(name)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "InvestmentSource",
|
||||
childRelationships: ["snapshots", "transactions"],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !name.isEmpty else { return "" }
|
||||
let acc = (obj.value(forKey: "account") as? NSManagedObject)?
|
||||
.value(forKey: "id") as? UUID
|
||||
return "source|\(acc?.uuidString ?? "none")|\(name)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Snapshot",
|
||||
childRelationships: [],
|
||||
context: context,
|
||||
key: { obj in
|
||||
// Only dedup snapshots that share the same source, same day AND
|
||||
// same value — a true copy. Orphans (nil source) are left alone.
|
||||
guard let src = (obj.value(forKey: "source") as? NSManagedObject)?
|
||||
.value(forKey: "id") as? UUID,
|
||||
let date = obj.value(forKey: "date") as? Date else { return "" }
|
||||
let day = (date.timeIntervalSinceReferenceDate / 86_400).rounded(.down)
|
||||
let value = (obj.value(forKey: "value") as? NSDecimalNumber)?
|
||||
.stringValue ?? "nil"
|
||||
return "snapshot|\(src.uuidString)|\(day)|\(value)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "Goal",
|
||||
childRelationships: [],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let name = (obj.value(forKey: "name") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !name.isEmpty else { return "" }
|
||||
let target = (obj.value(forKey: "targetAmount") as? NSDecimalNumber)?
|
||||
.stringValue ?? "nil"
|
||||
return "goal|\(name)|\(target)"
|
||||
})
|
||||
|
||||
removed += mergeLogicalDuplicates(
|
||||
entityName: "JournalEntry",
|
||||
childRelationships: [],
|
||||
context: context,
|
||||
key: { obj in
|
||||
let monthKey = (obj.value(forKey: "monthKey") as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return monthKey.isEmpty ? "" : "journal|\(monthKey)"
|
||||
})
|
||||
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Groups objects of `entityName` by `key`, keeps one deterministic winner
|
||||
/// per group, moves every child in `childRelationships` from the losers onto
|
||||
/// the winner, then deletes the losers. An empty key means "never dedup this
|
||||
/// object" (degenerate identity — e.g. no name, orphan snapshot).
|
||||
@discardableResult
|
||||
private func mergeLogicalDuplicates(
|
||||
entityName: String,
|
||||
childRelationships: [String],
|
||||
context: NSManagedObjectContext,
|
||||
key: (NSManagedObject) -> String
|
||||
) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
guard let objects = try? context.fetch(request), !objects.isEmpty else { return 0 }
|
||||
|
||||
var groups: [String: [NSManagedObject]] = [:]
|
||||
for obj in objects {
|
||||
let k = key(obj)
|
||||
guard !k.isEmpty else { continue }
|
||||
groups[k, default: []].append(obj)
|
||||
}
|
||||
|
||||
var removed = 0
|
||||
for (_, group) in groups where group.count > 1 {
|
||||
// Deterministic ordering → same winner on every device.
|
||||
let sorted = group.sorted { a, b in
|
||||
let ca = (a.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
let cb = (b.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
if ca != cb { return ca < cb }
|
||||
let ia = (a.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
let ib = (b.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
return ia < ib
|
||||
}
|
||||
let winner = sorted[0]
|
||||
for loser in sorted.dropFirst() {
|
||||
for rel in childRelationships {
|
||||
let children = loser.mutableSetValue(forKey: rel)
|
||||
guard children.count > 0 else { continue }
|
||||
let winnerChildren = winner.mutableSetValue(forKey: rel)
|
||||
// Snapshot children first so the winner is a superset before delete.
|
||||
for child in children.allObjects {
|
||||
winnerChildren.add(child) // reassigns the modeled inverse
|
||||
}
|
||||
}
|
||||
context.delete(loser)
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
print("[LogicalDedup] \(entityName): removed \(removed) duplicate(s)")
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// MARK: - Merge Default Duplicates (CloudKit-safe)
|
||||
|
||||
/// Merges the duplicate default Accounts and Categories that piled up before
|
||||
/// the stable-UUID fix (per-device "Default" accounts + default categories with
|
||||
/// random UUIDs). CloudKit-safe now because Account.sources/goals and
|
||||
/// Category.sources use the Nullify delete rule — so a merged-away parent's
|
||||
/// delete can never cascade-wipe children on any device (the build-68 disaster
|
||||
/// was Cascade + cross-device import ordering). Children are reassigned to a
|
||||
/// DETERMINISTIC winner (the stable-UUID record if present, else earliest
|
||||
/// createdAt / lowest UUID) so every device converges on the same survivor.
|
||||
@discardableResult
|
||||
func mergeDefaultDuplicates() -> Int {
|
||||
var merged = 0
|
||||
let context = viewContext
|
||||
context.performAndWait {
|
||||
merged += mergeDefaultAccounts(in: context)
|
||||
merged += mergeDuplicateCategories(in: context)
|
||||
if context.hasChanges { try? context.save() }
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private func deterministicWinner(_ objects: [NSManagedObject], stableID: UUID?) -> NSManagedObject? {
|
||||
if let stableID, let canonical = objects.first(where: { ($0.value(forKey: "id") as? UUID) == stableID }) {
|
||||
return canonical
|
||||
}
|
||||
return objects.sorted {
|
||||
let a = ($0.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
let b = ($1.value(forKey: "createdAt") as? Date) ?? .distantPast
|
||||
if a != b { return a < b }
|
||||
let ida = ($0.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
let idb = ($1.value(forKey: "id") as? UUID)?.uuidString ?? ""
|
||||
return ida < idb
|
||||
}.first
|
||||
}
|
||||
|
||||
private func reassign(_ children: Any?, key: String, to winner: NSManagedObject) {
|
||||
guard let set = children as? Set<NSManagedObject> else { return }
|
||||
for child in Array(set) { child.setValue(winner, forKey: key) }
|
||||
}
|
||||
|
||||
private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "Account")
|
||||
request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName)
|
||||
guard let accounts = try? context.fetch(request), accounts.count > 1,
|
||||
let winner = deterministicWinner(accounts, stableID: Account.defaultAccountStableID) else { return 0 }
|
||||
var removed = 0
|
||||
for loser in accounts where loser !== winner {
|
||||
reassign(loser.value(forKey: "sources"), key: "account", to: winner)
|
||||
reassign(loser.value(forKey: "goals"), key: "account", to: winner)
|
||||
context.delete(loser)
|
||||
removed += 1
|
||||
}
|
||||
if removed > 0 { print("[MergeDefaults] accounts: removed \(removed)") }
|
||||
return removed
|
||||
}
|
||||
|
||||
private func mergeDuplicateCategories(in context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "Category")
|
||||
guard let categories = try? context.fetch(request), !categories.isEmpty else { return 0 }
|
||||
var byName: [String: [NSManagedObject]] = [:]
|
||||
for c in categories {
|
||||
let name = (c.value(forKey: "name") as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !name.isEmpty else { continue }
|
||||
byName[name, default: []].append(c)
|
||||
}
|
||||
var removed = 0
|
||||
for (name, group) in byName where group.count > 1 {
|
||||
let stable = Category.stableID(for: name)
|
||||
guard let winner = deterministicWinner(group, stableID: stable) else { continue }
|
||||
for loser in group where loser !== winner {
|
||||
reassign(loser.value(forKey: "sources"), key: "category", to: winner)
|
||||
context.delete(loser)
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
if removed > 0 { print("[MergeDefaults] categories: removed \(removed)") }
|
||||
return removed
|
||||
}
|
||||
|
||||
// MARK: - Save Context
|
||||
@@ -258,18 +575,293 @@ class CoreDataStack: ObservableObject {
|
||||
// MARK: - Remote Change Handling
|
||||
|
||||
@objc private func processRemoteChanges(_ notification: Notification) {
|
||||
// Process remote changes on main context
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
// Ensure changes are persisted to disk before refreshing widget
|
||||
self?.save()
|
||||
self?.refreshWidgetData()
|
||||
guard let self else { return }
|
||||
// Force viewContext to re-read all objects from the persistent store.
|
||||
self.viewContext.refreshAllObjects()
|
||||
// Remove any duplicates that CloudKit import may have introduced.
|
||||
// This handles the first-time sync case where records existed locally before
|
||||
// CloudKit was enabled: the initial export+import creates duplicate objects.
|
||||
let removed = self.cleanupDuplicateObjects()
|
||||
if removed > 0 {
|
||||
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
|
||||
}
|
||||
// Merge default duplicates after each import (CloudKit-safe via Nullify
|
||||
// rules + deterministic winner → devices converge, no cascade wipes).
|
||||
let mergedDefaults = self.mergeDefaultDuplicates()
|
||||
if mergedDefaults > 0 {
|
||||
print("[RemoteChanges] Merged \(mergedDefaults) default duplicates after CloudKit import")
|
||||
}
|
||||
// Notify repositories to re-fetch unconditionally.
|
||||
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
|
||||
self.objectWillChange.send()
|
||||
self.save()
|
||||
self.refreshWidgetData()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cloudKitEventChanged(_ notification: Notification) {
|
||||
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
|
||||
as? NSPersistentCloudKitContainer.Event else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let isActive = event.endDate == nil
|
||||
self.isSyncing = isActive
|
||||
if !isActive {
|
||||
if event.succeeded, let endDate = event.endDate {
|
||||
self.lastSyncError = nil
|
||||
switch event.type {
|
||||
case .import: self.lastImportDate = endDate
|
||||
case .export: self.lastExportDate = endDate
|
||||
default: break
|
||||
}
|
||||
} else if let error = event.error {
|
||||
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
|
||||
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
|
||||
self.lastSyncErrorDetail = "\(typeLabel)\n\(Self.deepErrorReport(error))"
|
||||
self.lastSyncErrorHint = Self.hint(for: error)
|
||||
// Severity: a partial failure AFTER sync has worked at least
|
||||
// once is background noise (a subset of records CloudKit
|
||||
// retries), NOT a broken-sync alarm. Only a total failure with
|
||||
// no data ever synced is critical.
|
||||
let hasSyncedBefore = self.lastImportDate != nil || self.lastExportDate != nil
|
||||
let isPartial = (error as NSError).code == 2 // CKErrorPartialFailure
|
||||
self.syncErrorIsCritical = !(hasSyncedBefore || isPartial)
|
||||
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func describeError(_ error: Error, depth: Int = 0) -> String {
|
||||
guard depth < 3 else { return "…" }
|
||||
let ns = error as NSError
|
||||
var parts: [String] = ["\(ns.domain)(\(ns.code))"]
|
||||
// At depth 0, always include the localised description so the user sees
|
||||
// a human-readable message even when userInfo has no other keys.
|
||||
if depth == 0, let msg = ns.userInfo[NSLocalizedDescriptionKey] as? String {
|
||||
parts.append(msg)
|
||||
}
|
||||
for (key, value) in ns.userInfo {
|
||||
let k = "\(key)"
|
||||
if k == NSLocalizedDescriptionKey || k == "NSLocalizedDescription" { continue }
|
||||
if let nestedError = value as? Error {
|
||||
parts.append("\(k):\(describeError(nestedError, depth: depth + 1))")
|
||||
} else if let dict = value as? [AnyHashable: Any], !dict.isEmpty {
|
||||
let pairs = dict.prefix(3).map { kk, vv -> String in
|
||||
if let e = vv as? Error { return "\(kk)→\(describeError(e, depth: depth + 1))" }
|
||||
return "\(kk)=\(vv)"
|
||||
}
|
||||
parts.append("\(k){\(pairs.joined(separator: ", "))}")
|
||||
} else if !(value is [AnyHashable: Any]) {
|
||||
parts.append("\(k)=\(value)")
|
||||
}
|
||||
}
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Turns an opaque CloudKit error into a human-readable, actionable hint by
|
||||
/// digging into CKPartialErrorsByItemIDKey — the per-record failures hidden
|
||||
/// inside a CKErrorPartialFailure (code 2). The dominant underlying code
|
||||
/// tells us the real problem: schema not deployed (unknownItem/invalidArguments)
|
||||
/// vs. quota vs. transient network.
|
||||
static func hint(for error: Error) -> String? {
|
||||
let ns = error as NSError
|
||||
// Collect all leaf CKError codes across a possible partial-failure tree.
|
||||
var codes: [Int] = []
|
||||
func collect(_ e: NSError) {
|
||||
if e.domain == "CKErrorDomain", e.code != 2 { codes.append(e.code) }
|
||||
if let partial = e.userInfo["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
|
||||
for case let sub as NSError in partial.values { collect(sub) }
|
||||
}
|
||||
for value in e.userInfo.values {
|
||||
if let sub = value as? NSError, sub !== e { collect(sub) }
|
||||
}
|
||||
}
|
||||
collect(ns)
|
||||
|
||||
// CKError codes: 14 = unknownItem, 12 = invalidArguments, 6 = partialFailure,
|
||||
// 25 = quotaExceeded, 4 = networkFailure/unavailable, 3 = networkUnavailable,
|
||||
// 15 = serverRejectedRequest, 26 = operationCancelled.
|
||||
if codes.contains(14) || codes.contains(12) || codes.contains(15) {
|
||||
return String(localized: "icloud_hint_schema")
|
||||
}
|
||||
if codes.contains(25) {
|
||||
return String(localized: "icloud_hint_quota")
|
||||
}
|
||||
if codes.contains(3) || codes.contains(4) {
|
||||
return String(localized: "icloud_hint_network")
|
||||
}
|
||||
if ns.code == 2 {
|
||||
return String(localized: "icloud_hint_partial_generic")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Full recursive walk of a CloudKit/Core Data error tree. Unlike `describeError`
|
||||
/// (a compact one-liner), this follows NSUnderlyingError chains and
|
||||
/// CKPartialErrorsByItemIDKey, and pulls the failing record TYPE out of any
|
||||
/// embedded CKRecord — which is what tells us WHICH entity CloudKit rejects.
|
||||
static func deepErrorReport(_ error: Error) -> String {
|
||||
var lines: [String] = []
|
||||
var leafCodes = Set<Int>()
|
||||
var recordTypes = Set<String>()
|
||||
var visited = 0
|
||||
|
||||
func walk(_ err: Error, indent: Int) {
|
||||
guard visited < 300 else { return }
|
||||
visited += 1
|
||||
let ns = err as NSError
|
||||
let pad = String(repeating: " ", count: indent)
|
||||
lines.append("\(pad)\(ns.domain)(\(ns.code))")
|
||||
if ns.domain == "CKErrorDomain", ns.code != 2 { leafCodes.insert(ns.code) }
|
||||
|
||||
let info = ns.userInfo
|
||||
if let reason = info[NSLocalizedFailureReasonErrorKey] as? String {
|
||||
lines.append("\(pad) reason: \(reason)")
|
||||
}
|
||||
if let desc = info[NSLocalizedDescriptionKey] as? String {
|
||||
lines.append("\(pad) desc: \(desc)")
|
||||
}
|
||||
if let retry = info["CKErrorRetryAfterKey"] {
|
||||
lines.append("\(pad) retryAfter: \(retry)")
|
||||
}
|
||||
for key in ["CKRecordChangedErrorServerRecordKey",
|
||||
"CKRecordChangedErrorClientRecordKey",
|
||||
"CKRecordChangedErrorAncestorRecordKey"] {
|
||||
if let rec = info[key] as? CKRecord {
|
||||
recordTypes.insert(rec.recordType)
|
||||
lines.append("\(pad) record: \(rec.recordType) [\(rec.recordID.recordName)]")
|
||||
}
|
||||
}
|
||||
if let partial = info["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
|
||||
lines.append("\(pad) partialErrors: \(partial.count)")
|
||||
for (rid, value) in partial.prefix(25) {
|
||||
if let name = (rid as? CKRecord.ID)?.recordName {
|
||||
lines.append("\(pad) item: \(name)")
|
||||
}
|
||||
if let sub = value as? Error { walk(sub, indent: indent + 3) }
|
||||
}
|
||||
}
|
||||
if let underlying = info[NSUnderlyingErrorKey] as? Error {
|
||||
lines.append("\(pad) underlying:")
|
||||
walk(underlying, indent: indent + 2)
|
||||
}
|
||||
for (k, v) in info {
|
||||
let ks = "\(k)"
|
||||
if ks == NSUnderlyingErrorKey || ks == "CKPartialErrorsByItemIDKey" { continue }
|
||||
if let sub = v as? Error, (sub as NSError) !== ns {
|
||||
lines.append("\(pad) [\(ks)]:")
|
||||
walk(sub, indent: indent + 2)
|
||||
}
|
||||
}
|
||||
let keys = info.keys.map { "\($0)" }.sorted()
|
||||
if !keys.isEmpty { lines.append("\(pad) keys: \(keys.joined(separator: ", "))") }
|
||||
}
|
||||
|
||||
walk(error, indent: 0)
|
||||
|
||||
var summary = ""
|
||||
if !leafCodes.isEmpty {
|
||||
summary += "leafCKCodes: [\(leafCodes.sorted().map(String.init).joined(separator: ", "))]\n"
|
||||
}
|
||||
if !recordTypes.isEmpty {
|
||||
summary += "recordTypes: [\(recordTypes.sorted().joined(separator: ", "))]\n"
|
||||
}
|
||||
return summary + lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Local Core Data integrity probe: per-entity counts + records with nil id /
|
||||
/// nil createdAt / orphan relationships that can silently block a CloudKit export.
|
||||
func integrityReport() -> String {
|
||||
let ctx = viewContext
|
||||
var lines: [String] = []
|
||||
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal", "JournalEntry"]
|
||||
for name in entities {
|
||||
let count = (try? ctx.count(for: NSFetchRequest<NSManagedObject>(entityName: name))) ?? -1
|
||||
lines.append("\(name): \(count)")
|
||||
}
|
||||
func flag(_ label: String, _ n: Int) { if n > 0 { lines.append("⚠︎ \(label): \(n)") } }
|
||||
func fetch(_ name: String) -> [NSManagedObject] {
|
||||
(try? ctx.fetch(NSFetchRequest<NSManagedObject>(entityName: name))) ?? []
|
||||
}
|
||||
let sources = fetch("InvestmentSource")
|
||||
flag("sources nil id", sources.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("sources nil createdAt", sources.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
let snaps = fetch("Snapshot")
|
||||
flag("snapshots nil id", snaps.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("snapshots nil createdAt", snaps.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
flag("orphan snapshots (nil source)", snaps.filter { $0.value(forKey: "source") == nil }.count)
|
||||
let goals = fetch("Goal")
|
||||
flag("goals nil id", goals.filter { $0.value(forKey: "id") == nil }.count)
|
||||
flag("goals nil createdAt", goals.filter { $0.value(forKey: "createdAt") == nil }.count)
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func forceReload() {
|
||||
viewContext.perform { [weak self] in
|
||||
self?.viewContext.refreshAllObjects()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces NSPersistentCloudKitContainer to export all local data to iCloud.
|
||||
///
|
||||
/// Strategy: advance `createdAt` by 1 ms for every record. This guarantees
|
||||
/// a *real* value change that the SQLite persistent store will write as a
|
||||
/// persistent-history transaction — which is what NSPersistentCloudKitContainer
|
||||
/// needs to discover records and enqueue them for CloudKit export.
|
||||
///
|
||||
/// Setting a property to the *same* value may be silently discarded by the
|
||||
/// SQLite layer (no SQL UPDATE issued → no history entry → nothing to export).
|
||||
func forceExportToiCloud(completion: @escaping (Int) -> Void) {
|
||||
guard Self.cloudKitEnabled else { completion(0); return }
|
||||
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
|
||||
let context = newBackgroundContext()
|
||||
context.perform { [weak self] in
|
||||
var totalTouched = 0
|
||||
for entityName in entities {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
request.fetchBatchSize = 50
|
||||
guard let objects = try? context.fetch(request) else { continue }
|
||||
for object in objects {
|
||||
// Advance createdAt by 1 ms → always a genuine value change.
|
||||
let t = (object.value(forKey: "createdAt") as? Date) ?? Date()
|
||||
object.setValue(t.addingTimeInterval(0.001), forKey: "createdAt")
|
||||
totalTouched += 1
|
||||
}
|
||||
}
|
||||
if context.hasChanges {
|
||||
try? context.save()
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(totalTouched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces an immediate re-read of all Core Data objects from the persistent store.
|
||||
/// Call this when the app returns to the foreground so any iCloud changes made on
|
||||
/// other devices (while this device was inactive) are picked up right away.
|
||||
func refreshFromCloudKit() {
|
||||
guard Self.cloudKitEnabled else { return }
|
||||
viewContext.perform { [weak self] in
|
||||
self?.viewContext.refreshAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget Data Refresh
|
||||
|
||||
/// When true, `refreshWidgetData()` becomes a no-op. Set during batch operations
|
||||
/// (e.g. CSV import of hundreds of snapshots) so we don't run a synchronous WAL
|
||||
/// checkpoint + widget reload per row — that hammered the main thread into an ANR.
|
||||
/// Call `refreshWidgetData()` once when the batch finishes.
|
||||
var suppressWidgetRefresh = false
|
||||
|
||||
func refreshWidgetData() {
|
||||
if suppressWidgetRefresh { return }
|
||||
if #available(iOS 14.0, *) {
|
||||
if Self.appGroupEnabled {
|
||||
checkpointWAL()
|
||||
@@ -288,6 +880,7 @@ class CoreDataStack: ObservableObject {
|
||||
viewContext.refreshAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Shared Container for Widgets
|
||||
|
||||
@@ -15,6 +15,12 @@ struct MonthlySummary {
|
||||
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
|
||||
}
|
||||
|
||||
var formattedMonthYear: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
return formatter.string(from: startDate)
|
||||
}
|
||||
|
||||
var formattedStartingValue: String {
|
||||
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Tone / severity of an insight, used to pick an accent color and to
|
||||
/// prioritize which insights are surfaced first.
|
||||
enum InsightTone {
|
||||
case positive
|
||||
case neutral
|
||||
case attention
|
||||
|
||||
/// Higher priority insights are shown first when capping the list.
|
||||
var priority: Int {
|
||||
switch self {
|
||||
case .attention: return 2
|
||||
case .positive: return 1
|
||||
case .neutral: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioInsight: Identifiable {
|
||||
let id: String
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let value: String
|
||||
let accentColor: Color
|
||||
/// Optional supporting line shown under the value in the richer card.
|
||||
let detail: String?
|
||||
let tone: InsightTone
|
||||
|
||||
init(
|
||||
id: String,
|
||||
systemImage: String,
|
||||
title: String,
|
||||
value: String,
|
||||
accentColor: Color,
|
||||
detail: String? = nil,
|
||||
tone: InsightTone = .neutral
|
||||
) {
|
||||
self.id = id
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.accentColor = accentColor
|
||||
self.detail = detail
|
||||
self.tone = tone
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class AccountRepository: ObservableObject {
|
||||
self?.fetchAccounts()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.fetchAccounts() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
@@ -91,9 +96,12 @@ class AccountRepository: ObservableObject {
|
||||
return existing
|
||||
}
|
||||
|
||||
// No accounts exist, create Default account
|
||||
// No accounts exist, create Default account.
|
||||
// Use a DETERMINISTIC id so every device creates the same CloudKit record
|
||||
// instead of a per-device duplicate (root-cause fix for the dedup pile-up).
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = Account(context: context)
|
||||
account.id = Account.defaultAccountStableID
|
||||
account.name = Account.defaultAccountName
|
||||
account.currency = defaultCurrency
|
||||
account.inputMode = InputMode.simple.rawValue
|
||||
|
||||
@@ -25,6 +25,11 @@ class CategoryRepository: ObservableObject {
|
||||
self.fetchCategories()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.fetchCategories() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
@@ -104,6 +109,8 @@ class CategoryRepository: ObservableObject {
|
||||
}
|
||||
|
||||
let category = Category(context: context)
|
||||
// Deterministic id → same CloudKit record on every device (no duplicates).
|
||||
category.id = Category.stableID(for: categoryData.name)
|
||||
category.name = categoryData.name
|
||||
category.colorHex = categoryData.colorHex
|
||||
category.icon = categoryData.icon
|
||||
|
||||
@@ -23,6 +23,11 @@ class GoalRepository: ObservableObject {
|
||||
self?.fetchGoals()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.fetchGoals() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
@@ -24,6 +24,11 @@ class InvestmentSourceRepository: ObservableObject {
|
||||
self.fetchSources()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.fetchSources() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
@@ -162,7 +167,12 @@ class InvestmentSourceRepository: ObservableObject {
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteSource(_ source: InvestmentSource) {
|
||||
context.delete(source)
|
||||
if source.managedObjectContext == context {
|
||||
context.delete(source)
|
||||
} else {
|
||||
let objectInContext = context.object(with: source.objectID)
|
||||
context.delete(objectInContext)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ class SnapshotRepository: ObservableObject {
|
||||
date: Date = Date(),
|
||||
value: Decimal,
|
||||
contribution: Decimal? = nil,
|
||||
notes: String? = nil
|
||||
notes: String? = nil,
|
||||
batch: Bool = false
|
||||
) -> Snapshot {
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.source = source
|
||||
@@ -113,10 +114,65 @@ class SnapshotRepository: ObservableObject {
|
||||
}
|
||||
snapshot.notes = notes
|
||||
|
||||
save()
|
||||
// In batch mode (CSV import), skip the per-row save + widget refresh — the
|
||||
// caller saves the whole context once at the end. Saving per row triggered a
|
||||
// main-thread WAL checkpoint each time → ANR on large imports.
|
||||
if !batch {
|
||||
save()
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Gap Auto-Fill (estimated snapshots)
|
||||
|
||||
/// Creates ESTIMATED snapshots for the missing months of an internal gap,
|
||||
/// linearly interpolated between the source's value at `from` and at `to`.
|
||||
/// They are marked `isEstimated` so they read as clearly distinct from real
|
||||
/// data and can be removed in bulk. Idempotent (skips months that already have
|
||||
/// a snapshot). Returns how many were created.
|
||||
@discardableResult
|
||||
func autoFillGap(source: InvestmentSource, from: Date, to: Date, missingMonthDates: [Date]) -> Int {
|
||||
let snaps = source.snapshotsArray
|
||||
func value(atMonth month: Date) -> Decimal? {
|
||||
snaps.filter { $0.date.startOfMonth == month.startOfMonth }
|
||||
.max(by: { $0.date < $1.date })?.decimalValue
|
||||
}
|
||||
guard let v0 = value(atMonth: from),
|
||||
let v1 = value(atMonth: to),
|
||||
!missingMonthDates.isEmpty else { return 0 }
|
||||
|
||||
let steps = missingMonthDates.count + 1 // segments between v0 and v1
|
||||
var created = 0
|
||||
for (i, month) in missingMonthDates.enumerated() {
|
||||
let monthStart = month.startOfMonth
|
||||
if snaps.contains(where: { $0.date.startOfMonth == monthStart }) { continue }
|
||||
let fraction = Decimal(i + 1) / Decimal(steps)
|
||||
let interpolated = v0 + (v1 - v0) * fraction
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.source = source
|
||||
snapshot.date = monthStart
|
||||
snapshot.value = NSDecimalNumber(decimal: interpolated)
|
||||
snapshot.isEstimated = true
|
||||
snapshot.notes = String(localized: "gaps_estimated_note")
|
||||
created += 1
|
||||
}
|
||||
if created > 0 { save() }
|
||||
return created
|
||||
}
|
||||
|
||||
/// Deletes all estimated (auto-filled) snapshots, optionally scoped to one
|
||||
/// source. Returns how many were removed.
|
||||
@discardableResult
|
||||
func deleteEstimatedSnapshots(for source: InvestmentSource? = nil) -> Int {
|
||||
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
request.predicate = NSPredicate(format: "isEstimated == YES")
|
||||
guard let estimated = try? context.fetch(request) else { return 0 }
|
||||
let targets = source == nil ? estimated : estimated.filter { $0.source?.id == source?.id }
|
||||
for snapshot in targets { context.delete(snapshot) }
|
||||
if !targets.isEmpty { save() }
|
||||
return targets.count
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateSnapshot(
|
||||
@@ -148,6 +204,40 @@ class SnapshotRepository: ObservableObject {
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Contribution Propagation
|
||||
|
||||
enum ContributionPropagation {
|
||||
case forward // snapshots after the pivot
|
||||
case backward // snapshots before the pivot
|
||||
case all // every other snapshot of the source
|
||||
}
|
||||
|
||||
/// Applies `amount` as the contribution of other snapshots of the same source,
|
||||
/// relative to `snapshot`'s date, according to `direction`. The pivot snapshot
|
||||
/// itself is left untouched (it was already saved with its own value).
|
||||
func propagateContribution(
|
||||
_ amount: Decimal,
|
||||
from snapshot: Snapshot,
|
||||
direction: ContributionPropagation
|
||||
) {
|
||||
guard let source = snapshot.source,
|
||||
let set = source.snapshots as? Set<Snapshot> else { return }
|
||||
let pivot = snapshot.date
|
||||
let targets = set.filter { other in
|
||||
guard other.objectID != snapshot.objectID else { return false }
|
||||
switch direction {
|
||||
case .forward: return other.date > pivot
|
||||
case .backward: return other.date < pivot
|
||||
case .all: return true
|
||||
}
|
||||
}
|
||||
let value = NSDecimalNumber(decimal: amount)
|
||||
for target in targets {
|
||||
target.contribution = value
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteSnapshot(_ snapshot: Snapshot) {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class TransactionRepository {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
|
||||
let request: NSFetchRequest<Transaction> = Transaction.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source == %@", source)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
|
||||
]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createTransaction(
|
||||
source: InvestmentSource,
|
||||
type: TransactionType,
|
||||
date: Date,
|
||||
shares: Decimal?,
|
||||
price: Decimal?,
|
||||
amount: Decimal?,
|
||||
notes: String?
|
||||
) -> Transaction {
|
||||
let transaction = Transaction(context: context)
|
||||
transaction.source = source
|
||||
transaction.type = type.rawValue
|
||||
transaction.date = date
|
||||
if let shares = shares {
|
||||
transaction.shares = NSDecimalNumber(decimal: shares)
|
||||
}
|
||||
if let price = price {
|
||||
transaction.price = NSDecimalNumber(decimal: price)
|
||||
}
|
||||
if let amount = amount {
|
||||
transaction.amount = NSDecimalNumber(decimal: amount)
|
||||
}
|
||||
transaction.notes = notes
|
||||
|
||||
save()
|
||||
return transaction
|
||||
}
|
||||
|
||||
func deleteTransaction(_ transaction: Transaction) {
|
||||
context.delete(transaction)
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save transaction: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"Add a snapshot in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Füge einen Eintrag in ${applicationName} hinzu"
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Añade un registro en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ajoute un relevé dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aggiungi una rilevazione in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}でスナップショットを追加"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Adicione um registro no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Check my portfolio in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Zeige mein Portfolio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"Check my portfolio in ${applicationName}",
|
||||
"Show my net worth in ${applicationName}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Consulta mi cartera en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Consulte mon portefeuille dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Controlla il mio portafoglio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "${applicationName}でポートフォリオを確認"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Verifique minha carteira no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Log a value in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Erfasse einen Wert in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"Log a value in ${applicationName}",
|
||||
"Log my ${source} balance in ${applicationName}",
|
||||
"Update ${source} in ${applicationName}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Registra un valor en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Enregistre une valeur dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Registra un valore in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "${applicationName}で金額を記録"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Registre um valor no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Log my ${source} balance in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Erfasse den Saldo von ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Registra el saldo de ${source} en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Enregistre le solde de ${source} dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Registra il saldo di ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}で${source}の残高を記録"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Registre o saldo de ${source} no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show my net worth in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zeige mein Vermögen in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Muestra mi patrimonio en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Affiche mon patrimoine dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mostra il mio patrimonio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}で純資産を表示"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mostre meu patrimônio no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Update ${source} in ${applicationName}" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aktualisiere ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Actualiza ${source} en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mets à jour ${source} dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Aggiorna ${source} in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "${applicationName}で${source}を更新"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Atualize ${source} no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"What's my net worth in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"What's my net worth in ${applicationName}",
|
||||
"How much is my portfolio worth in ${applicationName}",
|
||||
"Get my net worth from ${applicationName}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"What's my streak in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"What's my streak in ${applicationName}",
|
||||
"Check my check-in streak in ${applicationName}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Update my portfolio in ${applicationName}" : {
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Aktualisiere mein Portfolio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringSet" : {
|
||||
"state" : "new",
|
||||
"values" : [
|
||||
"Update my portfolio in ${applicationName}",
|
||||
"Add a snapshot in ${applicationName}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"es-ES" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Actualiza mi cartera en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Mets à jour mon portefeuille dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Aggiorna il mio portafoglio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "${applicationName}でポートフォリオを更新"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "Atualize minha carteira no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -30,11 +30,32 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-1549720748100858~9632507420</string>
|
||||
<key>GADDelayAppMeasurementInit</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||
<false/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>Used to set investment update reminders.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Use Face ID to unlock your portfolio data.</string>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||
<key>SKAdNetworkItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -298,25 +319,6 @@
|
||||
<string>275upjj5gd.skadnetwork</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||
<false/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>Used to set investment update reminders.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Use Face ID to unlock your portfolio data.</string>
|
||||
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||
<true/>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
"app_name" = "Portfolio Journal";
|
||||
"ok" = "OK";
|
||||
"cancel" = "Abbrechen";
|
||||
"save" = "Speichern";
|
||||
"delete" = "Löschen";
|
||||
"edit" = "Bearbeiten";
|
||||
"add" = "Hinzufügen";
|
||||
"done" = "Fertig";
|
||||
"close" = "Schließen";
|
||||
"continue" = "Weiter";
|
||||
"skip" = "Überspringen";
|
||||
"error" = "Fehler";
|
||||
"success" = "Erfolg";
|
||||
"loading" = "Lädt...";
|
||||
"loading_data" = "Deine Daten werden geladen...";
|
||||
"tab_dashboard" = "Start";
|
||||
"tab_sources" = "Quellen";
|
||||
"tab_charts" = "Charts";
|
||||
"tab_settings" = "Einstellungen";
|
||||
"dashboard_title" = "Start";
|
||||
"total_portfolio_value" = "Gesamtwert des Portfolios";
|
||||
"today" = "heute";
|
||||
"returns" = "Rendite";
|
||||
"by_category" = "Nach Kategorie";
|
||||
"pending_updates" = "Ausstehende Updates";
|
||||
"see_all" = "Alle anzeigen";
|
||||
"sources_title" = "Quellen";
|
||||
"add_source" = "Quelle hinzufügen";
|
||||
"source_name" = "Quellenname";
|
||||
"select_category" = "Kategorie auswählen";
|
||||
"initial_value" = "Anfangswert";
|
||||
"initial_value_optional" = "Anfangswert (optional)";
|
||||
"reminder_frequency" = "Erinnerungsintervall";
|
||||
"source_limit_warning" = "Quellenlimit erreicht. Upgrade auf Premium für unbegrenzte Quellen.";
|
||||
"no_sources" = "Keine Investmentquellen";
|
||||
"no_sources_message" = "Füge deine erste Investmentquelle hinzu, um dein Portfolio zu verfolgen.";
|
||||
"add_snapshot" = "Snapshot hinzufügen";
|
||||
"edit_snapshot" = "Snapshot bearbeiten";
|
||||
"snapshot_date" = "Datum";
|
||||
"snapshot_value" = "Wert";
|
||||
"snapshot_contribution" = "Beitrag";
|
||||
"contribution_optional" = "Beitrag (optional)";
|
||||
"notes" = "Notizen";
|
||||
"notes_optional" = "Notizen (optional)";
|
||||
"previous_value" = "Vorheriger Wert: %@";
|
||||
"change_from_previous" = "Änderung zum vorherigen Wert";
|
||||
"charts_title" = "Charts";
|
||||
"evolution" = "Entwicklung";
|
||||
"allocation" = "Allokation";
|
||||
"performance" = "Performance";
|
||||
"drawdown" = "Drawdown";
|
||||
"volatility" = "Volatilität";
|
||||
"prediction" = "Prognose";
|
||||
"portfolio_evolution" = "Portfolioentwicklung";
|
||||
"asset_allocation" = "Asset-Allokation";
|
||||
"performance_by_category" = "Performance nach Kategorie";
|
||||
"drawdown_analysis" = "Drawdown-Analyse";
|
||||
"prediction_12_month" = "12-Monats-Prognose";
|
||||
"not_enough_data" = "Nicht genügend Daten";
|
||||
"cagr" = "CAGR";
|
||||
"twr" = "TWR";
|
||||
"max_drawdown" = "Max. Drawdown";
|
||||
"sharpe_ratio" = "Sharpe Ratio";
|
||||
"win_rate" = "Trefferquote";
|
||||
"avg_monthly" = "Monatsdurchschnitt";
|
||||
"best_month" = "Bester Monat";
|
||||
"worst_month" = "Schlechtester Monat";
|
||||
"premium" = "Premium";
|
||||
"upgrade_to_premium" = "Auf Premium upgraden";
|
||||
"unlock_full_potential" = "Volles Potenzial freischalten";
|
||||
"one_time_purchase" = "Einmalkauf";
|
||||
"includes_family_sharing" = "Inklusive Familienfreigabe";
|
||||
"upgrade_now" = "Jetzt upgraden";
|
||||
"restore_purchases" = "Käufe wiederherstellen";
|
||||
"premium_active" = "Premium aktiv";
|
||||
"premium_feature" = "Premium-Funktion";
|
||||
"unlock" = "Freischalten";
|
||||
"feature_unlimited_sources" = "Unbegrenzte Quellen";
|
||||
"feature_unlimited_sources_desc" = "Verfolge so viele Investments, wie du möchtest";
|
||||
"feature_full_history" = "Vollständige Historie";
|
||||
"feature_full_history_desc" = "Greife auf deine komplette Investmenthistorie zu";
|
||||
"feature_advanced_charts" = "Erweiterte Charts";
|
||||
"feature_advanced_charts_desc" = "5 Arten detaillierter Analysecharts";
|
||||
"feature_predictions" = "Prognosen";
|
||||
"feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen";
|
||||
"feature_export" = "Daten exportieren";
|
||||
"feature_export_desc" = "Export nach CSV und JSON";
|
||||
"feature_no_ads" = "Keine Werbung";
|
||||
"feature_no_ads_desc" = "Dauerhaft werbefrei";
|
||||
"paywall_benefit_history_title" = "Deine vollständige Historie";
|
||||
"paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins";
|
||||
"paywall_benefit_charts_title" = "Charts, die Muster zeigen";
|
||||
"paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort";
|
||||
"paywall_benefit_forecasts_title" = "12-Monats-Prognosen";
|
||||
"paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt";
|
||||
"paywall_benefit_noads_title" = "Keine Werbung, jemals";
|
||||
"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung";
|
||||
"settings_title" = "Einstellungen";
|
||||
"subscription" = "Abo";
|
||||
"notifications" = "Benachrichtigungen";
|
||||
"default_reminder_time" = "Standard-Erinnerungszeit";
|
||||
"data" = "Daten";
|
||||
"export_data" = "Daten exportieren";
|
||||
"total_sources" = "Anzahl Quellen";
|
||||
"total_snapshots" = "Anzahl Snapshots";
|
||||
"storage_used" = "Verwendeter Speicher";
|
||||
"about" = "Info";
|
||||
"version" = "Version";
|
||||
"privacy_policy" = "Datenschutzerklärung";
|
||||
"terms_of_service" = "Nutzungsbedingungen";
|
||||
"support" = "Support";
|
||||
"rate_app" = "App bewerten";
|
||||
"danger_zone" = "Gefahrenzone";
|
||||
"reset_all_data" = "Alle Daten zurücksetzen";
|
||||
"reset_confirmation" = "Dadurch werden alle Investmentdaten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.";
|
||||
"frequency_monthly" = "Monatlich";
|
||||
"frequency_quarterly" = "Vierteljährlich";
|
||||
"frequency_semiannual" = "Halbjährlich";
|
||||
"frequency_annual" = "Jährlich";
|
||||
"frequency_custom" = "Benutzerdefiniert";
|
||||
"frequency_never" = "Nie";
|
||||
"every_n_months" = "Alle %d Monate";
|
||||
"category_stocks" = "Aktien";
|
||||
"category_bonds" = "Anleihen";
|
||||
"category_real_estate" = "Immobilien";
|
||||
"category_crypto" = "Krypto";
|
||||
"category_cash" = "Cash";
|
||||
"category_etfs" = "ETFs";
|
||||
"category_retirement" = "Altersvorsorge";
|
||||
"category_other" = "Sonstiges";
|
||||
"uncategorized" = "Nicht kategorisiert";
|
||||
"time_1m" = "1M";
|
||||
"time_3m" = "3M";
|
||||
"time_6m" = "6M";
|
||||
"time_1y" = "1J";
|
||||
"time_all" = "Alle";
|
||||
"export_format" = "Format auswählen";
|
||||
"export_csv" = "CSV";
|
||||
"export_csv_desc" = "Kompatibel mit Excel und Google Sheets";
|
||||
"export_json" = "JSON";
|
||||
"export_json_desc" = "Vollständige Datenstruktur für Backups";
|
||||
"onboarding_track_title" = "Verfolge deine Investments";
|
||||
"onboarding_track_desc" = "Behalte alle deine Investmentquellen an einem Ort im Blick. Aktien, Anleihen, Immobilien, Krypto und mehr.";
|
||||
"onboarding_visualize_title" = "Visualisiere dein Wachstum";
|
||||
"onboarding_visualize_desc" = "Schöne Charts zeigen die Entwicklung, Allokation und Performance deines Portfolios im Zeitverlauf.";
|
||||
"onboarding_reminders_title" = "Verpasse nie ein Update";
|
||||
"onboarding_reminders_desc" = "Setze Erinnerungen, um deine Investments regelmäßig zu verfolgen. Monatlich, vierteljährlich oder individuell.";
|
||||
"onboarding_sync_title" = "Überall synchron";
|
||||
"onboarding_sync_desc" = "Deine Daten synchronisieren automatisch via iCloud auf all deinen Apple-Geräten.";
|
||||
"get_started" = "Loslegen";
|
||||
"onboarding_clarity_title" = "Wisse genau, wo du stehst";
|
||||
"onboarding_clarity_desc" = "Sieh dein Gesamtvermögen, echte Renditen und Allokation, immer aktuell.";
|
||||
"onboarding_habit_title" = "5 Minuten pro Monat reichen";
|
||||
"onboarding_habit_desc" = "Erfasse deine Werte einmal im Monat. Portfolio Journal übernimmt die Berechnungen und zeigt deinen Fortschritt.";
|
||||
"onboarding_calm_title" = "Ignoriere den Lärm. Verfolge den Trend.";
|
||||
"onboarding_calm_desc" = "Tägliche Schwankungen erzählen nicht die echte Geschichte. Dein Wachstum über Monate und Jahre schon.";
|
||||
"onboarding_goals_title" = "Erreiche deine finanziellen Ziele";
|
||||
"onboarding_goals_desc" = "Setze Ziele, verfolge Meilensteine und sieh genau, wie weit du gekommen bist.";
|
||||
"error_generic" = "Ein Fehler ist aufgetreten. Bitte versuche es erneut.";
|
||||
"error_no_purchases" = "Keine Käufe zum Wiederherstellen gefunden";
|
||||
"error_purchase_failed" = "Kauf fehlgeschlagen: %@";
|
||||
"error_export_failed" = "Export fehlgeschlagen. Bitte versuche es erneut.";
|
||||
"placeholder_source_name" = "z. B. Vanguard 401k";
|
||||
"placeholder_value" = "0.00";
|
||||
"placeholder_notes" = "Notizen hinzufügen...";
|
||||
"mood_energized_title" = "Voller Energie";
|
||||
"mood_confident_title" = "Selbstsicher";
|
||||
"mood_balanced_title" = "Ausgeglichen";
|
||||
"mood_cautious_title" = "Vorsichtig";
|
||||
"mood_stressed_title" = "Gestresst";
|
||||
"mood_energized_detail" = "Fühle mich unschlagbar";
|
||||
"mood_confident_detail" = "Auf Kurs und gelassen";
|
||||
"mood_balanced_detail" = "Ruhig und geduldig";
|
||||
"mood_cautious_detail" = "Beobachte die Bewegungen";
|
||||
"mood_stressed_detail" = "Brauche einen Reset";
|
||||
"achievement_streak_3_title" = "3-Monats-Serie";
|
||||
"achievement_streak_3_detail" = "Du hast drei Monate in Folge pünktlich eingecheckt.";
|
||||
"achievement_streak_6_title" = "Halbjahres-Serie";
|
||||
"achievement_streak_6_detail" = "Sechs pünktliche Check-ins in Folge.";
|
||||
"achievement_streak_12_title" = "Ein Jahr Momentum";
|
||||
"achievement_streak_12_detail" = "Ein ganzes Jahr ohne Fristversäumnis.";
|
||||
"achievement_perfect_on_time_title" = "Nie zu spät";
|
||||
"achievement_perfect_on_time_detail" = "Jeder Check-in kam vor der Frist an.";
|
||||
"achievement_clutch_finish_title" = "Last-Minute-Erfolg";
|
||||
"achievement_clutch_finish_detail" = "Mit wenigen Stunden Puffer, aber noch rechtzeitig abgegeben.";
|
||||
"achievement_early_bird_title" = "Frühstarter";
|
||||
"achievement_early_bird_detail" = "Im Schnitt beendest du deine Check-ins mit viel Zeit übrig.";
|
||||
"achievements_title" = "Erfolge";
|
||||
"achievements_view_all" = "Alle Erfolge anzeigen";
|
||||
"achievements_nav_title" = "Erfolge";
|
||||
"achievements_progress_title" = "Fortschritt";
|
||||
"achievements_unlocked_title" = "Freigeschaltet";
|
||||
"achievements_unlocked_empty" = "Schließe Check-ins ab, um Erfolge freizuschalten.";
|
||||
"achievements_locked_title" = "Gesperrt";
|
||||
"achievements_locked_empty" = "Alle Erfolge freigeschaltet. Gute Arbeit.";
|
||||
"rating_accessibility" = "Bewertung %d von 5";
|
||||
"achievements_unlocked_count" = "%d von %d freigeschaltet";
|
||||
"last_check_in" = "Letzter Check-in: %@";
|
||||
"next_check_in" = "Nächster Check-in: %@";
|
||||
"on_time_rate" = "%@ pünktlich";
|
||||
"on_time_count" = "%d/%d pünktlich";
|
||||
"tightest_finish" = "Knappster Abschluss: %@ vor der Frist.";
|
||||
"date_today" = "Heute";
|
||||
"date_yesterday" = "Gestern";
|
||||
"date_never" = "Nie";
|
||||
"calendar_event_title" = "%@: Monatlicher Check-in";
|
||||
"calendar_event_notes" = "Öffne %@ und erledige deinen monatlichen Check-in.";
|
||||
"checkin_enjoying_dialog_title" = "Wie sehr gefällt dir Portfolio Journal?";
|
||||
"checkin_enjoying_dialog_message" = "Glückwunsch zu deinem neuen Erfolg. Dein Feedback hilft uns, besser zu werden.";
|
||||
"not_now" = "Nicht jetzt";
|
||||
"rating_1_star" = "1 Stern";
|
||||
"rating_n_stars" = "%d Sterne";
|
||||
"app_store_review_title" = "Möchtest du eine Bewertung im App Store hinterlassen?";
|
||||
"app_store_review_message" = "Danke für die 5 Sterne. Das hilft anderen Investoren sehr, die App zu entdecken.";
|
||||
"write_review" = "Bewertung schreiben";
|
||||
"save_1_snapshot" = "1 Snapshot speichern";
|
||||
"save_n_snapshots" = "%d Snapshots speichern";
|
||||
"checkin_update_month" = "%@ aktualisieren";
|
||||
"checkin_start_new" = "Starten";
|
||||
"Home" = "Start";
|
||||
"Sources" = "Quellen";
|
||||
"Charts" = "Charts";
|
||||
"Settings" = "Einstellungen";
|
||||
"Journal" = "Journal";
|
||||
"Search monthly notes" = "Monatliche Notizen suchen";
|
||||
"Monthly Check-ins" = "Monatliche Check-ins";
|
||||
"No monthly notes yet." = "Noch keine monatlichen Notizen.";
|
||||
"No matching notes." = "Keine passenden Notizen.";
|
||||
"Jump to month" = "Zum Monat springen";
|
||||
"Today" = "Heute";
|
||||
"Mood not set" = "Stimmung nicht gesetzt";
|
||||
"No rating" = "Keine Bewertung";
|
||||
"No note yet." = "Noch keine Notiz.";
|
||||
"Monthly Note" = "Monatliche Notiz";
|
||||
"Open Full Note" = "Vollständige Notiz öffnen";
|
||||
"Duplicate Previous" = "Vorherigen duplizieren";
|
||||
"Save" = "Speichern";
|
||||
"Monthly Check-in" = "Monatlicher Check-in";
|
||||
"This Month" = "Diesen Monat";
|
||||
"No check-in yet this month" = "Diesen Monat noch kein Check-in";
|
||||
"Start your first check-in anytime." = "Starte deinen ersten Check-in jederzeit.";
|
||||
"Mark Check-in Complete" = "Check-in als abgeschlossen markieren";
|
||||
"Editing stays open. New check-ins unlock after 70% of the month." = "Die Bearbeitung bleibt offen. Neue Check-ins werden nach 70 % des Monats freigeschaltet.";
|
||||
"Momentum & Streaks" = "Momentum & Serien";
|
||||
"Log a check-in to start a streak" = "Erfasse einen Check-in, um eine Serie zu starten";
|
||||
"Streak" = "Serie";
|
||||
"On-time in a row" = "Pünktlich in Folge";
|
||||
"Best" = "Best";
|
||||
"Personal best" = "Persönlicher Bestwert";
|
||||
"Avg early" = "Ø Puffer";
|
||||
"vs deadline" = "vs. Frist";
|
||||
"On-time score" = "Pünktlichkeitsscore";
|
||||
"Achievements" = "Erfolge";
|
||||
"View all achievements" = "Alle Erfolge anzeigen";
|
||||
"Monthly Pulse" = "Monatlicher Puls";
|
||||
"Optional" = "Optional";
|
||||
"Rate this month" = "Diesen Monat bewerten";
|
||||
"How did it feel?" = "Wie hat es sich angefühlt?";
|
||||
"Monthly Summary" = "Monatsübersicht";
|
||||
"Starting" = "Start";
|
||||
"Ending" = "Ende";
|
||||
"Contributions" = "Beiträge";
|
||||
"Net Performance" = "Nettoperformance";
|
||||
"Update Sources" = "Quellen aktualisieren";
|
||||
"Add sources to start your monthly check-in." = "Füge Quellen hinzu, um deinen monatlichen Check-in zu starten.";
|
||||
"Updated this cycle" = "In diesem Zyklus aktualisiert";
|
||||
"Needs update" = "Benötigt Update";
|
||||
"Snapshot Notes" = "Snapshot-Notizen";
|
||||
"No snapshot notes for this month." = "Keine Snapshot-Notizen in diesem Monat.";
|
||||
"Source" = "Quelle";
|
||||
"Your full portfolio,\nfully clear" = "Dein komplettes Portfolio,\nvöllig klar";
|
||||
"One payment. Every feature. Forever." = "Eine Zahlung. Jede Funktion. Für immer.";
|
||||
"Get Full Access" = "Vollen Zugriff erhalten";
|
||||
"Restore Purchases" = "Käufe wiederherstellen";
|
||||
"Payment charged to your Apple ID account." = "Die Zahlung wird deinem Apple-ID-Konto belastet.";
|
||||
"Terms" = "Bedingungen";
|
||||
"Privacy" = "Datenschutz";
|
||||
"· one-time · Family Sharing" = "· einmalig · Familienfreigabe";
|
||||
"Full access, one payment" = "Voller Zugriff, eine Zahlung";
|
||||
"Unlimited sources, advanced charts & more" = "Unbegrenzte Quellen, erweiterte Charts und mehr";
|
||||
"See full access" = "Vollen Zugriff ansehen";
|
||||
"Batch Update" = "Sammelupdate";
|
||||
"Current value" = "Aktueller Wert";
|
||||
"Contribution this period (optional)" = "Beitrag in diesem Zeitraum (optional)";
|
||||
"Include Contribution" = "Beitrag einbeziehen";
|
||||
"New capital added" = "Neues Kapital hinzugefügt";
|
||||
"Contribution (Optional)" = "Beitrag (optional)";
|
||||
"Track new capital added to separate it from investment growth." = "Erfasse neues Kapital, um es vom Investmentwachstum zu trennen.";
|
||||
"Monthly Highlights" = "Monatliche Highlights";
|
||||
"Best Performer" = "Bester Performer";
|
||||
"Worst Performer" = "Schlechtester Performer";
|
||||
"Best Contributor" = "Größter Beitrag";
|
||||
"Update Check-in" = "Check-in aktualisieren";
|
||||
"Completed %@" = "Abgeschlossen %@";
|
||||
|
||||
// MARK: - Missing keys added (1.3.x)
|
||||
"Checking…" = "Wird geprüft…";
|
||||
"Clear Filters" = "Filter löschen";
|
||||
"Force Upload to iCloud" = "Zu iCloud hochladen";
|
||||
"No export yet" = "Noch nicht exportiert";
|
||||
"Not synced yet" = "Noch nicht synchronisiert";
|
||||
"Refresh" = "Aktualisieren";
|
||||
"Search sources" = "Quellen suchen";
|
||||
"Syncing with iCloud..." = "Mit iCloud synchronisieren...";
|
||||
"Uploading..." = "Wird hochgeladen...";
|
||||
"Verify iCloud Setup" = "iCloud-Einrichtung prüfen";
|
||||
"add_source_name_footer" = "Eine Quelle ist jede Investition, die du verfolgen möchtest: Aktien, ETFs, Sparkonten, Immobilien, Krypto und mehr.";
|
||||
"add_source_name_placeholder" = "z.B. MSCI World ETF, Sparkonto, Wohnung...";
|
||||
"categories_empty" = "Noch keine Kategorien.";
|
||||
"category_has_sources_warning" = "Eine Kategorie mit Quellen kann nicht gelöscht werden. Entferne oder weise alle Quellen zuerst um.";
|
||||
"category_name_placeholder" = "z.B. Notfallfonds";
|
||||
"chart_yoy_empty" = "Füge Snapshots über verschiedene Jahre hinzu, um den Vergleich zu sehen.";
|
||||
"chart_yoy_title" = "Jahr für Jahr";
|
||||
"chart_yoy_estimated_note" = "* geschätzt (Jahresend-Prognose)";
|
||||
"checking_icloud" = "iCloud wird geprüft...";
|
||||
"contributions_vs_returns_invested" = "Investiert";
|
||||
"contributions_vs_returns_returns" = "Marktrendite";
|
||||
"contributions_vs_returns_title" = "Investiert vs. Rendite";
|
||||
"csv_enter_value" = "Wert eingeben";
|
||||
"csv_field_category" = "Kategorie";
|
||||
"csv_field_contribution" = "Einlage";
|
||||
"csv_field_date" = "Datum";
|
||||
"csv_field_notes" = "Notizen";
|
||||
"csv_field_source" = "Quellenname (erforderlich)";
|
||||
"csv_field_value" = "Wert (erforderlich)";
|
||||
"csv_mapping_subtitle" = "Ordne deine CSV-Spalten den Portfolio Journal-Feldern zu";
|
||||
"csv_no_column" = "— Nicht zugeordnet —";
|
||||
"csv_no_date_hint" = "Alle Zeilen werden als Snapshot für heute importiert.";
|
||||
"csv_optional_section" = "Optionale Felder";
|
||||
"csv_preview_section" = "CSV-Vorschau";
|
||||
"csv_required_section" = "Pflichtfelder";
|
||||
"csv_use_today" = "Heutiges Datum verwenden";
|
||||
"goal_achieved_notification_body" = "Du hast dein Ziel erreicht: %@";
|
||||
"goal_achieved_notification_title" = "Ziel erreicht! 🎉";
|
||||
"goal_archive" = "Archivieren";
|
||||
"goal_delete_confirm" = "Löschen";
|
||||
"goal_delete_message" = "Dieses Ziel wird dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.";
|
||||
"goal_delete_title" = "Ziel löschen";
|
||||
"goal_unarchive" = "Wiederherstellen";
|
||||
"goals_all_active_achieved" = "Alle aktiven Ziele wurden erreicht.";
|
||||
"goals_empty_archived" = "Keine archivierten Ziele.";
|
||||
"goals_filter_active" = "Aktiv";
|
||||
"goals_filter_all" = "Alle";
|
||||
"goals_filter_archived" = "Archiviert";
|
||||
"icloud_check_description" = "Wenn du Daten auf einem anderen Gerät hast, aktiviere iCloud, um sie hier wiederherzustellen.";
|
||||
"icloud_check_title" = "Nutzt du Portfolio Journal bereits?";
|
||||
"icloud_enabled_description" = "Schließe die App und öffne sie erneut. Deine Daten werden automatisch von iCloud geladen.";
|
||||
"icloud_enabled_title" = "iCloud aktiviert";
|
||||
"monthly_checkin_notification_body" = "Trage die Werte dieses Monats ein und verfolge das Wachstum deines Portfolios.";
|
||||
"monthly_checkin_notification_title" = "Zeit für dein monatliches Update";
|
||||
"onboarding_add_first_source" = "Meine erste Investition hinzufügen";
|
||||
"onboarding_import_data" = "Vorhandene Daten importieren";
|
||||
"onboarding_quickstart_subtitle" = "Füge deine erste Investitionsquelle hinzu, um dein Portfolio zu verfolgen.";
|
||||
"onboarding_quickstart_title" = "Fast fertig";
|
||||
"reengagement_body" = "Ein paar Minuten reichen, um deine Investitionen im Blick zu behalten.";
|
||||
"reengagement_goal_title" = "Du bist auf Kurs 📈";
|
||||
"reengagement_goal_body" = "In diesem Tempo erreichst du \"%@\" bis %@. Sieh, wie nah du bist.";
|
||||
"reengagement_yoy_title" = "Sieh dir dieses Wachstum an 👀";
|
||||
"reengagement_yoy_body" = "Du liegst im letzten Jahr %@ im Plus. Sieh dir den Trend an.";
|
||||
"reengagement_month_title" = "Guter Monat 📈";
|
||||
"reengagement_month_body" = "Dein Portfolio ist diesen Monat um %@ gewachsen. Wirf einen Blick darauf.";
|
||||
"reengagement_milestone_title" = "Fast geschafft 🎯";
|
||||
"reengagement_milestone_body" = "Du bist fast bei %@. Wie nah bist du heute?";
|
||||
"reengagement_streak_title" = "Halte die Serie am Leben 🔥";
|
||||
"reengagement_streak_body" = "Du hast %d Monate in Folge erfasst. Hör jetzt nicht auf.";
|
||||
"reengagement_neutral_title" = "Gibt's was Neues? 👀";
|
||||
"reengagement_neutral_body" = "In deinem Portfolio hat sich etwas getan. Sieh, was sich geändert hat.";
|
||||
"reengagement_title" = "Dein Portfolio wartet";
|
||||
"snapshot_duplicate_add" = "Trotzdem hinzufügen";
|
||||
"snapshot_contribution_propagate_title" = "Beitrag anwenden";
|
||||
"snapshot_contribution_propagate_message" = "%@ auch als Beitrag auf andere Einträge anwenden?";
|
||||
"snapshot_contribution_propagate_forward" = "Auf spätere anwenden";
|
||||
"snapshot_contribution_propagate_backward" = "Auf frühere anwenden";
|
||||
"snapshot_contribution_propagate_all" = "Auf alle anwenden";
|
||||
"snapshot_contribution_propagate_this" = "Nur dieser Eintrag";
|
||||
"snapshot_duplicate_message" = "Du hast bereits einen Snapshot für diesen Monat. Möchtest du einen weiteren hinzufügen?";
|
||||
"snapshot_duplicate_replace" = "Vorhandenen ersetzen";
|
||||
"snapshot_duplicate_title" = "Snapshot bereits vorhanden";
|
||||
"sources_filter_all" = "Alle";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "Deine monatliche Portfolio-Zusammenfassung";
|
||||
"monthly_summary_notification_body" = "Sieh nach, wie deine Investitionen diesen Monat abgeschnitten haben.";
|
||||
"streak_badge" = "%d-Monats-Serie";
|
||||
"goals_empty_add_cta" = "Mein erstes Ziel hinzufügen";
|
||||
"journal_empty_title" = "Noch keine Einträge";
|
||||
"journal_empty_body" = "Snapshot-Aktualisierungen erstellen automatisch monatliche Einträge. Füge deine erste Quelle hinzu, um zu beginnen.";
|
||||
"quick_update_title" = "Schnellaktualisierung";
|
||||
"quick_update_section_header" = "Aktuelle Werte eingeben";
|
||||
"quick_update_section_footer" = "Es werden nur Quellen aktualisiert, für die ein Wert eingegeben wurde.";
|
||||
"quick_update_placeholder" = "Neuer Wert";
|
||||
"quick_update_save" = "Alle speichern";
|
||||
"quick_update_no_sources" = "Keine Quellen";
|
||||
"quick_update_no_sources_body" = "Füge zuerst Investitionsquellen hinzu, um die Schnellaktualisierung zu nutzen.";
|
||||
"update_available_title" = "Update verfügbar";
|
||||
"update_available_body" = "Version %@ ist im App Store verfügbar.";
|
||||
"whats_new_title" = "Neuheiten in 1.4";
|
||||
"whats_new_subtitle" = "Verbesserungen, die dir helfen, auf Kurs zu bleiben.";
|
||||
"whats_new_quick_update_title" = "Schnelles Portfolio-Update";
|
||||
"whats_new_quick_update_body" = "Aktualisiere alle deine Quellen mit einem Tippen auf einem einzigen Bildschirm.";
|
||||
"whats_new_streak_title" = "Update-Serie";
|
||||
"whats_new_streak_body" = "Verfolge, wie viele Monate du dein Portfolio in Folge aktuell gehalten hast.";
|
||||
"whats_new_goals_title" = "Intelligentere Ziele & Journal";
|
||||
"whats_new_goals_body" = "Verbesserte Leerzustände helfen dir, schneller zu starten.";
|
||||
"whats_new_continue" = "Los geht's";
|
||||
"source_monthly_contribution_title" = "Monatlicher Beitrag";
|
||||
"source_monthly_contribution_placeholder" = "z.B. 500";
|
||||
"source_monthly_contribution_not_set" = "Nicht konfiguriert";
|
||||
"source_monthly_contribution_hint" = "Wird in der Schnellaktualisierung vorausgefüllt";
|
||||
"quick_update_contribution_label" = "Beitrag";
|
||||
"quick_update_contribution_placeholder" = "Betrag";
|
||||
"source_monthly_contribution_apply_title" = "Beitrag anwenden";
|
||||
"source_monthly_contribution_apply_message" = "%@ auf alle früheren Snapshots ohne Beitrag anwenden?";
|
||||
"source_monthly_contribution_apply_retroactive" = "Auf alle früheren Snapshots anwenden";
|
||||
"source_monthly_contribution_apply_forward" = "Nur ab jetzt";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "Einblicke";
|
||||
"insight_milestone_title" = "Meilenstein in Sicht";
|
||||
"insight_milestone_value" = "%1$@ bis %2$@";
|
||||
"insight_ytd_title" = "Jahresbeginn";
|
||||
"insight_market_gains_title" = "Marktgewinne";
|
||||
"insight_market_gains_value" = "+%@ aus Märkten";
|
||||
"insight_streak_title" = "Tracking-Serie";
|
||||
"insight_streak_value" = "%d Monate in Folge";
|
||||
"insight_forecast_title" = "Auf dem Weg zu";
|
||||
"insight_allocation_title" = "Allokationsabweichung";
|
||||
"insight_allocation_over_value" = "%1$@ liegt bei %2$.0f%% — über deinem Ziel von %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ liegt bei %2$.0f%% — unter deinem Ziel von %3$.0f%%";
|
||||
"insight_allocation_detail" = "Zum Plan zurück ausbalancieren";
|
||||
"insight_concentration_title" = "Klumpenrisiko";
|
||||
"insight_concentration_value" = "%1$@ macht %2$.0f%% deines Portfolios aus";
|
||||
"insight_concentration_detail" = "Erwäge eine breitere Streuung";
|
||||
"insight_best_performer_title" = "Bester Wert";
|
||||
"insight_worst_performer_title" = "Braucht Aufmerksamkeit";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversifikation";
|
||||
"insight_diversification_single_value" = "Alles in einer Quelle — erwäge eine weitere";
|
||||
"insight_diversification_good_value" = "Auf %d Kategorien verteilt";
|
||||
"goal_projection_projected" = "Prognose: %@";
|
||||
"goal_projection_add_contributions" = "Beiträge hinzufügen, um es zu erreichen";
|
||||
"goal_chip_ahead" = "Voraus";
|
||||
"goal_chip_on_track" = "Im Plan";
|
||||
"goal_chip_behind" = "Im Rückstand";
|
||||
"goal_chip_at_this_pace" = "In diesem Tempo…";
|
||||
"notification_milestone_title" = "Portfolio-Meilenstein! 🎉";
|
||||
"notification_milestone_body" = "Dein Portfolio hat gerade %@ überschritten. Herzlichen Glückwunsch!";
|
||||
|
||||
"streak_protection_notification_title" = "Brich deine Serie nicht 🔥";
|
||||
"streak_protection_notification_body" = "Dein monatlicher Check-in steht noch aus. 2 Minuten genügen, um deine Serie zu halten.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Mehrere Konten";
|
||||
"paywall_benefit_accounts_subtitle" = "Getrennte Portfolios für Familie oder Business";
|
||||
"paywall_benefit_family_title" = "Familienfreigabe";
|
||||
"paywall_benefit_family_subtitle" = "Ein Kauf, bis zu 5 Familienmitglieder";
|
||||
"paywall_plan_annual" = "Jährlich";
|
||||
"paywall_plan_annual_per_year" = "%@ / Jahr";
|
||||
"paywall_plan_lifetime" = "Lebenslang";
|
||||
"paywall_plan_lifetime_badge" = "BESTER WERT";
|
||||
"paywall_trial_days" = "Tage";
|
||||
"paywall_trial_weeks" = "Wochen";
|
||||
"paywall_trial_months" = "Monate";
|
||||
"paywall_trial_years" = "Jahre";
|
||||
"paywall_trial_format" = "%d %@ gratis";
|
||||
|
||||
"chart_history_locked" = "%d weitere Monate Verlauf — mit Premium freischalten";
|
||||
|
||||
"chart_group_overview" = "Überblick";
|
||||
"chart_group_analyze" = "Analyse";
|
||||
"chart_group_risk" = "Risiko";
|
||||
"chart_group_forecast" = "Prognose";
|
||||
"kpi_total_value" = "Gesamtwert";
|
||||
"kpi_period_return" = "Zeitraum-Rendite";
|
||||
"kpi_cagr" = "CAGR";
|
||||
"kpi_volatility" = "Volatilität";
|
||||
"kpi_max_drawdown" = "Max. Drawdown";
|
||||
"chart_zoom_in" = "Vergrößern";
|
||||
"chart_zoom_out" = "Verkleinern";
|
||||
"chart_zoom_reset" = "Zoom zurücksetzen";
|
||||
|
||||
"quick_update_paste_suggestion" = "%@ in %@ einfügen";
|
||||
"quick_update_next_badge" = "NÄCHSTE";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "%1$@ für %2$@ gespeichert.";
|
||||
"intent_amount_invalid" = "Der Betrag muss größer als null sein. Wie viel?";
|
||||
"Log Portfolio Value" = "Portfoliowert erfassen";
|
||||
"Record a value for one of your sources without opening the app." = "Erfasse einen Wert für eine deiner Quellen, ohne die App zu öffnen.";
|
||||
"Amount" = "Betrag";
|
||||
"How much?" = "Wie viel?";
|
||||
"Log Value" = "Wert erfassen";
|
||||
"Update Portfolio" = "Portfolio aktualisieren";
|
||||
"Check Portfolio" = "Portfolio ansehen";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "Diagramm teilen";
|
||||
"chart_share_text" = "Mein %@-Diagramm — erstellt mit Portfolio Journal";
|
||||
"chart_share_tagline" = "Privates, ruhiges Investment-Tracking für iPhone & iPad.";
|
||||
"chart_share_scan" = "Zum Laden scannen";
|
||||
"chart_locked_title" = "%@ ist ein Premium-Diagramm";
|
||||
"chart_locked_cta" = "Premium freischalten";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in %@ erledigt";
|
||||
"checkin_done_next" = "Nächster: %@";
|
||||
"checkin_next_due" = "Nächster Check-in: %@";
|
||||
"checkin_days_left" = "noch %d Tage";
|
||||
"checkin_due_today" = "Heute fällig";
|
||||
"checkin_overdue" = "Überfällig";
|
||||
"dash_chart_yoy" = "Jahr für Jahr";
|
||||
"home_chart_locked_sub" = "Mit Premium freischalten, um dieses Diagramm zu sehen.";
|
||||
"checkin_sources_progress" = "%1$d von %2$d Quellen aktualisiert";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Letzter Monat";
|
||||
"guided_paste_amount" = "%@ einfügen";
|
||||
"guided_next" = "Weiter";
|
||||
"guided_same_as_last" = "Wie letzten Monat";
|
||||
"guided_skip" = "Überspringen";
|
||||
"guided_reflection_title" = "Wie fühlt sich dieser Monat an?";
|
||||
"guided_reflection_subtitle" = "Eine kurze Notiz für dein zukünftiges Ich.";
|
||||
"guided_note_placeholder" = "Eine ehrliche Zeile zu diesem Monat…";
|
||||
"guided_finish" = "Check-in abschließen";
|
||||
"guided_done_title" = "Check-in %@ erledigt";
|
||||
"guided_streak" = "%d Monate in Folge";
|
||||
"guided_share_month" = "Diesen Monat teilen";
|
||||
"guided_done_cta" = "Fertig";
|
||||
"journal_no_note" = "Noch keine Notiz.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Version %@";
|
||||
"whats_new_150_redesign_title" = "Ein ruhigeres, klareres Design";
|
||||
"whats_new_150_redesign_body" = "Neu gestaltetes Home mit klarem Fokus, deinem Check-in im Mittelpunkt und weniger Ablenkung.";
|
||||
"whats_new_150_guided_title" = "Geführter Monats-Check-in";
|
||||
"whats_new_150_guided_body" = "Eine Quelle pro Schritt, Live-Deltas, kurze Reflexion — dein 5-Minuten-Ritual, neu gedacht.";
|
||||
"whats_new_150_rings_title" = "Ziel-Ringe & Journal-Timeline";
|
||||
"whats_new_150_rings_body" = "Fortschrittsringe im Fitness-Stil für Ziele und eine Stimmungs-Timeline für deine Investment-Geschichte.";
|
||||
"whats_new_142_ocr_title" = "Update per Screenshot";
|
||||
"whats_new_142_ocr_body" = "Teile einen Screenshot deiner Bank-App — der Saldo wird auf dem Gerät erkannt, nichts verlässt dein iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri auf Deutsch";
|
||||
"whats_new_142_siri_body" = "\"Erfasse den Saldo von Indexa\" — Werte per Sprache oder Kurzbefehle erfassen, ohne die App zu öffnen.";
|
||||
"whats_new_142_charts_title" = "Diagramme, neu gebaut";
|
||||
"whats_new_142_charts_body" = "Native Seitenleiste auf dem iPad, passende KPIs pro Diagramm und Teilen als schönes Bild.";
|
||||
"whats_new_141_extension_title" = "Aktualisieren ohne App-Wechsel";
|
||||
"whats_new_141_extension_body" = "Teile einen Wert aus deiner Bank-App direkt in Portfolio Journal mit der neuen Erweiterung.";
|
||||
"whats_new_141_ipad_title" = "Besser auf dem iPad";
|
||||
"whats_new_141_ipad_body" = "Neu gestaltete Diagramme für den großen Bildschirm, mit Zoom und Wertbeschriftungen.";
|
||||
"whats_new_141_icloud_title" = "Mehr Sync";
|
||||
"whats_new_141_icloud_body" = "Monatliche Beiträge und Allokationsziele werden jetzt über iCloud synchronisiert.";
|
||||
"whats_new_title_format" = "Neu in %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Alles in Sekunden aktualisieren";
|
||||
"tip_quick_update_body" = "Quick Update zeigt alle Quellen auf einem Bildschirm — Werte aus der Zwischenablage einfügen, fertig.";
|
||||
"tip_ocr_title" = "Oder teile einfach einen Screenshot";
|
||||
"tip_ocr_body" = "Aus deiner Bank-App: Screenshot → Teilen → Portfolio Journal. Der Saldo wird auf dem Gerät gelesen.";
|
||||
"tip_siri_title" = "Probier es nächstes Mal per Sprache";
|
||||
"tip_siri_body" = "Sag Siri \"Erfasse den Saldo meines Kontos in Portfolio Journal\" — ohne die App zu öffnen.";
|
||||
"tip_chart_share_title" = "Teile dieses Diagramm";
|
||||
"tip_chart_share_body" = "Erzeugt ein schönes Bild mit deinen Daten, bereit für X oder WhatsApp.";
|
||||
"allocation_total" = "Gesamt";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud hat einige Datensätze abgelehnt. Meist wird das iCloud-Schema noch eingerichtet — löst sich oft innerhalb weniger Stunden oder nach dem nächsten App-Update.";
|
||||
"icloud_hint_quota" = "Dein iCloud-Speicher ist voll. Gib Speicher frei und versuche es erneut.";
|
||||
"icloud_hint_network" = "iCloud nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.";
|
||||
"icloud_hint_partial_generic" = "Einige Datensätze konnten diesmal nicht synchronisiert werden. Die App versucht es automatisch erneut.";
|
||||
"icloud_status_error" = "iCloud-Synchronisierungsproblem";
|
||||
"icloud_status_syncing" = "Synchronisierung mit iCloud";
|
||||
"icloud_show_details" = "Technische Details anzeigen";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Bestes Jahr";
|
||||
"yoy_worst_year" = "Schlechtestes Jahr";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "%@ einfügen";
|
||||
"quick_update_scan" = "Scannen";
|
||||
"quick_update_done_kbd" = "Fertig";
|
||||
"quick_update_scanning" = "Bild wird gelesen…";
|
||||
"quick_update_pick_amount" = "Welcher Betrag?";
|
||||
"quick_update_no_amount_found" = "Kein Betrag im Bild erkannt.";
|
||||
"icloud_copy_diagnostics" = "iCloud-Diagnose kopieren";
|
||||
"icloud_diagnostics_copied" = "Diagnose in Zwischenablage kopiert";
|
||||
|
||||
"Smooth Gaps in Charts" = "Lücken in Diagrammen glätten";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Für %d Monate fehlen Daten";
|
||||
"gaps_missing_count" = "%d fehlen";
|
||||
"gaps_range_between" = "Zwischen %@ und %@";
|
||||
"Fill in missing data" = "Fehlende Daten ergänzen";
|
||||
"Missing Data" = "Fehlende Daten";
|
||||
"Fill Gap" = "Lücke füllen";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Das Diagramm schätzt diese Monate. Füge echte Werte hinzu, damit dein Verlauf genau bleibt.";
|
||||
"Dismiss" = "Ausblenden";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d Mon.";
|
||||
"Monthly Summary" = "Monatsübersicht";
|
||||
"Portfolio Value" = "Portfoliowert";
|
||||
"Tracked with" = "Erfasst mit";
|
||||
"Share monthly summary" = "Monatsübersicht teilen";
|
||||
"since last check-in" = "seit dem letzten Check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Nichts Neues zu importieren. Deine Daten sind bereits aktuell.";
|
||||
"import_preview_sources" = "%d Quellen erstellen";
|
||||
"import_preview_snapshots_new" = "%d Einträge erstellen";
|
||||
"import_preview_snapshots_update" = "%d Einträge aktualisieren";
|
||||
"import_preview_categories" = "%d Kategorien erstellen";
|
||||
"import_preview_goals" = "%d Ziele erstellen";
|
||||
"import_preview_journal_new" = "%d Journaleinträge erstellen";
|
||||
"import_preview_journal_update" = "%d Journaleinträge aktualisieren";
|
||||
"Review Import" = "Import prüfen";
|
||||
"Reviewing import…" = "Import wird geprüft…";
|
||||
"Import" = "Importieren";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Geschätzt";
|
||||
"gaps_autofill_cta" = "%d Monate automatisch füllen";
|
||||
"Add manually" = "Manuell hinzufügen";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Das automatische Füllen schätzt die fehlenden Monate zwischen zwei Snapshots (als geschätzt markiert). Oder trage die echten Werte selbst ein.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Automatisches Backup";
|
||||
"Off" = "Aus";
|
||||
"Daily" = "Täglich";
|
||||
"Weekly" = "Wöchentlich";
|
||||
"Monthly" = "Monatlich";
|
||||
"Last auto backup" = "Letztes Auto-Backup";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Guthaben ausblenden";
|
||||
"Show balances" = "Guthaben anzeigen";
|
||||
"Balance hidden" = "Guthaben ausgeblendet";
|
||||
"Reveal your balances" = "Guthaben anzeigen";
|
||||
"Privacy" = "Datenschutz";
|
||||
"Hide Balances" = "Guthaben ausblenden";
|
||||
"Require Face ID to Reveal" = "Face ID zum Anzeigen erforderlich";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional Face ID zum Anzeigen erforderlich.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Du hast noch keinen Portfoliowert erfasst.";
|
||||
"intent_networth_dialog" = "Dein Vermögen beträgt %@.";
|
||||
"intent_streak" = "Du hast eine Serie von %d Monaten.";
|
||||
"intent_streak_with_date" = "Du hast eine Serie von %d Monaten. Dein letzter Check-in war am %@.";
|
||||
"intent_streak_none" = "Du hast noch keine aktive Serie. Erfasse einen Wert, um zu starten.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Benutzerdefinierter Zeitraum";
|
||||
"chart_range_from" = "Von";
|
||||
"chart_range_to" = "Bis";
|
||||
"chart_filtered_prefix" = "Gefiltert: %@";
|
||||
"chart_overlay_contributions" = "vs. Einzahlungen";
|
||||
"chart_insight_best_month" = "Bester Monat im Zeitraum: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Teile deinen Fortschritt";
|
||||
"share_percent_only" = "Nur Prozente";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Teilen";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Gesamtrendite";
|
||||
"share_metric_yoy" = "Dieses Jahr";
|
||||
"share_metric_since_inception" = "Seit Beginn";
|
||||
"share_cta_headline" = "Verfolge deine — kostenlos";
|
||||
"share_cta_sub" = "Scannen für Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Stand %@";
|
||||
"share_theme_dark" = "Dunkel";
|
||||
"share_theme_light" = "Hell";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Heutiger Wert";
|
||||
"add_source_value_footer" = "Wird als dein erster Check-in gespeichert – dein Fortschritt beginnt heute.";
|
||||
"quick_update_add_first_source" = "Erste Quelle hinzufügen";
|
||||
@@ -112,6 +112,16 @@
|
||||
"feature_no_ads" = "No Ads";
|
||||
"feature_no_ads_desc" = "Ad-free experience forever";
|
||||
|
||||
// Paywall benefits (1.2.0)
|
||||
"paywall_benefit_history_title" = "Your complete history";
|
||||
"paywall_benefit_history_subtitle" = "Every snapshot, contribution, and gain since day one";
|
||||
"paywall_benefit_charts_title" = "Charts that reveal patterns";
|
||||
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance — all in one place";
|
||||
"paywall_benefit_forecasts_title" = "12-month forecasts";
|
||||
"paywall_benefit_forecasts_subtitle" = "See where your portfolio is likely heading";
|
||||
"paywall_benefit_noads_title" = "No ads, ever";
|
||||
"paywall_benefit_noads_subtitle" = "Clean, focused experience with no distractions";
|
||||
|
||||
// MARK: - Settings
|
||||
"settings_title" = "Settings";
|
||||
"subscription" = "Subscription";
|
||||
@@ -177,6 +187,23 @@
|
||||
"onboarding_sync_desc" = "Your data syncs automatically via iCloud across all your Apple devices.";
|
||||
"get_started" = "Get Started";
|
||||
|
||||
// Onboarding pages (1.2.0)
|
||||
"onboarding_clarity_title" = "Know exactly where you stand";
|
||||
"onboarding_clarity_desc" = "See your total wealth, real returns, and allocation — always up to date.";
|
||||
"onboarding_habit_title" = "5 minutes a month is enough";
|
||||
"onboarding_habit_desc" = "Log your values once a month. Portfolio Journal handles the math and shows your progress.";
|
||||
"onboarding_calm_title" = "Ignore the noise. Track the trend.";
|
||||
"onboarding_calm_desc" = "Daily swings don't tell the real story. Your growth over months and years does.";
|
||||
"onboarding_goals_title" = "Reach your financial goals";
|
||||
"onboarding_goals_desc" = "Set targets, track milestones, and see exactly how far you've come.";
|
||||
|
||||
// iCloud check before onboarding (1.2.1)
|
||||
"icloud_check_title" = "Already use Portfolio Journal?";
|
||||
"icloud_check_description" = "If you have data on another device, enable iCloud to restore it here.";
|
||||
"icloud_enabled_title" = "iCloud Enabled";
|
||||
"icloud_enabled_description" = "Close the app and reopen it. Your data will load automatically from iCloud.";
|
||||
"checking_icloud" = "Checking iCloud...";
|
||||
|
||||
// MARK: - Errors
|
||||
"error_generic" = "An error occurred. Please try again.";
|
||||
"error_no_purchases" = "No purchases found to restore";
|
||||
@@ -235,3 +262,419 @@
|
||||
"date_never" = "Never";
|
||||
"calendar_event_title" = "%@: Monthly Check-in";
|
||||
"calendar_event_notes" = "Open %@ and complete your monthly check-in.";
|
||||
|
||||
// MARK: - Satisfaction & Review Dialogs
|
||||
"checkin_enjoying_dialog_title" = "How much are you enjoying Portfolio Journal?";
|
||||
"checkin_enjoying_dialog_message" = "Congrats on your new achievement! Your feedback helps us improve.";
|
||||
"not_now" = "Not Now";
|
||||
"rating_1_star" = "1 Star";
|
||||
"rating_n_stars" = "%d Stars";
|
||||
"app_store_review_title" = "Would you like to leave an App Store review?";
|
||||
"app_store_review_message" = "Thanks for the 5 stars. It really helps other investors discover the app.";
|
||||
"write_review" = "Write a Review";
|
||||
|
||||
// MARK: - Batch Update Actions
|
||||
"save_1_snapshot" = "Save 1 Snapshot";
|
||||
"save_n_snapshots" = "Save %d Snapshots";
|
||||
|
||||
// MARK: - Monthly Check-in Card
|
||||
"checkin_update_month" = "Update %@";
|
||||
"checkin_start_new" = "Start";
|
||||
|
||||
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
|
||||
"reengagement_title" = "Your portfolio is waiting";
|
||||
"reengagement_body" = "A few minutes is all it takes to stay on top of your investments.";
|
||||
"reengagement_goal_title" = "You're on track 📈";
|
||||
"reengagement_goal_body" = "At this pace you'll reach \"%@\" by %@. See how close you are.";
|
||||
"reengagement_yoy_title" = "Look at that growth 👀";
|
||||
"reengagement_yoy_body" = "You're up %@ over the last year. Check the trend.";
|
||||
"reengagement_month_title" = "Nice month 📈";
|
||||
"reengagement_month_body" = "Your portfolio grew %@ this month. Take a look.";
|
||||
"reengagement_milestone_title" = "So close 🎯";
|
||||
"reengagement_milestone_body" = "You're almost at %@. How close are you today?";
|
||||
"reengagement_streak_title" = "Keep the streak alive 🔥";
|
||||
"reengagement_streak_body" = "You've logged %d months in a row. Don't stop now.";
|
||||
"reengagement_neutral_title" = "Anything new? 👀";
|
||||
"reengagement_neutral_body" = "There's been movement in your portfolio. See what changed.";
|
||||
"monthly_checkin_notification_title" = "Time for your monthly update";
|
||||
"monthly_checkin_notification_body" = "Log this month's values and track your portfolio growth.";
|
||||
|
||||
// MARK: - Onboarding QuickStart (1.3.0)
|
||||
"onboarding_quickstart_title" = "Almost there";
|
||||
"onboarding_quickstart_subtitle" = "Add your first investment source to start tracking your portfolio.";
|
||||
"onboarding_add_first_source" = "Add My First Investment";
|
||||
"onboarding_import_data" = "Import Existing Data";
|
||||
|
||||
// MARK: - iCloud Sync Status (1.3.1)
|
||||
"Force Upload to iCloud" = "Force Upload to iCloud";
|
||||
"Uploading..." = "Uploading...";
|
||||
"No export yet" = "No export yet";
|
||||
"Syncing with iCloud..." = "Syncing with iCloud...";
|
||||
"Not synced yet" = "Not synced yet";
|
||||
"Refresh" = "Refresh";
|
||||
|
||||
// MARK: - iCloud Diagnostics (1.3.1)
|
||||
"Verify iCloud Setup" = "Verify iCloud Setup";
|
||||
"Checking…" = "Checking…";
|
||||
"context.hasChanges: YES ✓" = "context.hasChanges: YES ✓";
|
||||
"context.hasChanges: NO ✗ — objects may not have been queued" = "context.hasChanges: NO ✗ — objects may not have been queued";
|
||||
|
||||
// MARK: - Sources Filter & Search (1.3.1)
|
||||
"sources_filter_all" = "All";
|
||||
"Search sources" = "Search sources";
|
||||
"Clear Filters" = "Clear Filters";
|
||||
|
||||
// MARK: - AddSourceView (1.3.0)
|
||||
"add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment...";
|
||||
"add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more.";
|
||||
|
||||
// MARK: - Goals Archive (1.3.2)
|
||||
"goals_filter_active" = "Active";
|
||||
"goals_filter_archived" = "Archived";
|
||||
"goals_filter_all" = "All";
|
||||
"goal_archive" = "Archive";
|
||||
"goal_unarchive" = "Unarchive";
|
||||
"goals_empty_archived" = "No archived goals.";
|
||||
"goals_all_active_achieved" = "All active goals are achieved.";
|
||||
|
||||
// MARK: - Category Management (1.3.2)
|
||||
"category_name_placeholder" = "e.g. Emergency Fund";
|
||||
"category_has_sources_warning" = "Cannot delete a category that has sources. Remove or reassign all sources first.";
|
||||
"categories_empty" = "No categories yet.";
|
||||
|
||||
// MARK: - CSV Column Mapping (1.3.2)
|
||||
"csv_mapping_subtitle" = "Match your CSV columns to Portfolio Journal fields";
|
||||
"csv_field_source" = "Source Name (required)";
|
||||
"csv_field_value" = "Value (required)";
|
||||
"csv_field_date" = "Date";
|
||||
"csv_field_category" = "Category";
|
||||
"csv_field_contribution" = "Contribution";
|
||||
"csv_field_notes" = "Notes";
|
||||
"csv_no_column" = "— Not mapped —";
|
||||
"csv_enter_value" = "Enter value";
|
||||
"csv_use_today" = "Use today's date";
|
||||
"csv_no_date_hint" = "All rows will be imported as a snapshot for today.";
|
||||
"csv_preview_section" = "CSV Preview";
|
||||
"csv_required_section" = "Required Fields";
|
||||
"csv_optional_section" = "Optional Fields";
|
||||
|
||||
// MARK: - 1.3.2 Features
|
||||
"goal_achieved_notification_title" = "Goal achieved! 🎉";
|
||||
"goal_achieved_notification_body" = "You reached your goal: %@";
|
||||
|
||||
"snapshot_duplicate_title" = "Snapshot Already Exists";
|
||||
"snapshot_duplicate_message" = "You already have a snapshot for this month. Do you want to add another one?";
|
||||
"snapshot_duplicate_replace" = "Replace Existing";
|
||||
"snapshot_duplicate_add" = "Add Anyway";
|
||||
"snapshot_contribution_propagate_title" = "Apply Contribution";
|
||||
"snapshot_contribution_propagate_message" = "Apply %@ as the contribution to other snapshots too?";
|
||||
"snapshot_contribution_propagate_forward" = "Apply Going Forward";
|
||||
"snapshot_contribution_propagate_backward" = "Apply to Earlier";
|
||||
"snapshot_contribution_propagate_all" = "Apply to All";
|
||||
"snapshot_contribution_propagate_this" = "Only This Snapshot";
|
||||
|
||||
"contributions_vs_returns_title" = "Invested vs. Returns";
|
||||
"contributions_vs_returns_invested" = "Invested";
|
||||
"contributions_vs_returns_returns" = "Market Returns";
|
||||
|
||||
"chart_yoy_title" = "Year over Year";
|
||||
"chart_yoy_estimated_note" = "* estimated (year-end forecast)";
|
||||
"chart_yoy_empty" = "Add more snapshots across different years to see the comparison.";
|
||||
|
||||
// MARK: - Goal Delete Confirmation (1.3.2)
|
||||
"goal_delete_title" = "Delete Goal";
|
||||
"goal_delete_message" = "This goal will be permanently deleted. This action cannot be undone.";
|
||||
"goal_delete_confirm" = "Delete";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "Your Monthly Portfolio Summary";
|
||||
"monthly_summary_notification_body" = "Check how your investments performed this month.";
|
||||
"streak_badge" = "%d-month streak";
|
||||
"goals_empty_add_cta" = "Add My First Goal";
|
||||
"journal_empty_title" = "No check-ins yet";
|
||||
"journal_empty_body" = "Snapshot updates automatically create monthly entries. Add your first source to get started.";
|
||||
"quick_update_title" = "Quick Update";
|
||||
"quick_update_section_header" = "Enter current values";
|
||||
"quick_update_section_footer" = "Only sources with a value entered will be updated.";
|
||||
"quick_update_placeholder" = "New value";
|
||||
"quick_update_save" = "Save All";
|
||||
"quick_update_no_sources" = "No sources";
|
||||
"quick_update_no_sources_body" = "Add investment sources first to use Quick Update.";
|
||||
"update_available_title" = "Update Available";
|
||||
"update_available_body" = "Version %@ is ready on the App Store.";
|
||||
"whats_new_title" = "What's New in 1.4";
|
||||
"whats_new_subtitle" = "Improvements to help you stay on track.";
|
||||
"whats_new_quick_update_title" = "Quick Portfolio Update";
|
||||
"whats_new_quick_update_body" = "Update all your sources from a single screen with one tap.";
|
||||
"whats_new_streak_title" = "Update Streak";
|
||||
"whats_new_streak_body" = "Track how many months in a row you've kept your portfolio up to date.";
|
||||
"whats_new_goals_title" = "Smarter Goals & Journal";
|
||||
"whats_new_goals_body" = "Improved empty states help you get started faster.";
|
||||
"whats_new_continue" = "Let's Go";
|
||||
"source_monthly_contribution_title" = "Monthly Contribution";
|
||||
"source_monthly_contribution_placeholder" = "e.g. 500";
|
||||
"source_monthly_contribution_not_set" = "Not configured";
|
||||
"source_monthly_contribution_hint" = "Pre-filled as contribution in Quick Update";
|
||||
"quick_update_contribution_label" = "Contribution";
|
||||
"quick_update_contribution_placeholder" = "Amount";
|
||||
"source_monthly_contribution_apply_title" = "Apply Contribution";
|
||||
"source_monthly_contribution_apply_message" = "Apply %@ to all past snapshots that don't have a contribution?";
|
||||
"source_monthly_contribution_apply_retroactive" = "Apply to All Past Snapshots";
|
||||
"source_monthly_contribution_apply_forward" = "Only Going Forward";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "Insights";
|
||||
"insight_milestone_title" = "Milestone ahead";
|
||||
"insight_milestone_value" = "%1$@ from %2$@";
|
||||
"insight_ytd_title" = "Year to date";
|
||||
"insight_market_gains_title" = "Market gains";
|
||||
"insight_market_gains_value" = "+%@ from markets";
|
||||
"insight_streak_title" = "Tracking streak";
|
||||
"insight_streak_value" = "%d months in a row";
|
||||
"insight_forecast_title" = "On track for";
|
||||
"insight_allocation_title" = "Allocation drift";
|
||||
"insight_allocation_over_value" = "%1$@ is %2$.0f%% — above your %3$.0f%% target";
|
||||
"insight_allocation_under_value" = "%1$@ is %2$.0f%% — below your %3$.0f%% target";
|
||||
"insight_allocation_detail" = "Rebalance to stay on plan";
|
||||
"insight_concentration_title" = "Concentration risk";
|
||||
"insight_concentration_value" = "%1$@ is %2$.0f%% of your portfolio";
|
||||
"insight_concentration_detail" = "Consider spreading it out";
|
||||
"insight_best_performer_title" = "Best performer";
|
||||
"insight_worst_performer_title" = "Needs attention";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversification";
|
||||
"insight_diversification_single_value" = "All in one source — consider adding another";
|
||||
"insight_diversification_good_value" = "Spread across %d categories";
|
||||
"goal_projection_projected" = "Projected: %@";
|
||||
"goal_projection_add_contributions" = "Add contributions to reach this";
|
||||
"goal_chip_ahead" = "Ahead of schedule";
|
||||
"goal_chip_on_track" = "On track";
|
||||
"goal_chip_behind" = "Behind";
|
||||
"goal_chip_at_this_pace" = "At this pace…";
|
||||
"notification_milestone_title" = "Portfolio Milestone! 🎉";
|
||||
"notification_milestone_body" = "Your portfolio just passed %@. Congrats!";
|
||||
|
||||
"streak_protection_notification_title" = "Don't break your streak 🔥";
|
||||
"streak_protection_notification_body" = "Your monthly check-in is still pending. It takes 2 minutes to keep your streak alive.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Multiple Accounts";
|
||||
"paywall_benefit_accounts_subtitle" = "Separate portfolios for family or business";
|
||||
"paywall_benefit_family_title" = "Family Sharing";
|
||||
"paywall_benefit_family_subtitle" = "One purchase, up to 5 family members";
|
||||
"paywall_plan_annual" = "Annual";
|
||||
"paywall_plan_annual_per_year" = "%@ / year";
|
||||
"paywall_plan_lifetime" = "Lifetime";
|
||||
"paywall_plan_lifetime_badge" = "BEST VALUE";
|
||||
"paywall_trial_days" = "days";
|
||||
"paywall_trial_weeks" = "weeks";
|
||||
"paywall_trial_months" = "months";
|
||||
"paywall_trial_years" = "years";
|
||||
"paywall_trial_format" = "%d %@ free";
|
||||
|
||||
"chart_history_locked" = "%d more months of history — unlock with Premium";
|
||||
|
||||
"chart_group_overview" = "Overview";
|
||||
"chart_group_analyze" = "Analyze";
|
||||
"chart_group_risk" = "Risk";
|
||||
"chart_group_forecast" = "Forecast";
|
||||
"kpi_total_value" = "Total Value";
|
||||
"kpi_period_return" = "Period Return";
|
||||
"kpi_cagr" = "CAGR";
|
||||
"kpi_volatility" = "Volatility";
|
||||
"kpi_max_drawdown" = "Max Drawdown";
|
||||
"chart_zoom_in" = "Zoom in";
|
||||
"chart_zoom_out" = "Zoom out";
|
||||
"chart_zoom_reset" = "Reset zoom";
|
||||
|
||||
"quick_update_paste_suggestion" = "Paste %@ into %@";
|
||||
"quick_update_next_badge" = "NEXT";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "Saved %1$@ for %2$@.";
|
||||
"intent_amount_invalid" = "The amount must be greater than zero. How much?";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "Share chart";
|
||||
"chart_share_text" = "My %@ chart — tracked with Portfolio Journal";
|
||||
"chart_share_tagline" = "Private, calm investment tracking for iPhone & iPad.";
|
||||
"chart_share_scan" = "Scan to download";
|
||||
"chart_locked_title" = "%@ is a Premium chart";
|
||||
"chart_locked_cta" = "Unlock Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "%@ check-in done";
|
||||
"checkin_done_next" = "Next one: %@";
|
||||
"checkin_next_due" = "Next check-in: %@";
|
||||
"checkin_days_left" = "%d days left";
|
||||
"checkin_due_today" = "Due today";
|
||||
"checkin_overdue" = "Overdue";
|
||||
"dash_chart_yoy" = "Year vs Year";
|
||||
"home_chart_locked_sub" = "Unlock with Premium to explore this chart.";
|
||||
"checkin_sources_progress" = "%1$d of %2$d sources updated";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Last month";
|
||||
"guided_paste_amount" = "Paste %@";
|
||||
"guided_next" = "Next";
|
||||
"guided_same_as_last" = "Same as last month";
|
||||
"guided_skip" = "Skip";
|
||||
"guided_reflection_title" = "How does this month feel?";
|
||||
"guided_reflection_subtitle" = "A quick note for your future self.";
|
||||
"guided_note_placeholder" = "One honest line about this month…";
|
||||
"guided_finish" = "Finish check-in";
|
||||
"guided_done_title" = "%@ check-in done";
|
||||
"guided_streak" = "%d months in a row";
|
||||
"guided_share_month" = "Share this month";
|
||||
"guided_done_cta" = "Done";
|
||||
"journal_no_note" = "No note yet.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Version %@";
|
||||
"whats_new_150_redesign_title" = "A calmer, cleaner look";
|
||||
"whats_new_150_redesign_body" = "Redesigned Home with a clear hero, your check-in front and center, and less noise everywhere.";
|
||||
"whats_new_150_guided_title" = "Guided monthly check-in";
|
||||
"whats_new_150_guided_body" = "One source per step, live deltas, a quick reflection — your 5-minute ritual, reimagined.";
|
||||
"whats_new_150_rings_title" = "Goal rings & journal timeline";
|
||||
"whats_new_150_rings_body" = "Fitness-style progress rings for goals and a mood timeline for your investing story.";
|
||||
"whats_new_142_ocr_title" = "Update from a screenshot";
|
||||
"whats_new_142_ocr_body" = "Share a screenshot of your bank app — the balance is recognized on-device, nothing leaves your phone.";
|
||||
"whats_new_142_siri_title" = "Siri in your language";
|
||||
"whats_new_142_siri_body" = "\"Log my Indexa balance\" — record values by voice or from Shortcuts, no app needed.";
|
||||
"whats_new_142_charts_title" = "Charts, rebuilt";
|
||||
"whats_new_142_charts_body" = "Native sidebar on iPad, smart KPIs per chart, and a share button with beautiful branded images.";
|
||||
"whats_new_141_extension_title" = "Update without switching apps";
|
||||
"whats_new_141_extension_body" = "Share a value from your bank app straight into Portfolio Journal with the new share extension.";
|
||||
"whats_new_141_ipad_title" = "Better on iPad";
|
||||
"whats_new_141_ipad_body" = "Redesigned charts for the big screen, with pinch-to-zoom and value labels.";
|
||||
"whats_new_141_icloud_title" = "More in sync";
|
||||
"whats_new_141_icloud_body" = "Monthly contributions and allocation targets now sync across your devices via iCloud.";
|
||||
"whats_new_title_format" = "What's New in %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Update everything in seconds";
|
||||
"tip_quick_update_body" = "Quick Update lists all your sources in one screen — paste values from your clipboard and go.";
|
||||
"tip_ocr_title" = "Or just share a screenshot";
|
||||
"tip_ocr_body" = "From your bank app: screenshot → Share → Portfolio Journal. The balance is read on-device.";
|
||||
"tip_siri_title" = "Next time, try your voice";
|
||||
"tip_siri_body" = "Say \"Log my account balance in Portfolio Journal\" to Siri — no need to open the app.";
|
||||
"tip_chart_share_title" = "Share this chart";
|
||||
"tip_chart_share_body" = "Renders a beautiful image with your data, ready for X or WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "Some records were rejected by iCloud. This usually means the app's iCloud schema hasn't finished setting up — it often resolves within a few hours of first use, or after the next app update.";
|
||||
"icloud_hint_quota" = "Your iCloud storage is full. Free up space in iCloud settings and try again.";
|
||||
"icloud_hint_network" = "Couldn't reach iCloud. Check your connection and try again.";
|
||||
"icloud_hint_partial_generic" = "Some records couldn't sync this time. The app will retry automatically.";
|
||||
"icloud_status_error" = "iCloud sync problem";
|
||||
"icloud_status_syncing" = "Syncing with iCloud";
|
||||
"icloud_show_details" = "Show technical details";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Best year";
|
||||
"yoy_worst_year" = "Worst year";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Paste %@";
|
||||
"quick_update_scan" = "Scan";
|
||||
"quick_update_done_kbd" = "Done";
|
||||
"quick_update_scanning" = "Reading image…";
|
||||
"quick_update_pick_amount" = "Which amount?";
|
||||
"quick_update_no_amount_found" = "No amount recognized in that image.";
|
||||
"icloud_copy_diagnostics" = "Copy iCloud diagnostics";
|
||||
"icloud_diagnostics_copied" = "Diagnostics copied to clipboard";
|
||||
|
||||
"Smooth Gaps in Charts" = "Smooth Gaps in Charts";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "%d months are missing data";
|
||||
"gaps_missing_count" = "%d missing";
|
||||
"gaps_range_between" = "Between %@ and %@";
|
||||
"Fill in missing data" = "Fill in missing data";
|
||||
"Missing Data" = "Missing Data";
|
||||
"Fill Gap" = "Fill Gap";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "The chart estimates these months. Add real snapshots to keep your history accurate.";
|
||||
"Dismiss" = "Dismiss";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d mo";
|
||||
"Monthly Summary" = "Monthly Summary";
|
||||
"Portfolio Value" = "Portfolio Value";
|
||||
"Tracked with" = "Tracked with";
|
||||
"Share monthly summary" = "Share monthly summary";
|
||||
"since last check-in" = "since last check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Nothing new to import. Your data is already up to date.";
|
||||
"import_preview_sources" = "Create %d sources";
|
||||
"import_preview_snapshots_new" = "Create %d snapshots";
|
||||
"import_preview_snapshots_update" = "Update %d snapshots";
|
||||
"import_preview_categories" = "Create %d categories";
|
||||
"import_preview_goals" = "Create %d goals";
|
||||
"import_preview_journal_new" = "Create %d journal entries";
|
||||
"import_preview_journal_update" = "Update %d journal entries";
|
||||
"Review Import" = "Review Import";
|
||||
"Reviewing import…" = "Reviewing import…";
|
||||
"Import" = "Import";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimated";
|
||||
"gaps_autofill_cta" = "Auto-fill %d months";
|
||||
"Add manually" = "Add manually";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Auto Backup";
|
||||
"Off" = "Off";
|
||||
"Daily" = "Daily";
|
||||
"Weekly" = "Weekly";
|
||||
"Monthly" = "Monthly";
|
||||
"Last auto backup" = "Last auto backup";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Hide balances";
|
||||
"Show balances" = "Show balances";
|
||||
"Balance hidden" = "Balance hidden";
|
||||
"Reveal your balances" = "Reveal your balances";
|
||||
"Privacy" = "Privacy";
|
||||
"Hide Balances" = "Hide Balances";
|
||||
"Require Face ID to Reveal" = "Require Face ID to Reveal";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "You don't have any portfolio value recorded yet.";
|
||||
"intent_networth_dialog" = "Your net worth is %@.";
|
||||
"intent_streak" = "You're on a %d-month streak.";
|
||||
"intent_streak_with_date" = "You're on a %d-month streak. Your last check-in was %@.";
|
||||
"intent_streak_none" = "You don't have an active streak yet. Log a value to start one.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Custom range";
|
||||
"chart_range_from" = "From";
|
||||
"chart_range_to" = "To";
|
||||
"chart_filtered_prefix" = "Filtered: %@";
|
||||
"chart_overlay_contributions" = "vs Contributions";
|
||||
"chart_insight_best_month" = "Best month in range: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Share your progress";
|
||||
"share_percent_only" = "Only percentages";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Share";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Total return";
|
||||
"share_metric_yoy" = "This year";
|
||||
"share_metric_since_inception" = "Since inception";
|
||||
"share_cta_headline" = "Track yours — free";
|
||||
"share_cta_sub" = "Scan to get Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "As of %@";
|
||||
"share_theme_dark" = "Dark";
|
||||
"share_theme_light" = "Light";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Today's Value";
|
||||
"add_source_value_footer" = "Saved as your first check-in — your progress starts today.";
|
||||
"quick_update_add_first_source" = "Add Your First Source";
|
||||
|
||||
@@ -123,3 +123,509 @@
|
||||
// MARK: - Accessibility
|
||||
"rating_accessibility" = "Valoración %d de 5";
|
||||
"achievements_unlocked_count" = "%d de %d desbloqueados";
|
||||
|
||||
// MARK: - Satisfaction & Review Dialogs
|
||||
"checkin_enjoying_dialog_title" = "¿Cuánto disfrutas Portfolio Journal?";
|
||||
"checkin_enjoying_dialog_message" = "¡Felicidades por tu nuevo logro! Tu opinión nos ayuda a mejorar.";
|
||||
"not_now" = "Ahora no";
|
||||
"rating_1_star" = "1 estrella";
|
||||
"rating_n_stars" = "%d estrellas";
|
||||
"app_store_review_title" = "¿Te gustaría dejar una reseña en el App Store?";
|
||||
"app_store_review_message" = "Gracias por las 5 estrellas. Ayuda mucho a que otros inversores descubran la app.";
|
||||
"write_review" = "Escribir reseña";
|
||||
|
||||
// MARK: - Paywall (1.2.0)
|
||||
"Your full portfolio,\nfully clear" = "Tu cartera completa,\ntotalmente clara";
|
||||
"One payment. Every feature. Forever." = "Un pago. Todas las funciones. Para siempre.";
|
||||
"Get Full Access" = "Obtener acceso completo";
|
||||
"Restore Purchases" = "Restaurar compras";
|
||||
"Payment charged to your Apple ID account." = "El pago se cargará a tu cuenta de Apple ID.";
|
||||
"Terms" = "Términos";
|
||||
"Privacy" = "Privacidad";
|
||||
"· one-time · Family Sharing" = "· pago único · Family Sharing";
|
||||
"Full access, one payment" = "Acceso completo, un solo pago";
|
||||
"Unlimited sources, advanced charts & more" = "Fuentes ilimitadas, gráficos avanzados y más";
|
||||
"See full access" = "Ver acceso completo";
|
||||
|
||||
// Paywall benefits (1.2.0)
|
||||
"paywall_benefit_history_title" = "Tu historial completo";
|
||||
"paywall_benefit_history_subtitle" = "Cada snapshot, aportación y ganancia desde el primer día";
|
||||
"paywall_benefit_charts_title" = "Gráficos que revelan patrones";
|
||||
"paywall_benefit_charts_subtitle" = "Asignación, drawdown, rendimiento — todo en un lugar";
|
||||
"paywall_benefit_forecasts_title" = "Previsiones a 12 meses";
|
||||
"paywall_benefit_forecasts_subtitle" = "Ve a dónde se dirige tu cartera";
|
||||
"paywall_benefit_noads_title" = "Sin anuncios, nunca";
|
||||
"paywall_benefit_noads_subtitle" = "Una experiencia limpia y sin distracciones";
|
||||
|
||||
// MARK: - Onboarding pages (1.2.0)
|
||||
"onboarding_clarity_title" = "Sabe exactamente dónde estás";
|
||||
"onboarding_clarity_desc" = "Consulta tu patrimonio total, rendimientos reales y distribución — siempre actualizado.";
|
||||
"onboarding_habit_title" = "5 minutos al mes son suficientes";
|
||||
"onboarding_habit_desc" = "Registra tus valores una vez al mes. Portfolio Journal hace los cálculos y muestra tu progreso.";
|
||||
"onboarding_calm_title" = "Ignora el ruido. Sigue la tendencia.";
|
||||
"onboarding_calm_desc" = "Las variaciones diarias no cuentan la historia real. Tu crecimiento en meses y años sí.";
|
||||
"onboarding_goals_title" = "Alcanza tus metas financieras";
|
||||
"onboarding_goals_desc" = "Define objetivos, sigue los hitos y ve exactamente hasta dónde has llegado.";
|
||||
|
||||
// iCloud check antes del onboarding (1.2.1)
|
||||
"icloud_check_title" = "¿Ya usas Portfolio Journal?";
|
||||
"icloud_check_description" = "Si tienes datos en otro dispositivo, activa iCloud para restaurarlos aquí.";
|
||||
"icloud_enabled_title" = "iCloud activado";
|
||||
"icloud_enabled_description" = "Cierra la app y ábrela de nuevo. Tus datos se cargarán automáticamente desde iCloud.";
|
||||
"checking_icloud" = "Comprobando iCloud...";
|
||||
// Literales de botones (SwiftUI auto-lookup)
|
||||
"Restore from iCloud" = "Restaurar desde iCloud";
|
||||
"Start Fresh" = "Empezar desde cero";
|
||||
"Got it" = "Entendido";
|
||||
|
||||
// MARK: - Batch Update (1.2.0)
|
||||
"Batch Update" = "Actualización en bloque";
|
||||
"Current value" = "Valor actual";
|
||||
"Contribution this period (optional)" = "Aportación en este periodo (opcional)";
|
||||
"save_1_snapshot" = "Guardar 1 snapshot";
|
||||
"save_n_snapshots" = "Guardar %d snapshots";
|
||||
|
||||
// MARK: - Monthly Check-in Card
|
||||
"checkin_update_month" = "Actualizar %@";
|
||||
"checkin_start_new" = "Iniciar";
|
||||
|
||||
// MARK: - Contribution (1.2.0)
|
||||
"Include Contribution" = "Incluir aportación";
|
||||
"New capital added" = "Capital nuevo añadido";
|
||||
"Contribution (Optional)" = "Aportación (opcional)";
|
||||
"Track new capital added to separate it from investment growth." = "Registra el capital nuevo para separarlo del crecimiento de la inversión.";
|
||||
|
||||
// MARK: - Monthly Highlights (pre-existing gaps)
|
||||
"Monthly Highlights" = "Aspectos destacados del mes";
|
||||
"Best Performer" = "Mejor rendimiento";
|
||||
"Worst Performer" = "Peor rendimiento";
|
||||
"Best Contributor" = "Mayor aportador";
|
||||
"Update Check-in" = "Actualizar chequeo";
|
||||
"Completed %@" = "Completado %@";
|
||||
|
||||
// MARK: - Paywall literals (1.2.1)
|
||||
"Your full portfolio,\nfully clear" = "Tu portfolio completo,\nen todo su potencial";
|
||||
"One payment. Every feature. Forever." = "Un solo pago. Todas las funciones. Para siempre.";
|
||||
"Get Full Access" = "Obtener acceso completo";
|
||||
"Restore Purchases" = "Restaurar compras";
|
||||
"Payment charged to your Apple ID account." = "El cobro se realizará a tu cuenta de Apple ID.";
|
||||
|
||||
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
|
||||
"reengagement_title" = "Tu portfolio te espera";
|
||||
"reengagement_body" = "Unos minutos son suficientes para mantener el control de tus inversiones.";
|
||||
"reengagement_goal_title" = "Vas por buen camino 📈";
|
||||
"reengagement_goal_body" = "A este ritmo alcanzarás \"%@\" en %@. Mira lo cerca que estás.";
|
||||
"reengagement_yoy_title" = "Menudo crecimiento 👀";
|
||||
"reengagement_yoy_body" = "Llevas un %@ en el último año. Mira la tendencia.";
|
||||
"reengagement_month_title" = "Buen mes 📈";
|
||||
"reengagement_month_body" = "Tu cartera creció %@ este mes. Échale un ojo.";
|
||||
"reengagement_milestone_title" = "Muy cerca 🎯";
|
||||
"reengagement_milestone_body" = "Estás a punto de llegar a %@. ¿Cuánto te falta hoy?";
|
||||
"reengagement_streak_title" = "No pierdas la racha 🔥";
|
||||
"reengagement_streak_body" = "Llevas %d meses seguidos registrando. No pares ahora.";
|
||||
"reengagement_neutral_title" = "¿Algo nuevo? 👀";
|
||||
"reengagement_neutral_body" = "Ha habido movimiento en tu cartera. Mira qué ha cambiado.";
|
||||
"monthly_checkin_notification_title" = "Ya puedes actualizar tu portfolio";
|
||||
"monthly_checkin_notification_body" = "Registra los valores de este mes y sigue la evolución de tu cartera.";
|
||||
|
||||
// MARK: - Onboarding QuickStart (1.3.0)
|
||||
"onboarding_quickstart_title" = "Ya casi está";
|
||||
"onboarding_quickstart_subtitle" = "Añade tu primera fuente de inversión para empezar a seguir tu portfolio.";
|
||||
"onboarding_add_first_source" = "Añadir mi primera inversión";
|
||||
"onboarding_import_data" = "Importar datos existentes";
|
||||
|
||||
// MARK: - iCloud Sync Status (1.3.1)
|
||||
"Force Upload to iCloud" = "Forzar subida a iCloud";
|
||||
"Uploading..." = "Subiendo...";
|
||||
"No export yet" = "Aún no exportado";
|
||||
"Syncing with iCloud..." = "Sincronizando con iCloud...";
|
||||
"Not synced yet" = "Aún no sincronizado";
|
||||
"Refresh" = "Actualizar";
|
||||
|
||||
// MARK: - iCloud Diagnostics (1.3.1)
|
||||
"Verify iCloud Setup" = "Verificar configuración iCloud";
|
||||
"Checking…" = "Comprobando…";
|
||||
|
||||
// MARK: - Sources Filter & Search (1.3.1)
|
||||
"sources_filter_all" = "Todas";
|
||||
"Search sources" = "Buscar fuentes";
|
||||
"Clear Filters" = "Limpiar filtros";
|
||||
|
||||
// MARK: - AddSourceView (1.3.0)
|
||||
"add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso...";
|
||||
"add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más.";
|
||||
|
||||
// MARK: - Goals Archive (1.3.2)
|
||||
"goals_filter_active" = "Activos";
|
||||
"goals_filter_archived" = "Archivados";
|
||||
"goals_filter_all" = "Todos";
|
||||
"goal_archive" = "Archivar";
|
||||
"goal_unarchive" = "Restaurar";
|
||||
"goals_empty_archived" = "Sin objetivos archivados.";
|
||||
"goals_all_active_achieved" = "Todos los objetivos activos están logrados.";
|
||||
|
||||
// MARK: - Category Management (1.3.2)
|
||||
"category_name_placeholder" = "p.ej. Fondo de emergencia";
|
||||
"category_has_sources_warning" = "No se puede eliminar una categoría que tiene fuentes. Elimina o reasigna todas las fuentes primero.";
|
||||
"categories_empty" = "Sin categorías aún.";
|
||||
|
||||
// MARK: - CSV Column Mapping (1.3.2)
|
||||
"csv_mapping_subtitle" = "Relaciona tus columnas CSV con los campos de Portfolio Journal";
|
||||
"csv_field_source" = "Nombre de fuente (obligatorio)";
|
||||
"csv_field_value" = "Valor (obligatorio)";
|
||||
"csv_field_date" = "Fecha";
|
||||
"csv_field_category" = "Categoría";
|
||||
"csv_field_contribution" = "Aportación";
|
||||
"csv_field_notes" = "Notas";
|
||||
"csv_no_column" = "— Sin mapear —";
|
||||
"csv_enter_value" = "Valor fijo";
|
||||
"csv_use_today" = "Fecha de hoy";
|
||||
"csv_no_date_hint" = "Todas las filas se importarán como un snapshot de hoy.";
|
||||
"csv_preview_section" = "Vista previa CSV";
|
||||
"csv_required_section" = "Campos obligatorios";
|
||||
"csv_optional_section" = "Campos opcionales";
|
||||
|
||||
// MARK: - Category Management SwiftUI literals (1.3.2)
|
||||
"Categories" = "Categorías";
|
||||
"Add Category" = "Añadir categoría";
|
||||
"Edit Category" = "Editar categoría";
|
||||
"Category Name" = "Nombre de categoría";
|
||||
"Color" = "Color";
|
||||
"Icon" = "Icono";
|
||||
"Map Columns" = "Mapear columnas";
|
||||
"Has Header Row" = "Tiene fila de encabezado";
|
||||
"Required" = "Obligatorios";
|
||||
"Optional" = "Opcionales";
|
||||
"Preview" = "Vista previa";
|
||||
"Any CSV" = "Cualquier CSV";
|
||||
|
||||
// MARK: - 1.3.2 Features
|
||||
"goal_achieved_notification_title" = "¡Objetivo alcanzado! 🎉";
|
||||
"goal_achieved_notification_body" = "Has alcanzado tu objetivo: %@";
|
||||
|
||||
"snapshot_duplicate_title" = "Ya existe un snapshot";
|
||||
"snapshot_duplicate_message" = "Ya tienes un snapshot para este mes. ¿Quieres añadir otro?";
|
||||
"snapshot_duplicate_replace" = "Reemplazar existente";
|
||||
"snapshot_duplicate_add" = "Añadir de todos modos";
|
||||
"snapshot_contribution_propagate_title" = "Aplicar aportación";
|
||||
"snapshot_contribution_propagate_message" = "¿Aplicar %@ como aportación también a otros registros?";
|
||||
"snapshot_contribution_propagate_forward" = "Aplicar hacia adelante";
|
||||
"snapshot_contribution_propagate_backward" = "Aplicar hacia atrás";
|
||||
"snapshot_contribution_propagate_all" = "Aplicar a todos";
|
||||
"snapshot_contribution_propagate_this" = "Solo este registro";
|
||||
|
||||
"contributions_vs_returns_title" = "Invertido vs. Rentabilidad";
|
||||
"contributions_vs_returns_invested" = "Invertido";
|
||||
"contributions_vs_returns_returns" = "Rentabilidad de mercado";
|
||||
|
||||
"chart_yoy_title" = "Año a año";
|
||||
"chart_yoy_estimated_note" = "* estimado (previsión de cierre de año)";
|
||||
"chart_yoy_empty" = "Añade más snapshots en diferentes años para ver la comparación.";
|
||||
|
||||
"Year vs Year" = "Año a año";
|
||||
|
||||
// MARK: - Goal Delete Confirmation (1.3.2)
|
||||
"goal_delete_title" = "Eliminar objetivo";
|
||||
"goal_delete_message" = "Este objetivo se eliminará permanentemente. Esta acción no se puede deshacer.";
|
||||
"goal_delete_confirm" = "Eliminar";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "Tu resumen mensual del portfolio";
|
||||
"monthly_summary_notification_body" = "Comprueba cómo han rendido tus inversiones este mes.";
|
||||
"streak_badge" = "Racha de %d meses";
|
||||
"goals_empty_add_cta" = "Añadir mi primer objetivo";
|
||||
"journal_empty_title" = "Sin registros aún";
|
||||
"journal_empty_body" = "Las actualizaciones de snapshots crean entradas mensuales automáticamente. Añade tu primera fuente para empezar.";
|
||||
"quick_update_title" = "Actualización rápida";
|
||||
"quick_update_section_header" = "Introduce los valores actuales";
|
||||
"quick_update_section_footer" = "Solo se actualizarán las fuentes con un valor introducido.";
|
||||
"quick_update_placeholder" = "Nuevo valor";
|
||||
"quick_update_save" = "Guardar todo";
|
||||
"quick_update_no_sources" = "Sin fuentes";
|
||||
"quick_update_no_sources_body" = "Añade fuentes de inversión primero para usar la actualización rápida.";
|
||||
"update_available_title" = "Actualización disponible";
|
||||
"update_available_body" = "La versión %@ está disponible en el App Store.";
|
||||
"whats_new_title" = "Novedades en 1.4";
|
||||
"whats_new_subtitle" = "Mejoras para ayudarte a mantener el rumbo.";
|
||||
"whats_new_quick_update_title" = "Actualización rápida del portfolio";
|
||||
"whats_new_quick_update_body" = "Actualiza todas tus fuentes desde una sola pantalla con un toque.";
|
||||
"whats_new_streak_title" = "Racha de actualizaciones";
|
||||
"whats_new_streak_body" = "Sigue cuántos meses consecutivos has mantenido tu portfolio al día.";
|
||||
"whats_new_goals_title" = "Objetivos y diario más inteligentes";
|
||||
"whats_new_goals_body" = "Los estados vacíos mejorados te ayudan a empezar más rápido.";
|
||||
"whats_new_continue" = "¡Vamos!";
|
||||
"goal_delete_confirm" = "Eliminar";
|
||||
"source_monthly_contribution_title" = "Aportación Mensual";
|
||||
"source_monthly_contribution_placeholder" = "ej. 500";
|
||||
"source_monthly_contribution_not_set" = "No configurada";
|
||||
"source_monthly_contribution_hint" = "Se rellena automáticamente en Actualización Rápida";
|
||||
"quick_update_contribution_label" = "Aportación";
|
||||
"quick_update_contribution_placeholder" = "Importe";
|
||||
"source_monthly_contribution_apply_title" = "Aplicar Aportación";
|
||||
"source_monthly_contribution_apply_message" = "¿Aplicar %@ a todos los snapshots anteriores sin aportación?";
|
||||
"source_monthly_contribution_apply_retroactive" = "Aplicar a Todos los Históricos";
|
||||
"source_monthly_contribution_apply_forward" = "Solo a Partir de Ahora";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "Perspectivas";
|
||||
"insight_milestone_title" = "Hito cerca";
|
||||
"insight_milestone_value" = "%1$@ para %2$@";
|
||||
"insight_ytd_title" = "Año en curso";
|
||||
"insight_market_gains_title" = "Ganancias de mercado";
|
||||
"insight_market_gains_value" = "+%@ en mercados";
|
||||
"insight_streak_title" = "Racha de seguimiento";
|
||||
"insight_streak_value" = "%d meses consecutivos";
|
||||
"insight_forecast_title" = "En camino hacia";
|
||||
"insight_allocation_title" = "Desvío de asignación";
|
||||
"insight_allocation_over_value" = "%1$@ está al %2$.0f%% — por encima de tu objetivo del %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ está al %2$.0f%% — por debajo de tu objetivo del %3$.0f%%";
|
||||
"insight_allocation_detail" = "Reequilibra para seguir tu plan";
|
||||
"insight_concentration_title" = "Riesgo de concentración";
|
||||
"insight_concentration_value" = "%1$@ es el %2$.0f%% de tu cartera";
|
||||
"insight_concentration_detail" = "Plantéate diversificar";
|
||||
"insight_best_performer_title" = "Mejor rendimiento";
|
||||
"insight_worst_performer_title" = "Requiere atención";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversificación";
|
||||
"insight_diversification_single_value" = "Todo en una fuente — considera añadir otra";
|
||||
"insight_diversification_good_value" = "Repartido en %d categorías";
|
||||
"goal_projection_projected" = "Previsión: %@";
|
||||
"goal_projection_add_contributions" = "Añade aportaciones para alcanzarlo";
|
||||
"goal_chip_ahead" = "Por delante";
|
||||
"goal_chip_on_track" = "En camino";
|
||||
"goal_chip_behind" = "Con retraso";
|
||||
"goal_chip_at_this_pace" = "A este ritmo…";
|
||||
"notification_milestone_title" = "¡Hito de cartera! 🎉";
|
||||
"notification_milestone_body" = "Tu cartera acaba de superar %@. ¡Enhorabuena!";
|
||||
|
||||
"streak_protection_notification_title" = "No rompas tu racha 🔥";
|
||||
"streak_protection_notification_body" = "Aún tienes pendiente el check-in de este mes. Son 2 minutos y tu racha sigue viva.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Varias cuentas";
|
||||
"paywall_benefit_accounts_subtitle" = "Carteras separadas para familia o negocio";
|
||||
"paywall_benefit_family_title" = "En familia";
|
||||
"paywall_benefit_family_subtitle" = "Una compra, hasta 5 miembros de la familia";
|
||||
"paywall_plan_annual" = "Anual";
|
||||
"paywall_plan_annual_per_year" = "%@ / año";
|
||||
"paywall_plan_lifetime" = "Para siempre";
|
||||
"paywall_plan_lifetime_badge" = "MEJOR VALOR";
|
||||
"paywall_trial_days" = "días";
|
||||
"paywall_trial_weeks" = "semanas";
|
||||
"paywall_trial_months" = "meses";
|
||||
"paywall_trial_years" = "años";
|
||||
"paywall_trial_format" = "%d %@ gratis";
|
||||
|
||||
"chart_history_locked" = "%d meses más de historial — desbloquéalos con Premium";
|
||||
|
||||
"chart_group_overview" = "Resumen";
|
||||
"chart_group_analyze" = "Análisis";
|
||||
"chart_group_risk" = "Riesgo";
|
||||
"chart_group_forecast" = "Previsión";
|
||||
"kpi_total_value" = "Valor total";
|
||||
"kpi_period_return" = "Retorno periodo";
|
||||
"kpi_cagr" = "CAGR";
|
||||
"kpi_volatility" = "Volatilidad";
|
||||
"kpi_max_drawdown" = "Caída máx.";
|
||||
"chart_zoom_in" = "Acercar";
|
||||
"chart_zoom_out" = "Alejar";
|
||||
"chart_zoom_reset" = "Restablecer zoom";
|
||||
|
||||
"quick_update_paste_suggestion" = "Pegar %@ en %@";
|
||||
"quick_update_next_badge" = "SIGUIENTE";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "Guardado %1$@ para %2$@.";
|
||||
"intent_amount_invalid" = "El importe debe ser mayor que cero. ¿Cuánto?";
|
||||
"Log Portfolio Value" = "Registrar valor de cartera";
|
||||
"Record a value for one of your sources without opening the app." = "Registra un valor para una de tus fuentes sin abrir la app.";
|
||||
"Amount" = "Importe";
|
||||
"How much?" = "¿Cuánto?";
|
||||
"Log Value" = "Registrar valor";
|
||||
"Update Portfolio" = "Actualizar cartera";
|
||||
"Check Portfolio" = "Consultar cartera";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "Compartir gráfica";
|
||||
"chart_share_text" = "Mi gráfica de %@ — hecha con Portfolio Journal";
|
||||
"chart_share_tagline" = "Seguimiento de inversiones privado y tranquilo para iPhone y iPad.";
|
||||
"chart_share_scan" = "Escanea para descargar";
|
||||
"chart_locked_title" = "%@ es una gráfica Premium";
|
||||
"chart_locked_cta" = "Desbloquear Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in de %@ completado";
|
||||
"checkin_done_next" = "Próximo: %@";
|
||||
"checkin_next_due" = "Próximo check-in: %@";
|
||||
"checkin_days_left" = "faltan %d días";
|
||||
"checkin_due_today" = "Hoy";
|
||||
"checkin_overdue" = "Atrasado";
|
||||
"dash_chart_yoy" = "Año vs Año";
|
||||
"home_chart_locked_sub" = "Desbloquéalo con Premium para ver este gráfico.";
|
||||
"checkin_sources_progress" = "%1$d de %2$d fuentes actualizadas";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mes anterior";
|
||||
"guided_paste_amount" = "Pegar %@";
|
||||
"guided_next" = "Siguiente";
|
||||
"guided_same_as_last" = "Igual que el mes pasado";
|
||||
"guided_skip" = "Omitir";
|
||||
"guided_reflection_title" = "¿Cómo sientes este mes?";
|
||||
"guided_reflection_subtitle" = "Una nota rápida para tu yo del futuro.";
|
||||
"guided_note_placeholder" = "Una línea honesta sobre este mes…";
|
||||
"guided_finish" = "Terminar check-in";
|
||||
"guided_done_title" = "Check-in de %@ completado";
|
||||
"guided_streak" = "%d meses seguidos";
|
||||
"guided_share_month" = "Compartir este mes";
|
||||
"guided_done_cta" = "Hecho";
|
||||
"journal_no_note" = "Sin nota todavía.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Versión %@";
|
||||
"whats_new_150_redesign_title" = "Un look más limpio y tranquilo";
|
||||
"whats_new_150_redesign_body" = "Home rediseñado con tu valor total protagonista, el check-in en el centro y menos ruido en todas partes.";
|
||||
"whats_new_150_guided_title" = "Check-in mensual guiado";
|
||||
"whats_new_150_guided_body" = "Una fuente por paso, deltas en vivo y una reflexión rápida — tu ritual de 5 minutos, reimaginado.";
|
||||
"whats_new_150_rings_title" = "Anillos de metas y timeline";
|
||||
"whats_new_150_rings_body" = "Anillos de progreso estilo Fitness para tus metas y un timeline con tu historia como inversor.";
|
||||
"whats_new_142_ocr_title" = "Actualiza desde un pantallazo";
|
||||
"whats_new_142_ocr_body" = "Comparte una captura de tu banco — el saldo se reconoce en el dispositivo, nada sale de tu iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri en tu idioma";
|
||||
"whats_new_142_siri_body" = "\"Registra el saldo de Indexa\" — apunta valores por voz o desde Atajos, sin abrir la app.";
|
||||
"whats_new_142_charts_title" = "Gráficas renovadas";
|
||||
"whats_new_142_charts_body" = "Sidebar nativa en iPad, KPIs inteligentes por gráfica y botón de compartir con imágenes con estilo.";
|
||||
"whats_new_141_extension_title" = "Actualiza sin cambiar de app";
|
||||
"whats_new_141_extension_body" = "Comparte un valor desde la app de tu banco directo a Portfolio Journal con la nueva extensión.";
|
||||
"whats_new_141_ipad_title" = "Mejor en iPad";
|
||||
"whats_new_141_ipad_body" = "Gráficas rediseñadas para pantalla grande, con zoom y etiquetas de valor.";
|
||||
"whats_new_141_icloud_title" = "Más sincronizado";
|
||||
"whats_new_141_icloud_body" = "Las aportaciones mensuales y los objetivos de asignación ahora se sincronizan vía iCloud.";
|
||||
"whats_new_title_format" = "Novedades de la %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Actualiza todo en segundos";
|
||||
"tip_quick_update_body" = "Quick Update lista todas tus fuentes en una pantalla — pega valores del portapapeles y listo.";
|
||||
"tip_ocr_title" = "O comparte un pantallazo";
|
||||
"tip_ocr_body" = "Desde tu app del banco: captura → Compartir → Portfolio Journal. El saldo se lee en el dispositivo.";
|
||||
"tip_siri_title" = "La próxima vez, prueba con la voz";
|
||||
"tip_siri_body" = "Dile a Siri \"Registra el saldo de mi cuenta en Portfolio Journal\" — sin abrir la app.";
|
||||
"tip_chart_share_title" = "Comparte esta gráfica";
|
||||
"tip_chart_share_body" = "Genera una imagen bonita con tus datos, lista para X o WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud rechazó algunos registros. Suele significar que el esquema de iCloud aún se está configurando — normalmente se resuelve en unas horas de uso, o tras la próxima actualización de la app.";
|
||||
"icloud_hint_quota" = "Tu almacenamiento de iCloud está lleno. Libera espacio en ajustes de iCloud e inténtalo de nuevo.";
|
||||
"icloud_hint_network" = "No se pudo conectar con iCloud. Revisa tu conexión e inténtalo de nuevo.";
|
||||
"icloud_hint_partial_generic" = "Algunos registros no se sincronizaron esta vez. La app lo reintentará automáticamente.";
|
||||
"icloud_status_error" = "Problema de sincronización con iCloud";
|
||||
"icloud_status_syncing" = "Sincronizando con iCloud";
|
||||
"icloud_show_details" = "Ver detalles técnicos";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Mejor año";
|
||||
"yoy_worst_year" = "Peor año";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Pegar %@";
|
||||
"quick_update_scan" = "Escanear";
|
||||
"quick_update_done_kbd" = "Listo";
|
||||
"quick_update_scanning" = "Leyendo imagen…";
|
||||
"quick_update_pick_amount" = "¿Qué importe?";
|
||||
"quick_update_no_amount_found" = "No se reconoció ningún importe en esa imagen.";
|
||||
"icloud_copy_diagnostics" = "Copiar diagnóstico de iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnóstico copiado al portapapeles";
|
||||
|
||||
"Smooth Gaps in Charts" = "Suavizar huecos en gráficas";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Faltan datos de %d meses";
|
||||
"gaps_missing_count" = "%d sin datos";
|
||||
"gaps_range_between" = "Entre %@ y %@";
|
||||
"Fill in missing data" = "Completa los datos que faltan";
|
||||
"Missing Data" = "Datos faltantes";
|
||||
"Fill Gap" = "Rellenar hueco";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "La gráfica estima estos meses. Añade valores reales para mantener tu historial preciso.";
|
||||
"Dismiss" = "Descartar";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d m";
|
||||
"Monthly Summary" = "Resumen mensual";
|
||||
"Portfolio Value" = "Valor de la cartera";
|
||||
"Tracked with" = "Registrado con";
|
||||
"Share monthly summary" = "Compartir resumen mensual";
|
||||
"since last check-in" = "desde el último chequeo";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "No hay nada nuevo que importar. Tus datos ya están actualizados.";
|
||||
"import_preview_sources" = "Crear %d fuentes";
|
||||
"import_preview_snapshots_new" = "Crear %d registros";
|
||||
"import_preview_snapshots_update" = "Actualizar %d registros";
|
||||
"import_preview_categories" = "Crear %d categorías";
|
||||
"import_preview_goals" = "Crear %d objetivos";
|
||||
"import_preview_journal_new" = "Crear %d entradas del diario";
|
||||
"import_preview_journal_update" = "Actualizar %d entradas del diario";
|
||||
"Review Import" = "Revisar importación";
|
||||
"Reviewing import…" = "Revisando importación…";
|
||||
"Import" = "Importar";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimado";
|
||||
"gaps_autofill_cta" = "Autocompletar %d meses";
|
||||
"Add manually" = "Añadir manualmente";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "El autocompletado estima los meses que faltan entre dos snapshots (marcados como estimados). O añade los valores reales tú mismo.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Copia automática";
|
||||
"Off" = "Desactivado";
|
||||
"Daily" = "Diario";
|
||||
"Weekly" = "Semanal";
|
||||
"Monthly" = "Mensual";
|
||||
"Last auto backup" = "Última copia automática";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Ocultar saldos";
|
||||
"Show balances" = "Mostrar saldos";
|
||||
"Balance hidden" = "Saldo oculto";
|
||||
"Reveal your balances" = "Mostrar tus saldos";
|
||||
"Privacy" = "Privacidad";
|
||||
"Hide Balances" = "Ocultar saldos";
|
||||
"Require Face ID to Reveal" = "Requerir Face ID para mostrar";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere Face ID para mostrarlas.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Aún no tienes ningún valor de cartera registrado.";
|
||||
"intent_networth_dialog" = "Tu patrimonio es %@.";
|
||||
"intent_streak" = "Llevas una racha de %d meses.";
|
||||
"intent_streak_with_date" = "Llevas una racha de %d meses. Tu último check-in fue el %@.";
|
||||
"intent_streak_none" = "Aún no tienes una racha activa. Registra un valor para empezar una.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Rango personalizado";
|
||||
"chart_range_from" = "Desde";
|
||||
"chart_range_to" = "Hasta";
|
||||
"chart_filtered_prefix" = "Filtrado: %@";
|
||||
"chart_overlay_contributions" = "vs Aportaciones";
|
||||
"chart_insight_best_month" = "Mejor mes del rango: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Comparte tu progreso";
|
||||
"share_percent_only" = "Solo porcentajes";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Historia";
|
||||
"share_card_cta" = "Compartir";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Rentabilidad total";
|
||||
"share_metric_yoy" = "Este año";
|
||||
"share_metric_since_inception" = "Desde el inicio";
|
||||
"share_cta_headline" = "Controla la tuya — gratis";
|
||||
"share_cta_sub" = "Escanea para descargar Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "A %@";
|
||||
"share_theme_dark" = "Oscuro";
|
||||
"share_theme_light" = "Claro";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valor de hoy";
|
||||
"add_source_value_footer" = "Se guarda como tu primer check-in: tu progreso empieza hoy.";
|
||||
"quick_update_add_first_source" = "Añade tu primera fuente";
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
"app_name" = "Portfolio Journal";
|
||||
"ok" = "OK";
|
||||
"cancel" = "Annuler";
|
||||
"save" = "Enregistrer";
|
||||
"delete" = "Supprimer";
|
||||
"edit" = "Modifier";
|
||||
"add" = "Ajouter";
|
||||
"done" = "Terminé";
|
||||
"close" = "Fermer";
|
||||
"continue" = "Continuer";
|
||||
"skip" = "Passer";
|
||||
"error" = "Erreur";
|
||||
"success" = "Succès";
|
||||
"loading" = "Chargement...";
|
||||
"loading_data" = "Chargement de vos données...";
|
||||
"tab_dashboard" = "Accueil";
|
||||
"tab_sources" = "Sources";
|
||||
"tab_charts" = "Graphiques";
|
||||
"tab_settings" = "Réglages";
|
||||
"dashboard_title" = "Accueil";
|
||||
"total_portfolio_value" = "Valeur totale du portefeuille";
|
||||
"today" = "aujourd'hui";
|
||||
"returns" = "Rendements";
|
||||
"by_category" = "Par catégorie";
|
||||
"pending_updates" = "Mises à jour en attente";
|
||||
"see_all" = "Voir tout";
|
||||
"sources_title" = "Sources";
|
||||
"add_source" = "Ajouter une source";
|
||||
"source_name" = "Nom de la source";
|
||||
"select_category" = "Sélectionner une catégorie";
|
||||
"initial_value" = "Valeur initiale";
|
||||
"initial_value_optional" = "Valeur initiale (optionnelle)";
|
||||
"reminder_frequency" = "Fréquence des rappels";
|
||||
"source_limit_warning" = "Limite de sources atteinte. Passez à Premium pour des sources illimitées.";
|
||||
"no_sources" = "Aucune source d'investissement";
|
||||
"no_sources_message" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
|
||||
"add_snapshot" = "Ajouter un snapshot";
|
||||
"edit_snapshot" = "Modifier le snapshot";
|
||||
"snapshot_date" = "Date";
|
||||
"snapshot_value" = "Valeur";
|
||||
"snapshot_contribution" = "Contribution";
|
||||
"contribution_optional" = "Contribution (optionnelle)";
|
||||
"notes" = "Notes";
|
||||
"notes_optional" = "Notes (optionnelles)";
|
||||
"previous_value" = "Précédent : %@";
|
||||
"change_from_previous" = "Variation par rapport au précédent";
|
||||
"charts_title" = "Graphiques";
|
||||
"evolution" = "Évolution";
|
||||
"allocation" = "Allocation";
|
||||
"performance" = "Performance";
|
||||
"drawdown" = "Drawdown";
|
||||
"volatility" = "Volatilité";
|
||||
"prediction" = "Prévision";
|
||||
"portfolio_evolution" = "Évolution du portefeuille";
|
||||
"asset_allocation" = "Allocation des actifs";
|
||||
"performance_by_category" = "Performance par catégorie";
|
||||
"drawdown_analysis" = "Analyse du drawdown";
|
||||
"prediction_12_month" = "Prévision sur 12 mois";
|
||||
"not_enough_data" = "Pas assez de données";
|
||||
"cagr" = "CAGR";
|
||||
"twr" = "TWR";
|
||||
"max_drawdown" = "Drawdown max";
|
||||
"sharpe_ratio" = "Ratio de Sharpe";
|
||||
"win_rate" = "Taux de réussite";
|
||||
"avg_monthly" = "Moyenne mensuelle";
|
||||
"best_month" = "Meilleur mois";
|
||||
"worst_month" = "Pire mois";
|
||||
"premium" = "Premium";
|
||||
"upgrade_to_premium" = "Passer à Premium";
|
||||
"unlock_full_potential" = "Débloquez tout le potentiel";
|
||||
"one_time_purchase" = "Achat unique";
|
||||
"includes_family_sharing" = "Inclut le partage familial";
|
||||
"upgrade_now" = "Passer maintenant";
|
||||
"restore_purchases" = "Restaurer les achats";
|
||||
"premium_active" = "Premium actif";
|
||||
"premium_feature" = "Fonction Premium";
|
||||
"unlock" = "Débloquer";
|
||||
"feature_unlimited_sources" = "Sources illimitées";
|
||||
"feature_unlimited_sources_desc" = "Suivez autant d'investissements que vous le souhaitez";
|
||||
"feature_full_history" = "Historique complet";
|
||||
"feature_full_history_desc" = "Accédez à tout votre historique d'investissement";
|
||||
"feature_advanced_charts" = "Graphiques avancés";
|
||||
"feature_advanced_charts_desc" = "5 types de graphiques analytiques détaillés";
|
||||
"feature_predictions" = "Prévisions";
|
||||
"feature_predictions_desc" = "Prévisions IA sur 12 mois";
|
||||
"feature_export" = "Exporter les données";
|
||||
"feature_export_desc" = "Export vers CSV et JSON";
|
||||
"feature_no_ads" = "Sans publicité";
|
||||
"feature_no_ads_desc" = "Expérience sans publicité à vie";
|
||||
"paywall_benefit_history_title" = "Votre historique complet";
|
||||
"paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour";
|
||||
"paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances";
|
||||
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit";
|
||||
"paywall_benefit_forecasts_title" = "Prévisions à 12 mois";
|
||||
"paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller";
|
||||
"paywall_benefit_noads_title" = "Aucune pub, jamais";
|
||||
"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions";
|
||||
"settings_title" = "Réglages";
|
||||
"subscription" = "Abonnement";
|
||||
"notifications" = "Notifications";
|
||||
"default_reminder_time" = "Heure de rappel par défaut";
|
||||
"data" = "Données";
|
||||
"export_data" = "Exporter les données";
|
||||
"total_sources" = "Nombre total de sources";
|
||||
"total_snapshots" = "Nombre total de snapshots";
|
||||
"storage_used" = "Stockage utilisé";
|
||||
"about" = "À propos";
|
||||
"version" = "Version";
|
||||
"privacy_policy" = "Politique de confidentialité";
|
||||
"terms_of_service" = "Conditions d'utilisation";
|
||||
"support" = "Support";
|
||||
"rate_app" = "Noter l'app";
|
||||
"danger_zone" = "Zone de danger";
|
||||
"reset_all_data" = "Réinitialiser toutes les données";
|
||||
"reset_confirmation" = "Cela supprimera définitivement toutes vos données d'investissement. Cette action est irréversible.";
|
||||
"frequency_monthly" = "Mensuel";
|
||||
"frequency_quarterly" = "Trimestriel";
|
||||
"frequency_semiannual" = "Semestriel";
|
||||
"frequency_annual" = "Annuel";
|
||||
"frequency_custom" = "Personnalisé";
|
||||
"frequency_never" = "Jamais";
|
||||
"every_n_months" = "Tous les %d mois";
|
||||
"category_stocks" = "Actions";
|
||||
"category_bonds" = "Obligations";
|
||||
"category_real_estate" = "Immobilier";
|
||||
"category_crypto" = "Crypto";
|
||||
"category_cash" = "Espèces";
|
||||
"category_etfs" = "ETF";
|
||||
"category_retirement" = "Retraite";
|
||||
"category_other" = "Autre";
|
||||
"uncategorized" = "Non catégorisé";
|
||||
"time_1m" = "1M";
|
||||
"time_3m" = "3M";
|
||||
"time_6m" = "6M";
|
||||
"time_1y" = "1A";
|
||||
"time_all" = "Tout";
|
||||
"export_format" = "Sélectionner le format";
|
||||
"export_csv" = "CSV";
|
||||
"export_csv_desc" = "Compatible avec Excel et Google Sheets";
|
||||
"export_json" = "JSON";
|
||||
"export_json_desc" = "Structure complète pour sauvegarde";
|
||||
"onboarding_track_title" = "Suivez vos investissements";
|
||||
"onboarding_track_desc" = "Surveillez toutes vos sources d'investissement au même endroit. Actions, obligations, immobilier, crypto et plus.";
|
||||
"onboarding_visualize_title" = "Visualisez votre croissance";
|
||||
"onboarding_visualize_desc" = "De beaux graphiques montrent l'évolution, l'allocation et la performance de votre portefeuille dans le temps.";
|
||||
"onboarding_reminders_title" = "Ne manquez jamais une mise à jour";
|
||||
"onboarding_reminders_desc" = "Définissez des rappels pour suivre régulièrement vos investissements. Mensuels, trimestriels ou personnalisés.";
|
||||
"onboarding_sync_title" = "Synchronisez partout";
|
||||
"onboarding_sync_desc" = "Vos données se synchronisent automatiquement via iCloud sur tous vos appareils Apple.";
|
||||
"get_started" = "Commencer";
|
||||
"onboarding_clarity_title" = "Sachez exactement où vous en êtes";
|
||||
"onboarding_clarity_desc" = "Voyez votre patrimoine total, vos rendements réels et votre allocation, toujours à jour.";
|
||||
"onboarding_habit_title" = "5 minutes par mois suffisent";
|
||||
"onboarding_habit_desc" = "Saisissez vos valeurs une fois par mois. Portfolio Journal s'occupe des calculs et montre vos progrès.";
|
||||
"onboarding_calm_title" = "Ignorez le bruit. Suivez la tendance.";
|
||||
"onboarding_calm_desc" = "Les variations quotidiennes ne racontent pas la vraie histoire. Votre croissance sur des mois et des années, si.";
|
||||
"onboarding_goals_title" = "Atteignez vos objectifs financiers";
|
||||
"onboarding_goals_desc" = "Fixez des objectifs, suivez les étapes et voyez exactement jusqu'où vous êtes arrivé.";
|
||||
"error_generic" = "Une erreur s'est produite. Veuillez réessayer.";
|
||||
"error_no_purchases" = "Aucun achat à restaurer";
|
||||
"error_purchase_failed" = "Achat échoué : %@";
|
||||
"error_export_failed" = "L'export a échoué. Veuillez réessayer.";
|
||||
"placeholder_source_name" = "ex. Vanguard 401k";
|
||||
"placeholder_value" = "0.00";
|
||||
"placeholder_notes" = "Ajouter des notes...";
|
||||
"mood_energized_title" = "En feu";
|
||||
"mood_confident_title" = "Confiant";
|
||||
"mood_balanced_title" = "Stable";
|
||||
"mood_cautious_title" = "Prudent";
|
||||
"mood_stressed_title" = "Stressé";
|
||||
"mood_energized_detail" = "Je me sens imbattable";
|
||||
"mood_confident_detail" = "Sur la bonne voie et serein";
|
||||
"mood_balanced_detail" = "Calme et patient";
|
||||
"mood_cautious_detail" = "J'observe les mouvements";
|
||||
"mood_stressed_detail" = "Besoin de souffler";
|
||||
"achievement_streak_3_title" = "Série de 3 mois";
|
||||
"achievement_streak_3_detail" = "Vous avez gardé vos check-ins à l'heure pendant trois mois d'affilée.";
|
||||
"achievement_streak_6_title" = "Série de six mois";
|
||||
"achievement_streak_6_detail" = "Six check-ins consécutifs à l'heure.";
|
||||
"achievement_streak_12_title" = "Une année d'élan";
|
||||
"achievement_streak_12_detail" = "Une année complète sans manquer la date limite.";
|
||||
"achievement_perfect_on_time_title" = "Jamais en retard";
|
||||
"achievement_perfect_on_time_detail" = "Chaque check-in a été envoyé avant la date limite.";
|
||||
"achievement_clutch_finish_title" = "Final serré";
|
||||
"achievement_clutch_finish_detail" = "Envoyé avec quelques heures d'avance, mais toujours à temps.";
|
||||
"achievement_early_bird_title" = "Lève-tôt";
|
||||
"achievement_early_bird_detail" = "En moyenne, vous terminez avec une belle marge.";
|
||||
"achievements_title" = "Succès";
|
||||
"achievements_view_all" = "Voir tous les succès";
|
||||
"achievements_nav_title" = "Succès";
|
||||
"achievements_progress_title" = "Progression";
|
||||
"achievements_unlocked_title" = "Débloqués";
|
||||
"achievements_unlocked_empty" = "Complétez des check-ins pour débloquer des succès.";
|
||||
"achievements_locked_title" = "Verrouillés";
|
||||
"achievements_locked_empty" = "Tous les succès sont débloqués. Beau travail.";
|
||||
"rating_accessibility" = "Note %d sur 5";
|
||||
"achievements_unlocked_count" = "%d sur %d débloqués";
|
||||
"last_check_in" = "Dernier check-in : %@";
|
||||
"next_check_in" = "Prochain check-in : %@";
|
||||
"on_time_rate" = "%@ à l'heure";
|
||||
"on_time_count" = "%d/%d à l'heure";
|
||||
"tightest_finish" = "Fin la plus serrée : %@ avant la date limite.";
|
||||
"date_today" = "Aujourd'hui";
|
||||
"date_yesterday" = "Hier";
|
||||
"date_never" = "Jamais";
|
||||
"calendar_event_title" = "%@: Check-in mensuel";
|
||||
"calendar_event_notes" = "Ouvrez %@ et terminez votre check-in mensuel.";
|
||||
"checkin_enjoying_dialog_title" = "À quel point appréciez-vous Portfolio Journal ?";
|
||||
"checkin_enjoying_dialog_message" = "Félicitations pour votre nouveau succès. Votre avis nous aide à nous améliorer.";
|
||||
"not_now" = "Pas maintenant";
|
||||
"rating_1_star" = "1 étoile";
|
||||
"rating_n_stars" = "%d étoiles";
|
||||
"app_store_review_title" = "Souhaitez-vous laisser un avis sur l'App Store ?";
|
||||
"app_store_review_message" = "Merci pour les 5 étoiles. Cela aide vraiment d'autres investisseurs à découvrir l'app.";
|
||||
"write_review" = "Écrire un avis";
|
||||
"save_1_snapshot" = "Enregistrer 1 snapshot";
|
||||
"save_n_snapshots" = "Enregistrer %d snapshots";
|
||||
"checkin_update_month" = "Mettre à jour %@";
|
||||
"checkin_start_new" = "Commencer";
|
||||
"Home" = "Accueil";
|
||||
"Sources" = "Sources";
|
||||
"Charts" = "Graphiques";
|
||||
"Settings" = "Réglages";
|
||||
"Journal" = "Journal";
|
||||
"Search monthly notes" = "Rechercher des notes mensuelles";
|
||||
"Monthly Check-ins" = "Check-ins mensuels";
|
||||
"No monthly notes yet." = "Aucune note mensuelle pour l'instant.";
|
||||
"No matching notes." = "Aucune note correspondante.";
|
||||
"Jump to month" = "Aller au mois";
|
||||
"Today" = "Aujourd'hui";
|
||||
"Mood not set" = "Humeur non définie";
|
||||
"No rating" = "Aucune note";
|
||||
"No note yet." = "Pas encore de note.";
|
||||
"Monthly Note" = "Note mensuelle";
|
||||
"Open Full Note" = "Ouvrir la note complète";
|
||||
"Duplicate Previous" = "Dupliquer le précédent";
|
||||
"Save" = "Enregistrer";
|
||||
"Monthly Check-in" = "Check-in mensuel";
|
||||
"This Month" = "Ce mois-ci";
|
||||
"No check-in yet this month" = "Aucun check-in ce mois-ci";
|
||||
"Start your first check-in anytime." = "Commencez votre premier check-in quand vous voulez.";
|
||||
"Mark Check-in Complete" = "Marquer le check-in comme terminé";
|
||||
"Editing stays open. New check-ins unlock after 70% of the month." = "L'édition reste ouverte. Les nouveaux check-ins se débloquent après 70 % du mois.";
|
||||
"Momentum & Streaks" = "Élan et séries";
|
||||
"Log a check-in to start a streak" = "Enregistrez un check-in pour démarrer une série";
|
||||
"Streak" = "Série";
|
||||
"On-time in a row" = "À l'heure d'affilée";
|
||||
"Best" = "Meilleur";
|
||||
"Personal best" = "Meilleur record";
|
||||
"Avg early" = "Marge moyenne";
|
||||
"vs deadline" = "vs date limite";
|
||||
"On-time score" = "Score de ponctualité";
|
||||
"Achievements" = "Succès";
|
||||
"View all achievements" = "Voir tous les succès";
|
||||
"Monthly Pulse" = "Pouls mensuel";
|
||||
"Optional" = "Optionnel";
|
||||
"Rate this month" = "Notez ce mois";
|
||||
"How did it feel?" = "Comment cela s'est-il passé ?";
|
||||
"Monthly Summary" = "Résumé mensuel";
|
||||
"Starting" = "Départ";
|
||||
"Ending" = "Fin";
|
||||
"Contributions" = "Contributions";
|
||||
"Net Performance" = "Performance nette";
|
||||
"Update Sources" = "Mettre à jour les sources";
|
||||
"Add sources to start your monthly check-in." = "Ajoutez des sources pour commencer votre check-in mensuel.";
|
||||
"Updated this cycle" = "Mis à jour sur ce cycle";
|
||||
"Needs update" = "Nécessite une mise à jour";
|
||||
"Snapshot Notes" = "Notes des snapshots";
|
||||
"No snapshot notes for this month." = "Aucune note de snapshot pour ce mois.";
|
||||
"Source" = "Source";
|
||||
"Your full portfolio,\nfully clear" = "Votre portefeuille complet,\nen toute clarté";
|
||||
"One payment. Every feature. Forever." = "Un paiement. Toutes les fonctionnalités. Pour toujours.";
|
||||
"Get Full Access" = "Obtenir l'accès complet";
|
||||
"Restore Purchases" = "Restaurer les achats";
|
||||
"Payment charged to your Apple ID account." = "Le paiement sera facturé à votre compte Apple ID.";
|
||||
"Terms" = "Conditions";
|
||||
"Privacy" = "Confidentialité";
|
||||
"· one-time · Family Sharing" = "· achat unique · Partage familial";
|
||||
"Full access, one payment" = "Accès complet, un paiement";
|
||||
"Unlimited sources, advanced charts & more" = "Sources illimitées, graphiques avancés et plus";
|
||||
"See full access" = "Voir l'accès complet";
|
||||
"Batch Update" = "Mise à jour groupée";
|
||||
"Current value" = "Valeur actuelle";
|
||||
"Contribution this period (optional)" = "Contribution sur cette période (optionnelle)";
|
||||
"Include Contribution" = "Inclure une contribution";
|
||||
"New capital added" = "Nouveau capital ajouté";
|
||||
"Contribution (Optional)" = "Contribution (optionnelle)";
|
||||
"Track new capital added to separate it from investment growth." = "Suivez le nouveau capital ajouté pour le distinguer de la croissance de l'investissement.";
|
||||
"Monthly Highlights" = "Temps forts du mois";
|
||||
"Best Performer" = "Meilleure performance";
|
||||
"Worst Performer" = "Pire performance";
|
||||
"Best Contributor" = "Meilleur contributeur";
|
||||
"Update Check-in" = "Mettre à jour le check-in";
|
||||
"Completed %@" = "Terminé %@";
|
||||
|
||||
// MARK: - Missing keys added (1.3.x)
|
||||
"Checking…" = "Vérification…";
|
||||
"Clear Filters" = "Effacer les filtres";
|
||||
"Force Upload to iCloud" = "Forcer l'envoi vers iCloud";
|
||||
"No export yet" = "Pas encore exporté";
|
||||
"Not synced yet" = "Pas encore synchronisé";
|
||||
"Refresh" = "Actualiser";
|
||||
"Search sources" = "Rechercher des sources";
|
||||
"Syncing with iCloud..." = "Synchronisation avec iCloud...";
|
||||
"Uploading..." = "Envoi en cours...";
|
||||
"Verify iCloud Setup" = "Vérifier la configuration iCloud";
|
||||
"add_source_name_footer" = "Une source est tout investissement que vous souhaitez suivre : actions, ETFs, comptes d'épargne, immobilier, crypto et plus.";
|
||||
"add_source_name_placeholder" = "ex. ETF MSCI World, Livret A, Appartement...";
|
||||
"categories_empty" = "Aucune catégorie pour l'instant.";
|
||||
"category_has_sources_warning" = "Impossible de supprimer une catégorie qui contient des sources. Supprimez ou réaffectez d'abord toutes les sources.";
|
||||
"category_name_placeholder" = "ex. Fonds d'urgence";
|
||||
"chart_yoy_empty" = "Ajoutez plus de snapshots sur différentes années pour voir la comparaison.";
|
||||
"chart_yoy_title" = "Année par année";
|
||||
"chart_yoy_estimated_note" = "* estimé (prévision de fin d'année)";
|
||||
"checking_icloud" = "Vérification d'iCloud...";
|
||||
"contributions_vs_returns_invested" = "Investi";
|
||||
"contributions_vs_returns_returns" = "Rendement du marché";
|
||||
"contributions_vs_returns_title" = "Investi vs. Rendement";
|
||||
"csv_enter_value" = "Saisir une valeur";
|
||||
"csv_field_category" = "Catégorie";
|
||||
"csv_field_contribution" = "Contribution";
|
||||
"csv_field_date" = "Date";
|
||||
"csv_field_notes" = "Notes";
|
||||
"csv_field_source" = "Nom de la source (requis)";
|
||||
"csv_field_value" = "Valeur (requise)";
|
||||
"csv_mapping_subtitle" = "Associez vos colonnes CSV aux champs de Portfolio Journal";
|
||||
"csv_no_column" = "— Non mappé —";
|
||||
"csv_no_date_hint" = "Toutes les lignes seront importées comme un snapshot d'aujourd'hui.";
|
||||
"csv_optional_section" = "Champs optionnels";
|
||||
"csv_preview_section" = "Aperçu CSV";
|
||||
"csv_required_section" = "Champs obligatoires";
|
||||
"csv_use_today" = "Utiliser la date du jour";
|
||||
"goal_achieved_notification_body" = "Vous avez atteint votre objectif : %@";
|
||||
"goal_achieved_notification_title" = "Objectif atteint ! 🎉";
|
||||
"goal_archive" = "Archiver";
|
||||
"goal_delete_confirm" = "Supprimer";
|
||||
"goal_delete_message" = "Cet objectif sera définitivement supprimé. Cette action est irréversible.";
|
||||
"goal_delete_title" = "Supprimer l'objectif";
|
||||
"goal_unarchive" = "Restaurer";
|
||||
"goals_all_active_achieved" = "Tous les objectifs actifs sont atteints.";
|
||||
"goals_empty_archived" = "Aucun objectif archivé.";
|
||||
"goals_filter_active" = "Actifs";
|
||||
"goals_filter_all" = "Tous";
|
||||
"goals_filter_archived" = "Archivés";
|
||||
"icloud_check_description" = "Si vous avez des données sur un autre appareil, activez iCloud pour les restaurer ici.";
|
||||
"icloud_check_title" = "Vous utilisez déjà Portfolio Journal ?";
|
||||
"icloud_enabled_description" = "Fermez l'application et rouvrez-la. Vos données se chargeront automatiquement depuis iCloud.";
|
||||
"icloud_enabled_title" = "iCloud activé";
|
||||
"monthly_checkin_notification_body" = "Enregistrez les valeurs de ce mois et suivez la croissance de votre portefeuille.";
|
||||
"monthly_checkin_notification_title" = "C'est l'heure de votre mise à jour mensuelle";
|
||||
"onboarding_add_first_source" = "Ajouter mon premier investissement";
|
||||
"onboarding_import_data" = "Importer des données existantes";
|
||||
"onboarding_quickstart_subtitle" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
|
||||
"onboarding_quickstart_title" = "Presque prêt";
|
||||
"reengagement_body" = "Quelques minutes suffisent pour rester au top de vos investissements.";
|
||||
"reengagement_goal_title" = "Vous êtes sur la bonne voie 📈";
|
||||
"reengagement_goal_body" = "À ce rythme, vous atteindrez \"%@\" d'ici %@. Voyez où vous en êtes.";
|
||||
"reengagement_yoy_title" = "Regardez cette progression 👀";
|
||||
"reengagement_yoy_body" = "Vous êtes en hausse de %@ sur un an. Consultez la tendance.";
|
||||
"reengagement_month_title" = "Beau mois 📈";
|
||||
"reengagement_month_body" = "Votre portefeuille a gagné %@ ce mois-ci. Jetez-y un œil.";
|
||||
"reengagement_milestone_title" = "Si près 🎯";
|
||||
"reengagement_milestone_body" = "Vous approchez de %@. À combien êtes-vous aujourd'hui ?";
|
||||
"reengagement_streak_title" = "Gardez votre série 🔥";
|
||||
"reengagement_streak_body" = "Vous avez enregistré %d mois d'affilée. Ne vous arrêtez pas.";
|
||||
"reengagement_neutral_title" = "Du nouveau ? 👀";
|
||||
"reengagement_neutral_body" = "Il y a eu du mouvement dans votre portefeuille. Voyez ce qui a changé.";
|
||||
"reengagement_title" = "Votre portefeuille vous attend";
|
||||
"snapshot_duplicate_add" = "Ajouter quand même";
|
||||
"snapshot_contribution_propagate_title" = "Appliquer la contribution";
|
||||
"snapshot_contribution_propagate_message" = "Appliquer %@ comme contribution aux autres relevés aussi ?";
|
||||
"snapshot_contribution_propagate_forward" = "Appliquer aux suivants";
|
||||
"snapshot_contribution_propagate_backward" = "Appliquer aux précédents";
|
||||
"snapshot_contribution_propagate_all" = "Appliquer à tous";
|
||||
"snapshot_contribution_propagate_this" = "Ce relevé uniquement";
|
||||
"snapshot_duplicate_message" = "Vous avez déjà un snapshot pour ce mois. Voulez-vous en ajouter un autre ?";
|
||||
"snapshot_duplicate_replace" = "Remplacer l'existant";
|
||||
"snapshot_duplicate_title" = "Snapshot déjà existant";
|
||||
"sources_filter_all" = "Toutes";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "Votre résumé mensuel de portefeuille";
|
||||
"monthly_summary_notification_body" = "Vérifiez comment vos investissements ont performé ce mois-ci.";
|
||||
"streak_badge" = "Série de %d mois";
|
||||
"goals_empty_add_cta" = "Ajouter mon premier objectif";
|
||||
"journal_empty_title" = "Aucune entrée pour l'instant";
|
||||
"journal_empty_body" = "Les mises à jour de snapshots créent automatiquement des entrées mensuelles. Ajoutez votre première source pour commencer.";
|
||||
"quick_update_title" = "Mise à jour rapide";
|
||||
"quick_update_section_header" = "Saisir les valeurs actuelles";
|
||||
"quick_update_section_footer" = "Seules les sources avec une valeur saisie seront mises à jour.";
|
||||
"quick_update_placeholder" = "Nouvelle valeur";
|
||||
"quick_update_save" = "Tout enregistrer";
|
||||
"quick_update_no_sources" = "Aucune source";
|
||||
"quick_update_no_sources_body" = "Ajoutez d'abord des sources d'investissement pour utiliser la mise à jour rapide.";
|
||||
"update_available_title" = "Mise à jour disponible";
|
||||
"update_available_body" = "La version %@ est disponible sur l'App Store.";
|
||||
"whats_new_title" = "Nouveautés dans 1.4";
|
||||
"whats_new_subtitle" = "Améliorations pour vous aider à rester sur la bonne voie.";
|
||||
"whats_new_quick_update_title" = "Mise à jour rapide du portefeuille";
|
||||
"whats_new_quick_update_body" = "Mettez à jour toutes vos sources depuis un seul écran en un geste.";
|
||||
"whats_new_streak_title" = "Série de mises à jour";
|
||||
"whats_new_streak_body" = "Suivez le nombre de mois consécutifs où vous avez tenu votre portefeuille à jour.";
|
||||
"whats_new_goals_title" = "Objectifs et journal plus intelligents";
|
||||
"whats_new_goals_body" = "Les états vides améliorés vous aident à démarrer plus rapidement.";
|
||||
"whats_new_continue" = "C'est parti !";
|
||||
"source_monthly_contribution_title" = "Apport Mensuel";
|
||||
"source_monthly_contribution_placeholder" = "ex. 500";
|
||||
"source_monthly_contribution_not_set" = "Non configuré";
|
||||
"source_monthly_contribution_hint" = "Pré-rempli dans Mise à jour rapide";
|
||||
"quick_update_contribution_label" = "Apport";
|
||||
"quick_update_contribution_placeholder" = "Montant";
|
||||
"source_monthly_contribution_apply_title" = "Appliquer l'apport";
|
||||
"source_monthly_contribution_apply_message" = "Appliquer %@ à tous les snapshots passés sans apport ?";
|
||||
"source_monthly_contribution_apply_retroactive" = "Appliquer à tous les anciens snapshots";
|
||||
"source_monthly_contribution_apply_forward" = "Seulement à partir de maintenant";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "Aperçus";
|
||||
"insight_milestone_title" = "Jalon en vue";
|
||||
"insight_milestone_value" = "%1$@ de %2$@";
|
||||
"insight_ytd_title" = "Depuis le début de l'année";
|
||||
"insight_market_gains_title" = "Gains de marché";
|
||||
"insight_market_gains_value" = "+%@ des marchés";
|
||||
"insight_streak_title" = "Série de suivi";
|
||||
"insight_streak_value" = "%d mois consécutifs";
|
||||
"insight_forecast_title" = "En bonne voie pour";
|
||||
"insight_allocation_title" = "Écart d'allocation";
|
||||
"insight_allocation_over_value" = "%1$@ est à %2$.0f%% — au-dessus de votre cible de %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ est à %2$.0f%% — en dessous de votre cible de %3$.0f%%";
|
||||
"insight_allocation_detail" = "Rééquilibrez pour rester dans le plan";
|
||||
"insight_concentration_title" = "Risque de concentration";
|
||||
"insight_concentration_value" = "%1$@ représente %2$.0f%% de votre portefeuille";
|
||||
"insight_concentration_detail" = "Envisagez de mieux répartir";
|
||||
"insight_best_performer_title" = "Meilleure performance";
|
||||
"insight_worst_performer_title" = "À surveiller";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversification";
|
||||
"insight_diversification_single_value" = "Tout sur une seule source — pensez à en ajouter";
|
||||
"insight_diversification_good_value" = "Réparti sur %d catégories";
|
||||
"goal_projection_projected" = "Prévu : %@";
|
||||
"goal_projection_add_contributions" = "Ajoutez des versements pour l'atteindre";
|
||||
"goal_chip_ahead" = "En avance";
|
||||
"goal_chip_on_track" = "Dans les temps";
|
||||
"goal_chip_behind" = "En retard";
|
||||
"goal_chip_at_this_pace" = "À ce rythme…";
|
||||
"notification_milestone_title" = "Jalon de portefeuille ! 🎉";
|
||||
"notification_milestone_body" = "Votre portefeuille vient de dépasser %@. Félicitations !";
|
||||
|
||||
"streak_protection_notification_title" = "Ne brisez pas votre série 🔥";
|
||||
"streak_protection_notification_body" = "Votre bilan mensuel est encore en attente. 2 minutes suffisent pour garder votre série.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Comptes multiples";
|
||||
"paywall_benefit_accounts_subtitle" = "Des portefeuilles séparés pour la famille ou le travail";
|
||||
"paywall_benefit_family_title" = "Partage familial";
|
||||
"paywall_benefit_family_subtitle" = "Un achat, jusqu'à 5 membres de la famille";
|
||||
"paywall_plan_annual" = "Annuel";
|
||||
"paywall_plan_annual_per_year" = "%@ / an";
|
||||
"paywall_plan_lifetime" = "À vie";
|
||||
"paywall_plan_lifetime_badge" = "MEILLEURE OFFRE";
|
||||
"paywall_trial_days" = "jours";
|
||||
"paywall_trial_weeks" = "semaines";
|
||||
"paywall_trial_months" = "mois";
|
||||
"paywall_trial_years" = "ans";
|
||||
"paywall_trial_format" = "%d %@ gratuits";
|
||||
|
||||
"chart_history_locked" = "%d mois d'historique en plus — débloquez avec Premium";
|
||||
|
||||
"chart_group_overview" = "Aperçu";
|
||||
"chart_group_analyze" = "Analyse";
|
||||
"chart_group_risk" = "Risque";
|
||||
"chart_group_forecast" = "Prévision";
|
||||
"kpi_total_value" = "Valeur totale";
|
||||
"kpi_period_return" = "Rendement période";
|
||||
"kpi_cagr" = "TCAC";
|
||||
"kpi_volatility" = "Volatilité";
|
||||
"kpi_max_drawdown" = "Baisse max.";
|
||||
"chart_zoom_in" = "Zoom avant";
|
||||
"chart_zoom_out" = "Zoom arrière";
|
||||
"chart_zoom_reset" = "Réinitialiser le zoom";
|
||||
|
||||
"quick_update_paste_suggestion" = "Coller %@ dans %@";
|
||||
"quick_update_next_badge" = "SUIVANTE";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "%1$@ enregistré pour %2$@.";
|
||||
"intent_amount_invalid" = "Le montant doit être supérieur à zéro. Combien ?";
|
||||
"Log Portfolio Value" = "Enregistrer la valeur du portefeuille";
|
||||
"Record a value for one of your sources without opening the app." = "Enregistrez une valeur pour l’une de vos sources sans ouvrir l’app.";
|
||||
"Amount" = "Montant";
|
||||
"How much?" = "Combien ?";
|
||||
"Log Value" = "Enregistrer une valeur";
|
||||
"Update Portfolio" = "Mettre à jour le portefeuille";
|
||||
"Check Portfolio" = "Consulter le portefeuille";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "Partager le graphique";
|
||||
"chart_share_text" = "Mon graphique %@ — créé avec Portfolio Journal";
|
||||
"chart_share_tagline" = "Suivi d’investissements privé et serein pour iPhone et iPad.";
|
||||
"chart_share_scan" = "Scannez pour télécharger";
|
||||
"chart_locked_title" = "%@ est un graphique Premium";
|
||||
"chart_locked_cta" = "Débloquer Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in de %@ terminé";
|
||||
"checkin_done_next" = "Prochain : %@";
|
||||
"checkin_next_due" = "Prochain point : %@";
|
||||
"checkin_days_left" = "%d jours restants";
|
||||
"checkin_due_today" = "Aujourd'hui";
|
||||
"checkin_overdue" = "En retard";
|
||||
"dash_chart_yoy" = "Année vs Année";
|
||||
"home_chart_locked_sub" = "Débloquez avec Premium pour voir ce graphique.";
|
||||
"checkin_sources_progress" = "%1$d sur %2$d sources mises à jour";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mois dernier";
|
||||
"guided_paste_amount" = "Coller %@";
|
||||
"guided_next" = "Suivant";
|
||||
"guided_same_as_last" = "Comme le mois dernier";
|
||||
"guided_skip" = "Passer";
|
||||
"guided_reflection_title" = "Comment se sent ce mois-ci ?";
|
||||
"guided_reflection_subtitle" = "Une note rapide pour votre futur vous.";
|
||||
"guided_note_placeholder" = "Une ligne honnête sur ce mois…";
|
||||
"guided_finish" = "Terminer le check-in";
|
||||
"guided_done_title" = "Check-in de %@ terminé";
|
||||
"guided_streak" = "%d mois d'affilée";
|
||||
"guided_share_month" = "Partager ce mois";
|
||||
"guided_done_cta" = "Terminé";
|
||||
"journal_no_note" = "Pas encore de note.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Version %@";
|
||||
"whats_new_150_redesign_title" = "Un design plus calme et épuré";
|
||||
"whats_new_150_redesign_body" = "Accueil repensé avec votre valeur totale en vedette, le check-in au centre et moins de bruit partout.";
|
||||
"whats_new_150_guided_title" = "Check-in mensuel guidé";
|
||||
"whats_new_150_guided_body" = "Une source par étape, des deltas en direct, une réflexion rapide — votre rituel de 5 minutes, réinventé.";
|
||||
"whats_new_150_rings_title" = "Anneaux d'objectifs & timeline";
|
||||
"whats_new_150_rings_body" = "Des anneaux de progression style Fitness pour vos objectifs et une timeline de votre parcours d'investisseur.";
|
||||
"whats_new_142_ocr_title" = "Mise à jour par capture d'écran";
|
||||
"whats_new_142_ocr_body" = "Partagez une capture de votre banque — le solde est reconnu sur l'appareil, rien ne quitte votre iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri dans votre langue";
|
||||
"whats_new_142_siri_body" = "« Enregistre le solde de Indexa » — notez des valeurs à la voix ou depuis Raccourcis, sans ouvrir l'app.";
|
||||
"whats_new_142_charts_title" = "Graphiques repensés";
|
||||
"whats_new_142_charts_body" = "Barre latérale native sur iPad, KPIs adaptés à chaque graphique et partage en belles images.";
|
||||
"whats_new_141_extension_title" = "Mettre à jour sans changer d'app";
|
||||
"whats_new_141_extension_body" = "Partagez une valeur depuis votre app bancaire directement vers Portfolio Journal avec la nouvelle extension.";
|
||||
"whats_new_141_ipad_title" = "Meilleur sur iPad";
|
||||
"whats_new_141_ipad_body" = "Graphiques repensés pour le grand écran, avec zoom et étiquettes de valeur.";
|
||||
"whats_new_141_icloud_title" = "Plus synchronisé";
|
||||
"whats_new_141_icloud_body" = "Les contributions mensuelles et les objectifs d'allocation se synchronisent désormais via iCloud.";
|
||||
"whats_new_title_format" = "Nouveautés de la %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Tout mettre à jour en quelques secondes";
|
||||
"tip_quick_update_body" = "Quick Update liste toutes vos sources sur un écran — collez les valeurs du presse-papiers et c'est fait.";
|
||||
"tip_ocr_title" = "Ou partagez une capture d'écran";
|
||||
"tip_ocr_body" = "Depuis votre app bancaire : capture → Partager → Portfolio Journal. Le solde est lu sur l'appareil.";
|
||||
"tip_siri_title" = "La prochaine fois, essayez la voix";
|
||||
"tip_siri_body" = "Dites à Siri « Enregistre le solde de mon compte dans Portfolio Journal » — sans ouvrir l'app.";
|
||||
"tip_chart_share_title" = "Partagez ce graphique";
|
||||
"tip_chart_share_body" = "Génère une belle image avec vos données, prête pour X ou WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud a refusé certains enregistrements. Cela signifie souvent que le schéma iCloud est encore en cours de configuration — se résout généralement en quelques heures ou après la prochaine mise à jour.";
|
||||
"icloud_hint_quota" = "Votre stockage iCloud est plein. Libérez de l'espace et réessayez.";
|
||||
"icloud_hint_network" = "Impossible de joindre iCloud. Vérifiez votre connexion et réessayez.";
|
||||
"icloud_hint_partial_generic" = "Certains enregistrements n'ont pas pu être synchronisés cette fois. L'app réessaiera automatiquement.";
|
||||
"icloud_status_error" = "Problème de synchronisation iCloud";
|
||||
"icloud_status_syncing" = "Synchronisation avec iCloud";
|
||||
"icloud_show_details" = "Afficher les détails techniques";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Meilleure année";
|
||||
"yoy_worst_year" = "Pire année";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Coller %@";
|
||||
"quick_update_scan" = "Scanner";
|
||||
"quick_update_done_kbd" = "OK";
|
||||
"quick_update_scanning" = "Lecture de l’image…";
|
||||
"quick_update_pick_amount" = "Quel montant ?";
|
||||
"quick_update_no_amount_found" = "Aucun montant reconnu dans l’image.";
|
||||
"icloud_copy_diagnostics" = "Copier le diagnostic iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnostic copié dans le presse-papiers";
|
||||
|
||||
"Smooth Gaps in Charts" = "Lisser les écarts dans les graphiques";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Données manquantes pour %d mois";
|
||||
"gaps_missing_count" = "%d manquants";
|
||||
"gaps_range_between" = "Entre %@ et %@";
|
||||
"Fill in missing data" = "Compléter les données manquantes";
|
||||
"Missing Data" = "Données manquantes";
|
||||
"Fill Gap" = "Combler le vide";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Le graphique estime ces mois. Ajoutez des valeurs réelles pour garder un historique précis.";
|
||||
"Dismiss" = "Ignorer";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d mois";
|
||||
"Monthly Summary" = "Résumé mensuel";
|
||||
"Portfolio Value" = "Valeur du portefeuille";
|
||||
"Tracked with" = "Suivi avec";
|
||||
"Share monthly summary" = "Partager le résumé mensuel";
|
||||
"since last check-in" = "depuis le dernier point";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Rien de nouveau à importer. Vos données sont déjà à jour.";
|
||||
"import_preview_sources" = "Créer %d sources";
|
||||
"import_preview_snapshots_new" = "Créer %d relevés";
|
||||
"import_preview_snapshots_update" = "Mettre à jour %d relevés";
|
||||
"import_preview_categories" = "Créer %d catégories";
|
||||
"import_preview_goals" = "Créer %d objectifs";
|
||||
"import_preview_journal_new" = "Créer %d entrées de journal";
|
||||
"import_preview_journal_update" = "Mettre à jour %d entrées de journal";
|
||||
"Review Import" = "Vérifier l’import";
|
||||
"Reviewing import…" = "Vérification de l’import…";
|
||||
"Import" = "Importer";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimé";
|
||||
"gaps_autofill_cta" = "Remplir %d mois";
|
||||
"Add manually" = "Ajouter manuellement";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Le remplissage auto estime les mois manquants entre deux relevés (marqués comme estimés). Ou saisis les vraies valeurs toi-même.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Sauvegarde auto";
|
||||
"Off" = "Désactivé";
|
||||
"Daily" = "Quotidien";
|
||||
"Weekly" = "Hebdomadaire";
|
||||
"Monthly" = "Mensuel";
|
||||
"Last auto backup" = "Dernière sauvegarde auto";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Masquer les soldes";
|
||||
"Show balances" = "Afficher les soldes";
|
||||
"Balance hidden" = "Solde masqué";
|
||||
"Reveal your balances" = "Afficher vos soldes";
|
||||
"Privacy" = "Confidentialité";
|
||||
"Hide Balances" = "Masquer les soldes";
|
||||
"Require Face ID to Reveal" = "Exiger Face ID pour afficher";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez Face ID pour les afficher, en option.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Tu n'as encore enregistré aucune valeur de portefeuille.";
|
||||
"intent_networth_dialog" = "Ton patrimoine est de %@.";
|
||||
"intent_streak" = "Tu es sur une série de %d mois.";
|
||||
"intent_streak_with_date" = "Tu es sur une série de %d mois. Ton dernier relevé date du %@.";
|
||||
"intent_streak_none" = "Tu n'as pas encore de série active. Enregistre une valeur pour en commencer une.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Période personnalisée";
|
||||
"chart_range_from" = "De";
|
||||
"chart_range_to" = "À";
|
||||
"chart_filtered_prefix" = "Filtré : %@";
|
||||
"chart_overlay_contributions" = "vs Versements";
|
||||
"chart_insight_best_month" = "Meilleur mois de la période : %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Partage tes progrès";
|
||||
"share_percent_only" = "Pourcentages uniquement";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Partager";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Rendement total";
|
||||
"share_metric_yoy" = "Cette année";
|
||||
"share_metric_since_inception" = "Depuis le début";
|
||||
"share_cta_headline" = "Suis le tien — gratuit";
|
||||
"share_cta_sub" = "Scanne pour Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Au %@";
|
||||
"share_theme_dark" = "Sombre";
|
||||
"share_theme_light" = "Clair";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valeur du jour";
|
||||
"add_source_value_footer" = "Enregistrée comme votre premier check-in : votre progression commence aujourd'hui.";
|
||||
"quick_update_add_first_source" = "Ajouter votre première source";
|
||||
@@ -0,0 +1,679 @@
|
||||
"app_name" = "Portfolio Journal";
|
||||
"ok" = "OK";
|
||||
"cancel" = "Annulla";
|
||||
"save" = "Salva";
|
||||
"delete" = "Elimina";
|
||||
"edit" = "Modifica";
|
||||
"add" = "Aggiungi";
|
||||
"done" = "Fatto";
|
||||
"close" = "Chiudi";
|
||||
"continue" = "Continua";
|
||||
"skip" = "Salta";
|
||||
"error" = "Errore";
|
||||
"success" = "Successo";
|
||||
"loading" = "Caricamento...";
|
||||
"loading_data" = "Caricamento dei tuoi dati...";
|
||||
"tab_dashboard" = "Home";
|
||||
"tab_sources" = "Fonti";
|
||||
"tab_charts" = "Grafici";
|
||||
"tab_settings" = "Impostazioni";
|
||||
"dashboard_title" = "Home";
|
||||
"total_portfolio_value" = "Valore totale del portafoglio";
|
||||
"today" = "oggi";
|
||||
"returns" = "Rendimenti";
|
||||
"by_category" = "Per categoria";
|
||||
"pending_updates" = "Aggiornamenti in sospeso";
|
||||
"see_all" = "Vedi tutto";
|
||||
"sources_title" = "Fonti";
|
||||
"add_source" = "Aggiungi fonte";
|
||||
"source_name" = "Nome fonte";
|
||||
"select_category" = "Seleziona categoria";
|
||||
"initial_value" = "Valore iniziale";
|
||||
"initial_value_optional" = "Valore iniziale (opzionale)";
|
||||
"reminder_frequency" = "Frequenza promemoria";
|
||||
"source_limit_warning" = "Limite di fonti raggiunto. Passa a Premium per fonti illimitate.";
|
||||
"no_sources" = "Nessuna fonte di investimento";
|
||||
"no_sources_message" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
|
||||
"add_snapshot" = "Aggiungi snapshot";
|
||||
"edit_snapshot" = "Modifica snapshot";
|
||||
"snapshot_date" = "Data";
|
||||
"snapshot_value" = "Valore";
|
||||
"snapshot_contribution" = "Contributo";
|
||||
"contribution_optional" = "Contributo (opzionale)";
|
||||
"notes" = "Note";
|
||||
"notes_optional" = "Note (opzionali)";
|
||||
"previous_value" = "Precedente: %@";
|
||||
"change_from_previous" = "Variazione rispetto al precedente";
|
||||
"charts_title" = "Grafici";
|
||||
"evolution" = "Evoluzione";
|
||||
"allocation" = "Allocazione";
|
||||
"performance" = "Performance";
|
||||
"drawdown" = "Drawdown";
|
||||
"volatility" = "Volatilità";
|
||||
"prediction" = "Previsione";
|
||||
"portfolio_evolution" = "Evoluzione del portafoglio";
|
||||
"asset_allocation" = "Allocazione degli asset";
|
||||
"performance_by_category" = "Performance per categoria";
|
||||
"drawdown_analysis" = "Analisi del drawdown";
|
||||
"prediction_12_month" = "Previsione a 12 mesi";
|
||||
"not_enough_data" = "Dati insufficienti";
|
||||
"cagr" = "CAGR";
|
||||
"twr" = "TWR";
|
||||
"max_drawdown" = "Drawdown massimo";
|
||||
"sharpe_ratio" = "Indice di Sharpe";
|
||||
"win_rate" = "Tasso di successo";
|
||||
"avg_monthly" = "Media mensile";
|
||||
"best_month" = "Mese migliore";
|
||||
"worst_month" = "Mese peggiore";
|
||||
"premium" = "Premium";
|
||||
"upgrade_to_premium" = "Passa a Premium";
|
||||
"unlock_full_potential" = "Sblocca tutto il potenziale";
|
||||
"one_time_purchase" = "Acquisto una tantum";
|
||||
"includes_family_sharing" = "Include la condivisione in famiglia";
|
||||
"upgrade_now" = "Aggiorna ora";
|
||||
"restore_purchases" = "Ripristina acquisti";
|
||||
"premium_active" = "Premium attivo";
|
||||
"premium_feature" = "Funzione Premium";
|
||||
"unlock" = "Sblocca";
|
||||
"feature_unlimited_sources" = "Fonti illimitate";
|
||||
"feature_unlimited_sources_desc" = "Monitora tutti gli investimenti che vuoi";
|
||||
"feature_full_history" = "Cronologia completa";
|
||||
"feature_full_history_desc" = "Accedi a tutta la tua cronologia di investimento";
|
||||
"feature_advanced_charts" = "Grafici avanzati";
|
||||
"feature_advanced_charts_desc" = "5 tipi di grafici analitici dettagliati";
|
||||
"feature_predictions" = "Previsioni";
|
||||
"feature_predictions_desc" = "Previsioni a 12 mesi basate su IA";
|
||||
"feature_export" = "Esporta dati";
|
||||
"feature_export_desc" = "Esporta in CSV e JSON";
|
||||
"feature_no_ads" = "Niente pubblicità";
|
||||
"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre";
|
||||
"paywall_benefit_history_title" = "La tua cronologia completa";
|
||||
"paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno";
|
||||
"paywall_benefit_charts_title" = "Grafici che rivelano schemi";
|
||||
"paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto";
|
||||
"paywall_benefit_forecasts_title" = "Previsioni a 12 mesi";
|
||||
"paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio";
|
||||
"paywall_benefit_noads_title" = "Niente pubblicità, mai";
|
||||
"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni";
|
||||
"settings_title" = "Impostazioni";
|
||||
"subscription" = "Abbonamento";
|
||||
"notifications" = "Notifiche";
|
||||
"default_reminder_time" = "Ora promemoria predefinita";
|
||||
"data" = "Dati";
|
||||
"export_data" = "Esporta dati";
|
||||
"total_sources" = "Fonti totali";
|
||||
"total_snapshots" = "Snapshot totali";
|
||||
"storage_used" = "Spazio utilizzato";
|
||||
"about" = "Info";
|
||||
"version" = "Versione";
|
||||
"privacy_policy" = "Informativa sulla privacy";
|
||||
"terms_of_service" = "Termini di servizio";
|
||||
"support" = "Supporto";
|
||||
"rate_app" = "Valuta l'app";
|
||||
"danger_zone" = "Area pericolosa";
|
||||
"reset_all_data" = "Reimposta tutti i dati";
|
||||
"reset_confirmation" = "Questo eliminerà definitivamente tutti i dati di investimento. L'azione non può essere annullata.";
|
||||
"frequency_monthly" = "Mensile";
|
||||
"frequency_quarterly" = "Trimestrale";
|
||||
"frequency_semiannual" = "Semestrale";
|
||||
"frequency_annual" = "Annuale";
|
||||
"frequency_custom" = "Personalizzata";
|
||||
"frequency_never" = "Mai";
|
||||
"every_n_months" = "Ogni %d mese/i";
|
||||
"category_stocks" = "Azioni";
|
||||
"category_bonds" = "Obbligazioni";
|
||||
"category_real_estate" = "Immobiliare";
|
||||
"category_crypto" = "Cripto";
|
||||
"category_cash" = "Liquidità";
|
||||
"category_etfs" = "ETF";
|
||||
"category_retirement" = "Pensione";
|
||||
"category_other" = "Altro";
|
||||
"uncategorized" = "Senza categoria";
|
||||
"time_1m" = "1M";
|
||||
"time_3m" = "3M";
|
||||
"time_6m" = "6M";
|
||||
"time_1y" = "1A";
|
||||
"time_all" = "Tutto";
|
||||
"export_format" = "Seleziona formato";
|
||||
"export_csv" = "CSV";
|
||||
"export_csv_desc" = "Compatibile con Excel e Google Sheets";
|
||||
"export_json" = "JSON";
|
||||
"export_json_desc" = "Struttura dati completa per backup";
|
||||
"onboarding_track_title" = "Monitora i tuoi investimenti";
|
||||
"onboarding_track_desc" = "Controlla tutte le tue fonti di investimento in un unico posto. Azioni, obbligazioni, immobiliare, cripto e altro.";
|
||||
"onboarding_visualize_title" = "Visualizza la tua crescita";
|
||||
"onboarding_visualize_desc" = "Bellissimi grafici mostrano l'evoluzione, l'allocazione e la performance del portafoglio nel tempo.";
|
||||
"onboarding_reminders_title" = "Non perdere mai un aggiornamento";
|
||||
"onboarding_reminders_desc" = "Imposta promemoria per monitorare regolarmente i tuoi investimenti. Mensili, trimestrali o personalizzati.";
|
||||
"onboarding_sync_title" = "Sincronizza ovunque";
|
||||
"onboarding_sync_desc" = "I tuoi dati si sincronizzano automaticamente tramite iCloud su tutti i tuoi dispositivi Apple.";
|
||||
"get_started" = "Inizia";
|
||||
"onboarding_clarity_title" = "Sai esattamente a che punto sei";
|
||||
"onboarding_clarity_desc" = "Vedi il tuo patrimonio totale, i rendimenti reali e l'allocazione, sempre aggiornati.";
|
||||
"onboarding_habit_title" = "Bastano 5 minuti al mese";
|
||||
"onboarding_habit_desc" = "Registra i tuoi valori una volta al mese. Portfolio Journal fa i calcoli e mostra i tuoi progressi.";
|
||||
"onboarding_calm_title" = "Ignora il rumore. Segui il trend.";
|
||||
"onboarding_calm_desc" = "Le oscillazioni giornaliere non raccontano la storia vera. La tua crescita in mesi e anni sì.";
|
||||
"onboarding_goals_title" = "Raggiungi i tuoi obiettivi finanziari";
|
||||
"onboarding_goals_desc" = "Imposta obiettivi, segui le tappe e vedi esattamente quanto hai fatto.";
|
||||
"error_generic" = "Si è verificato un errore. Riprova.";
|
||||
"error_no_purchases" = "Nessun acquisto da ripristinare";
|
||||
"error_purchase_failed" = "Acquisto non riuscito: %@";
|
||||
"error_export_failed" = "Esportazione non riuscita. Riprova.";
|
||||
"placeholder_source_name" = "es. Vanguard 401k";
|
||||
"placeholder_value" = "0.00";
|
||||
"placeholder_notes" = "Aggiungi note...";
|
||||
"mood_energized_title" = "Carico";
|
||||
"mood_confident_title" = "Fiducioso";
|
||||
"mood_balanced_title" = "Equilibrato";
|
||||
"mood_cautious_title" = "Cauto";
|
||||
"mood_stressed_title" = "Stressato";
|
||||
"mood_energized_detail" = "Mi sento imbattibile";
|
||||
"mood_confident_detail" = "In carreggiata e lucido";
|
||||
"mood_balanced_detail" = "Calmo e paziente";
|
||||
"mood_cautious_detail" = "Osservo i movimenti";
|
||||
"mood_stressed_detail" = "Ho bisogno di resettare";
|
||||
"achievement_streak_3_title" = "Serie di 3 mesi";
|
||||
"achievement_streak_3_detail" = "Hai mantenuto i check-in puntuali per tre mesi di fila.";
|
||||
"achievement_streak_6_title" = "Serie di mezzo anno";
|
||||
"achievement_streak_6_detail" = "Sei check-in consecutivi puntuali.";
|
||||
"achievement_streak_12_title" = "Un anno di slancio";
|
||||
"achievement_streak_12_detail" = "Un anno intero senza perdere la scadenza.";
|
||||
"achievement_perfect_on_time_title" = "Mai in ritardo";
|
||||
"achievement_perfect_on_time_detail" = "Ogni check-in è arrivato prima della scadenza.";
|
||||
"achievement_clutch_finish_title" = "Finale al limite";
|
||||
"achievement_clutch_finish_detail" = "Inviato con poche ore di margine ma comunque in tempo.";
|
||||
"achievement_early_bird_title" = "Mattiniero";
|
||||
"achievement_early_bird_detail" = "In media completi tutto con molto margine.";
|
||||
"achievements_title" = "Obiettivi";
|
||||
"achievements_view_all" = "Vedi tutti gli obiettivi";
|
||||
"achievements_nav_title" = "Obiettivi";
|
||||
"achievements_progress_title" = "Progressi";
|
||||
"achievements_unlocked_title" = "Sbloccati";
|
||||
"achievements_unlocked_empty" = "Completa i check-in per sbloccare obiettivi.";
|
||||
"achievements_locked_title" = "Bloccati";
|
||||
"achievements_locked_empty" = "Tutti gli obiettivi sbloccati. Ottimo lavoro.";
|
||||
"rating_accessibility" = "Valutazione %d su 5";
|
||||
"achievements_unlocked_count" = "%d su %d sbloccati";
|
||||
"last_check_in" = "Ultimo check-in: %@";
|
||||
"next_check_in" = "Prossimo check-in: %@";
|
||||
"on_time_rate" = "%@ puntuale";
|
||||
"on_time_count" = "%d/%d in tempo";
|
||||
"tightest_finish" = "Chiusura più tirata: %@ prima della scadenza.";
|
||||
"date_today" = "Oggi";
|
||||
"date_yesterday" = "Ieri";
|
||||
"date_never" = "Mai";
|
||||
"calendar_event_title" = "%@: Check-in mensile";
|
||||
"calendar_event_notes" = "Apri %@ e completa il tuo check-in mensile.";
|
||||
"checkin_enjoying_dialog_title" = "Quanto ti piace Portfolio Journal?";
|
||||
"checkin_enjoying_dialog_message" = "Congratulazioni per il tuo nuovo obiettivo. Il tuo feedback ci aiuta a migliorare.";
|
||||
"not_now" = "Non ora";
|
||||
"rating_1_star" = "1 stella";
|
||||
"rating_n_stars" = "%d stelle";
|
||||
"app_store_review_title" = "Vuoi lasciare una recensione sull'App Store?";
|
||||
"app_store_review_message" = "Grazie per le 5 stelle. Aiuta davvero altri investitori a scoprire l'app.";
|
||||
"write_review" = "Scrivi una recensione";
|
||||
"save_1_snapshot" = "Salva 1 snapshot";
|
||||
"save_n_snapshots" = "Salva %d snapshot";
|
||||
"checkin_update_month" = "Aggiorna %@";
|
||||
"checkin_start_new" = "Inizia";
|
||||
"Home" = "Home";
|
||||
"Sources" = "Fonti";
|
||||
"Charts" = "Grafici";
|
||||
"Settings" = "Impostazioni";
|
||||
"Journal" = "Diario";
|
||||
"Search monthly notes" = "Cerca note mensili";
|
||||
"Monthly Check-ins" = "Check-in mensili";
|
||||
"No monthly notes yet." = "Ancora nessuna nota mensile.";
|
||||
"No matching notes." = "Nessuna nota corrispondente.";
|
||||
"Jump to month" = "Vai al mese";
|
||||
"Today" = "Oggi";
|
||||
"Mood not set" = "Stato d'animo non impostato";
|
||||
"No rating" = "Nessuna valutazione";
|
||||
"No note yet." = "Ancora nessuna nota.";
|
||||
"Monthly Note" = "Nota mensile";
|
||||
"Open Full Note" = "Apri nota completa";
|
||||
"Duplicate Previous" = "Duplica precedente";
|
||||
"Save" = "Salva";
|
||||
"Monthly Check-in" = "Check-in mensile";
|
||||
"This Month" = "Questo mese";
|
||||
"No check-in yet this month" = "Nessun check-in questo mese";
|
||||
"Start your first check-in anytime." = "Inizia il tuo primo check-in quando vuoi.";
|
||||
"Mark Check-in Complete" = "Segna check-in come completato";
|
||||
"Editing stays open. New check-ins unlock after 70% of the month." = "La modifica resta aperta. I nuovi check-in si sbloccano dopo il 70 % del mese.";
|
||||
"Momentum & Streaks" = "Slancio e serie";
|
||||
"Log a check-in to start a streak" = "Registra un check-in per iniziare una serie";
|
||||
"Streak" = "Serie";
|
||||
"On-time in a row" = "In tempo di fila";
|
||||
"Best" = "Migliore";
|
||||
"Personal best" = "Record personale";
|
||||
"Avg early" = "Anticipo medio";
|
||||
"vs deadline" = "vs scadenza";
|
||||
"On-time score" = "Punteggio puntualità";
|
||||
"Achievements" = "Obiettivi";
|
||||
"View all achievements" = "Vedi tutti gli obiettivi";
|
||||
"Monthly Pulse" = "Polso mensile";
|
||||
"Optional" = "Opzionale";
|
||||
"Rate this month" = "Valuta questo mese";
|
||||
"How did it feel?" = "Come ti sei sentito?";
|
||||
"Monthly Summary" = "Riepilogo mensile";
|
||||
"Starting" = "Inizio";
|
||||
"Ending" = "Fine";
|
||||
"Contributions" = "Contributi";
|
||||
"Net Performance" = "Performance netta";
|
||||
"Update Sources" = "Aggiorna fonti";
|
||||
"Add sources to start your monthly check-in." = "Aggiungi fonti per iniziare il tuo check-in mensile.";
|
||||
"Updated this cycle" = "Aggiornato in questo ciclo";
|
||||
"Needs update" = "Da aggiornare";
|
||||
"Snapshot Notes" = "Note degli snapshot";
|
||||
"No snapshot notes for this month." = "Nessuna nota snapshot per questo mese.";
|
||||
"Source" = "Fonte";
|
||||
"Your full portfolio,\nfully clear" = "Il tuo intero portafoglio,\ntutto chiaro";
|
||||
"One payment. Every feature. Forever." = "Un pagamento. Ogni funzione. Per sempre.";
|
||||
"Get Full Access" = "Ottieni accesso completo";
|
||||
"Restore Purchases" = "Ripristina acquisti";
|
||||
"Payment charged to your Apple ID account." = "Il pagamento verrà addebitato sul tuo account Apple ID.";
|
||||
"Terms" = "Termini";
|
||||
"Privacy" = "Privacy";
|
||||
"· one-time · Family Sharing" = "· una tantum · In famiglia";
|
||||
"Full access, one payment" = "Accesso completo, un solo pagamento";
|
||||
"Unlimited sources, advanced charts & more" = "Fonti illimitate, grafici avanzati e altro";
|
||||
"See full access" = "Vedi accesso completo";
|
||||
"Batch Update" = "Aggiornamento in blocco";
|
||||
"Current value" = "Valore attuale";
|
||||
"Contribution this period (optional)" = "Contributo di questo periodo (opzionale)";
|
||||
"Include Contribution" = "Includi contributo";
|
||||
"New capital added" = "Nuovo capitale aggiunto";
|
||||
"Contribution (Optional)" = "Contributo (opzionale)";
|
||||
"Track new capital added to separate it from investment growth." = "Tieni traccia del nuovo capitale aggiunto per separarlo dalla crescita dell'investimento.";
|
||||
"Monthly Highlights" = "Punti salienti del mese";
|
||||
"Best Performer" = "Miglior performance";
|
||||
"Worst Performer" = "Peggior performance";
|
||||
"Best Contributor" = "Maggior contributore";
|
||||
"Update Check-in" = "Aggiorna check-in";
|
||||
"Completed %@" = "Completato %@";
|
||||
|
||||
// MARK: - Missing keys added (1.3.x)
|
||||
"Checking…" = "Verifica…";
|
||||
"Clear Filters" = "Cancella filtri";
|
||||
"Force Upload to iCloud" = "Forza caricamento su iCloud";
|
||||
"No export yet" = "Nessun export ancora";
|
||||
"Not synced yet" = "Non ancora sincronizzato";
|
||||
"Refresh" = "Aggiorna";
|
||||
"Search sources" = "Cerca fonti";
|
||||
"Syncing with iCloud..." = "Sincronizzazione con iCloud...";
|
||||
"Uploading..." = "Caricamento...";
|
||||
"Verify iCloud Setup" = "Verifica configurazione iCloud";
|
||||
"add_source_name_footer" = "Una fonte è qualsiasi investimento che vuoi monitorare: azioni, ETF, conti di risparmio, immobili, crypto e altro.";
|
||||
"add_source_name_placeholder" = "es. ETF MSCI World, Conto deposito, Appartamento...";
|
||||
"categories_empty" = "Nessuna categoria ancora.";
|
||||
"category_has_sources_warning" = "Non è possibile eliminare una categoria che ha fonti. Rimuovi o riassegna prima tutte le fonti.";
|
||||
"category_name_placeholder" = "es. Fondo di emergenza";
|
||||
"chart_yoy_empty" = "Aggiungi più snapshot in anni diversi per vedere il confronto.";
|
||||
"chart_yoy_title" = "Anno su anno";
|
||||
"chart_yoy_estimated_note" = "* stimato (previsione di fine anno)";
|
||||
"checking_icloud" = "Verifica iCloud...";
|
||||
"contributions_vs_returns_invested" = "Investito";
|
||||
"contributions_vs_returns_returns" = "Rendimento di mercato";
|
||||
"contributions_vs_returns_title" = "Investito vs. Rendimento";
|
||||
"csv_enter_value" = "Inserisci valore";
|
||||
"csv_field_category" = "Categoria";
|
||||
"csv_field_contribution" = "Contributo";
|
||||
"csv_field_date" = "Data";
|
||||
"csv_field_notes" = "Note";
|
||||
"csv_field_source" = "Nome fonte (obbligatorio)";
|
||||
"csv_field_value" = "Valore (obbligatorio)";
|
||||
"csv_mapping_subtitle" = "Associa le colonne CSV ai campi di Portfolio Journal";
|
||||
"csv_no_column" = "— Non mappato —";
|
||||
"csv_no_date_hint" = "Tutte le righe saranno importate come snapshot di oggi.";
|
||||
"csv_optional_section" = "Campi opzionali";
|
||||
"csv_preview_section" = "Anteprima CSV";
|
||||
"csv_required_section" = "Campi obbligatori";
|
||||
"csv_use_today" = "Usa la data di oggi";
|
||||
"goal_achieved_notification_body" = "Hai raggiunto il tuo obiettivo: %@";
|
||||
"goal_achieved_notification_title" = "Obiettivo raggiunto! 🎉";
|
||||
"goal_archive" = "Archivia";
|
||||
"goal_delete_confirm" = "Elimina";
|
||||
"goal_delete_message" = "Questo obiettivo sarà eliminato definitivamente. Questa azione non può essere annullata.";
|
||||
"goal_delete_title" = "Elimina obiettivo";
|
||||
"goal_unarchive" = "Ripristina";
|
||||
"goals_all_active_achieved" = "Tutti gli obiettivi attivi sono stati raggiunti.";
|
||||
"goals_empty_archived" = "Nessun obiettivo archiviato.";
|
||||
"goals_filter_active" = "Attivi";
|
||||
"goals_filter_all" = "Tutti";
|
||||
"goals_filter_archived" = "Archiviati";
|
||||
"icloud_check_description" = "Se hai dati su un altro dispositivo, abilita iCloud per ripristinarli qui.";
|
||||
"icloud_check_title" = "Usi già Portfolio Journal?";
|
||||
"icloud_enabled_description" = "Chiudi l'app e riaprila. I tuoi dati si caricheranno automaticamente da iCloud.";
|
||||
"icloud_enabled_title" = "iCloud abilitato";
|
||||
"monthly_checkin_notification_body" = "Registra i valori di questo mese e monitora la crescita del tuo portafoglio.";
|
||||
"monthly_checkin_notification_title" = "È ora del tuo aggiornamento mensile";
|
||||
"onboarding_add_first_source" = "Aggiungi il mio primo investimento";
|
||||
"onboarding_import_data" = "Importa dati esistenti";
|
||||
"onboarding_quickstart_subtitle" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
|
||||
"onboarding_quickstart_title" = "Quasi pronto";
|
||||
"reengagement_body" = "Pochi minuti bastano per tenere sotto controllo i tuoi investimenti.";
|
||||
"reengagement_goal_title" = "Sei sulla buona strada 📈";
|
||||
"reengagement_goal_body" = "A questo ritmo raggiungerai \"%@\" entro %@. Guarda quanto manca.";
|
||||
"reengagement_yoy_title" = "Guarda che crescita 👀";
|
||||
"reengagement_yoy_body" = "Sei in rialzo del %@ nell'ultimo anno. Controlla l'andamento.";
|
||||
"reengagement_month_title" = "Bel mese 📈";
|
||||
"reengagement_month_body" = "Il tuo portafoglio è cresciuto di %@ questo mese. Dai un'occhiata.";
|
||||
"reengagement_milestone_title" = "Ci sei quasi 🎯";
|
||||
"reengagement_milestone_body" = "Sei quasi a %@. Quanto manca oggi?";
|
||||
"reengagement_streak_title" = "Non perdere la serie 🔥";
|
||||
"reengagement_streak_body" = "Hai registrato %d mesi di fila. Non fermarti ora.";
|
||||
"reengagement_neutral_title" = "Novità? 👀";
|
||||
"reengagement_neutral_body" = "C'è stato movimento nel tuo portafoglio. Guarda cosa è cambiato.";
|
||||
"reengagement_title" = "Il tuo portafoglio ti aspetta";
|
||||
"snapshot_duplicate_add" = "Aggiungi comunque";
|
||||
"snapshot_contribution_propagate_title" = "Applica contributo";
|
||||
"snapshot_contribution_propagate_message" = "Applicare %@ come contributo anche agli altri snapshot?";
|
||||
"snapshot_contribution_propagate_forward" = "Applica ai successivi";
|
||||
"snapshot_contribution_propagate_backward" = "Applica ai precedenti";
|
||||
"snapshot_contribution_propagate_all" = "Applica a tutti";
|
||||
"snapshot_contribution_propagate_this" = "Solo questo snapshot";
|
||||
"snapshot_duplicate_message" = "Hai già uno snapshot per questo mese. Vuoi aggiungerne un altro?";
|
||||
"snapshot_duplicate_replace" = "Sostituisci esistente";
|
||||
"snapshot_duplicate_title" = "Snapshot già esistente";
|
||||
"sources_filter_all" = "Tutte";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "Il tuo riepilogo mensile del portafoglio";
|
||||
"monthly_summary_notification_body" = "Controlla come hanno performato i tuoi investimenti questo mese.";
|
||||
"streak_badge" = "Serie di %d mesi";
|
||||
"goals_empty_add_cta" = "Aggiungi il mio primo obiettivo";
|
||||
"journal_empty_title" = "Nessun registro ancora";
|
||||
"journal_empty_body" = "Gli aggiornamenti degli snapshot creano automaticamente voci mensili. Aggiungi la tua prima fonte per iniziare.";
|
||||
"quick_update_title" = "Aggiornamento rapido";
|
||||
"quick_update_section_header" = "Inserisci i valori correnti";
|
||||
"quick_update_section_footer" = "Verranno aggiornate solo le fonti con un valore inserito.";
|
||||
"quick_update_placeholder" = "Nuovo valore";
|
||||
"quick_update_save" = "Salva tutto";
|
||||
"quick_update_no_sources" = "Nessuna fonte";
|
||||
"quick_update_no_sources_body" = "Aggiungi prima fonti di investimento per usare l'aggiornamento rapido.";
|
||||
"update_available_title" = "Aggiornamento disponibile";
|
||||
"update_available_body" = "La versione %@ è disponibile sull'App Store.";
|
||||
"whats_new_title" = "Novità in 1.4";
|
||||
"whats_new_subtitle" = "Miglioramenti per aiutarti a restare in carreggiata.";
|
||||
"whats_new_quick_update_title" = "Aggiornamento rapido del portafoglio";
|
||||
"whats_new_quick_update_body" = "Aggiorna tutte le tue fonti da un'unica schermata con un tocco.";
|
||||
"whats_new_streak_title" = "Serie di aggiornamenti";
|
||||
"whats_new_streak_body" = "Tieni traccia di quanti mesi consecutivi hai tenuto aggiornato il tuo portafoglio.";
|
||||
"whats_new_goals_title" = "Obiettivi e diario più intelligenti";
|
||||
"whats_new_goals_body" = "Gli stati vuoti migliorati ti aiutano a iniziare più velocemente.";
|
||||
"whats_new_continue" = "Iniziamo!";
|
||||
"source_monthly_contribution_title" = "Contributo Mensile";
|
||||
"source_monthly_contribution_placeholder" = "es. 500";
|
||||
"source_monthly_contribution_not_set" = "Non configurato";
|
||||
"source_monthly_contribution_hint" = "Pre-compilato in Aggiornamento Rapido";
|
||||
"quick_update_contribution_label" = "Contributo";
|
||||
"quick_update_contribution_placeholder" = "Importo";
|
||||
"source_monthly_contribution_apply_title" = "Applica Contributo";
|
||||
"source_monthly_contribution_apply_message" = "Applicare %@ a tutti gli snapshot passati senza contributo?";
|
||||
"source_monthly_contribution_apply_retroactive" = "Applica a tutti i snapshot passati";
|
||||
"source_monthly_contribution_apply_forward" = "Solo da adesso in poi";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "Approfondimenti";
|
||||
"insight_milestone_title" = "Traguardo vicino";
|
||||
"insight_milestone_value" = "%1$@ da %2$@";
|
||||
"insight_ytd_title" = "Da inizio anno";
|
||||
"insight_market_gains_title" = "Guadagni di mercato";
|
||||
"insight_market_gains_value" = "+%@ dai mercati";
|
||||
"insight_streak_title" = "Serie di aggiornamenti";
|
||||
"insight_streak_value" = "%d mesi consecutivi";
|
||||
"insight_forecast_title" = "In direzione di";
|
||||
"insight_allocation_title" = "Scostamento allocazione";
|
||||
"insight_allocation_over_value" = "%1$@ è al %2$.0f%% — sopra il tuo obiettivo del %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ è al %2$.0f%% — sotto il tuo obiettivo del %3$.0f%%";
|
||||
"insight_allocation_detail" = "Riequilibra per restare nel piano";
|
||||
"insight_concentration_title" = "Rischio di concentrazione";
|
||||
"insight_concentration_value" = "%1$@ è il %2$.0f%% del tuo portafoglio";
|
||||
"insight_concentration_detail" = "Valuta di diversificare";
|
||||
"insight_best_performer_title" = "Miglior rendimento";
|
||||
"insight_worst_performer_title" = "Richiede attenzione";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversificazione";
|
||||
"insight_diversification_single_value" = "Tutto in un'unica fonte — valuta di aggiungerne un'altra";
|
||||
"insight_diversification_good_value" = "Distribuito su %d categorie";
|
||||
"goal_projection_projected" = "Previsione: %@";
|
||||
"goal_projection_add_contributions" = "Aggiungi versamenti per raggiungerlo";
|
||||
"goal_chip_ahead" = "In anticipo";
|
||||
"goal_chip_on_track" = "In linea";
|
||||
"goal_chip_behind" = "In ritardo";
|
||||
"goal_chip_at_this_pace" = "A questo ritmo…";
|
||||
"notification_milestone_title" = "Traguardo raggiunto! 🎉";
|
||||
"notification_milestone_body" = "Il tuo portafoglio ha appena superato %@. Complimenti!";
|
||||
|
||||
"streak_protection_notification_title" = "Non interrompere la tua serie 🔥";
|
||||
"streak_protection_notification_body" = "Il check-in di questo mese è ancora in sospeso. Bastano 2 minuti per mantenere viva la serie.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Più account";
|
||||
"paywall_benefit_accounts_subtitle" = "Portafogli separati per famiglia o lavoro";
|
||||
"paywall_benefit_family_title" = "In famiglia";
|
||||
"paywall_benefit_family_subtitle" = "Un acquisto, fino a 5 membri della famiglia";
|
||||
"paywall_plan_annual" = "Annuale";
|
||||
"paywall_plan_annual_per_year" = "%@ / anno";
|
||||
"paywall_plan_lifetime" = "Per sempre";
|
||||
"paywall_plan_lifetime_badge" = "MIGLIOR VALORE";
|
||||
"paywall_trial_days" = "giorni";
|
||||
"paywall_trial_weeks" = "settimane";
|
||||
"paywall_trial_months" = "mesi";
|
||||
"paywall_trial_years" = "anni";
|
||||
"paywall_trial_format" = "%d %@ gratis";
|
||||
|
||||
"chart_history_locked" = "%d mesi di storico in più — sblocca con Premium";
|
||||
|
||||
"chart_group_overview" = "Panoramica";
|
||||
"chart_group_analyze" = "Analisi";
|
||||
"chart_group_risk" = "Rischio";
|
||||
"chart_group_forecast" = "Previsione";
|
||||
"kpi_total_value" = "Valore totale";
|
||||
"kpi_period_return" = "Rendimento periodo";
|
||||
"kpi_cagr" = "CAGR";
|
||||
"kpi_volatility" = "Volatilità";
|
||||
"kpi_max_drawdown" = "Drawdown max";
|
||||
"chart_zoom_in" = "Ingrandisci";
|
||||
"chart_zoom_out" = "Riduci";
|
||||
"chart_zoom_reset" = "Reimposta zoom";
|
||||
|
||||
"quick_update_paste_suggestion" = "Incolla %@ in %@";
|
||||
"quick_update_next_badge" = "PROSSIMA";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "%1$@ salvato per %2$@.";
|
||||
"intent_amount_invalid" = "L’importo deve essere maggiore di zero. Quanto?";
|
||||
"Log Portfolio Value" = "Registra valore del portafoglio";
|
||||
"Record a value for one of your sources without opening the app." = "Registra un valore per una delle tue fonti senza aprire l’app.";
|
||||
"Amount" = "Importo";
|
||||
"How much?" = "Quanto?";
|
||||
"Log Value" = "Registra valore";
|
||||
"Update Portfolio" = "Aggiorna portafoglio";
|
||||
"Check Portfolio" = "Controlla portafoglio";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "Condividi grafico";
|
||||
"chart_share_text" = "Il mio grafico %@ — creato con Portfolio Journal";
|
||||
"chart_share_tagline" = "Monitoraggio degli investimenti privato e sereno per iPhone e iPad.";
|
||||
"chart_share_scan" = "Scansiona per scaricare";
|
||||
"chart_locked_title" = "%@ è un grafico Premium";
|
||||
"chart_locked_cta" = "Sblocca Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in di %@ completato";
|
||||
"checkin_done_next" = "Prossimo: %@";
|
||||
"checkin_next_due" = "Prossimo check-in: %@";
|
||||
"checkin_days_left" = "%d giorni rimasti";
|
||||
"checkin_due_today" = "Oggi";
|
||||
"checkin_overdue" = "In ritardo";
|
||||
"dash_chart_yoy" = "Anno su anno";
|
||||
"home_chart_locked_sub" = "Sblocca con Premium per vedere questo grafico.";
|
||||
"checkin_sources_progress" = "%1$d di %2$d fonti aggiornate";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mese scorso";
|
||||
"guided_paste_amount" = "Incolla %@";
|
||||
"guided_next" = "Avanti";
|
||||
"guided_same_as_last" = "Come il mese scorso";
|
||||
"guided_skip" = "Salta";
|
||||
"guided_reflection_title" = "Come senti questo mese?";
|
||||
"guided_reflection_subtitle" = "Una nota veloce per il te del futuro.";
|
||||
"guided_note_placeholder" = "Una riga sincera su questo mese…";
|
||||
"guided_finish" = "Concludi il check-in";
|
||||
"guided_done_title" = "Check-in di %@ completato";
|
||||
"guided_streak" = "%d mesi di fila";
|
||||
"guided_share_month" = "Condividi questo mese";
|
||||
"guided_done_cta" = "Fatto";
|
||||
"journal_no_note" = "Ancora nessuna nota.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Versione %@";
|
||||
"whats_new_150_redesign_title" = "Un look più calmo e pulito";
|
||||
"whats_new_150_redesign_body" = "Home ridisegnata con il tuo valore totale in primo piano, il check-in al centro e meno rumore ovunque.";
|
||||
"whats_new_150_guided_title" = "Check-in mensile guidato";
|
||||
"whats_new_150_guided_body" = "Una fonte per passo, delta in tempo reale, una riflessione veloce — il tuo rituale di 5 minuti, ripensato.";
|
||||
"whats_new_150_rings_title" = "Anelli obiettivi & timeline";
|
||||
"whats_new_150_rings_body" = "Anelli di progresso stile Fitness per gli obiettivi e una timeline della tua storia da investitore.";
|
||||
"whats_new_142_ocr_title" = "Aggiorna da uno screenshot";
|
||||
"whats_new_142_ocr_body" = "Condividi uno screenshot della tua banca — il saldo viene riconosciuto sul dispositivo, nulla lascia il tuo iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri nella tua lingua";
|
||||
"whats_new_142_siri_body" = "\"Registra il saldo di Indexa\" — registra valori con la voce o dai Comandi rapidi, senza aprire l'app.";
|
||||
"whats_new_142_charts_title" = "Grafici rinnovati";
|
||||
"whats_new_142_charts_body" = "Barra laterale nativa su iPad, KPI su misura per ogni grafico e condivisione come belle immagini.";
|
||||
"whats_new_141_extension_title" = "Aggiorna senza cambiare app";
|
||||
"whats_new_141_extension_body" = "Condividi un valore dall'app della tua banca direttamente in Portfolio Journal con la nuova estensione.";
|
||||
"whats_new_141_ipad_title" = "Meglio su iPad";
|
||||
"whats_new_141_ipad_body" = "Grafici ridisegnati per lo schermo grande, con zoom ed etichette dei valori.";
|
||||
"whats_new_141_icloud_title" = "Più sincronizzato";
|
||||
"whats_new_141_icloud_body" = "I contributi mensili e gli obiettivi di allocazione ora si sincronizzano via iCloud.";
|
||||
"whats_new_title_format" = "Novità della %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Aggiorna tutto in pochi secondi";
|
||||
"tip_quick_update_body" = "Quick Update elenca tutte le tue fonti in una schermata — incolla i valori dagli appunti e via.";
|
||||
"tip_ocr_title" = "Oppure condividi uno screenshot";
|
||||
"tip_ocr_body" = "Dall'app della tua banca: screenshot → Condividi → Portfolio Journal. Il saldo viene letto sul dispositivo.";
|
||||
"tip_siri_title" = "La prossima volta prova con la voce";
|
||||
"tip_siri_body" = "Di' a Siri \"Registra il saldo del mio conto in Portfolio Journal\" — senza aprire l'app.";
|
||||
"tip_chart_share_title" = "Condividi questo grafico";
|
||||
"tip_chart_share_body" = "Genera una bella immagine con i tuoi dati, pronta per X o WhatsApp.";
|
||||
"allocation_total" = "Totale";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "iCloud ha rifiutato alcuni record. Di solito significa che lo schema iCloud è ancora in configurazione — spesso si risolve in poche ore o dopo il prossimo aggiornamento.";
|
||||
"icloud_hint_quota" = "Il tuo spazio iCloud è pieno. Libera spazio e riprova.";
|
||||
"icloud_hint_network" = "Impossibile raggiungere iCloud. Controlla la connessione e riprova.";
|
||||
"icloud_hint_partial_generic" = "Alcuni record non si sono sincronizzati stavolta. L'app riproverà automaticamente.";
|
||||
"icloud_status_error" = "Problema di sincronizzazione iCloud";
|
||||
"icloud_status_syncing" = "Sincronizzazione con iCloud";
|
||||
"icloud_show_details" = "Mostra dettagli tecnici";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Anno migliore";
|
||||
"yoy_worst_year" = "Anno peggiore";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Incolla %@";
|
||||
"quick_update_scan" = "Scansiona";
|
||||
"quick_update_done_kbd" = "Fine";
|
||||
"quick_update_scanning" = "Lettura immagine…";
|
||||
"quick_update_pick_amount" = "Quale importo?";
|
||||
"quick_update_no_amount_found" = "Nessun importo riconosciuto nell’immagine.";
|
||||
"icloud_copy_diagnostics" = "Copia diagnostica iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnostica copiata negli appunti";
|
||||
|
||||
"Smooth Gaps in Charts" = "Uniforma i vuoti nei grafici";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Mancano i dati di %d mesi";
|
||||
"gaps_missing_count" = "%d mancanti";
|
||||
"gaps_range_between" = "Tra %@ e %@";
|
||||
"Fill in missing data" = "Completa i dati mancanti";
|
||||
"Missing Data" = "Dati mancanti";
|
||||
"Fill Gap" = "Colma il vuoto";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Il grafico stima questi mesi. Aggiungi valori reali per mantenere accurata la cronologia.";
|
||||
"Dismiss" = "Ignora";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d mesi";
|
||||
"Monthly Summary" = "Riepilogo mensile";
|
||||
"Portfolio Value" = "Valore del portafoglio";
|
||||
"Tracked with" = "Registrato con";
|
||||
"Share monthly summary" = "Condividi il riepilogo mensile";
|
||||
"since last check-in" = "dall’ultimo check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Niente di nuovo da importare. I tuoi dati sono già aggiornati.";
|
||||
"import_preview_sources" = "Crea %d fonti";
|
||||
"import_preview_snapshots_new" = "Crea %d rilevazioni";
|
||||
"import_preview_snapshots_update" = "Aggiorna %d rilevazioni";
|
||||
"import_preview_categories" = "Crea %d categorie";
|
||||
"import_preview_goals" = "Crea %d obiettivi";
|
||||
"import_preview_journal_new" = "Crea %d voci del diario";
|
||||
"import_preview_journal_update" = "Aggiorna %d voci del diario";
|
||||
"Review Import" = "Rivedi importazione";
|
||||
"Reviewing import…" = "Revisione importazione…";
|
||||
"Import" = "Importa";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Stimato";
|
||||
"gaps_autofill_cta" = "Riempi %d mesi";
|
||||
"Add manually" = "Aggiungi manualmente";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Il riempimento automatico stima i mesi mancanti tra due snapshot (contrassegnati come stimati). Oppure inserisci tu i valori reali.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Backup automatico";
|
||||
"Off" = "Disattivato";
|
||||
"Daily" = "Giornaliero";
|
||||
"Weekly" = "Settimanale";
|
||||
"Monthly" = "Mensile";
|
||||
"Last auto backup" = "Ultimo backup automatico";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Nascondi saldi";
|
||||
"Show balances" = "Mostra saldi";
|
||||
"Balance hidden" = "Saldo nascosto";
|
||||
"Reveal your balances" = "Mostra i tuoi saldi";
|
||||
"Privacy" = "Privacy";
|
||||
"Hide Balances" = "Nascondi saldi";
|
||||
"Require Face ID to Reveal" = "Richiedi Face ID per mostrare";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi Face ID per mostrarli.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Non hai ancora registrato alcun valore di portafoglio.";
|
||||
"intent_networth_dialog" = "Il tuo patrimonio è %@.";
|
||||
"intent_streak" = "Sei a una serie di %d mesi.";
|
||||
"intent_streak_with_date" = "Sei a una serie di %d mesi. Il tuo ultimo check-in è stato il %@.";
|
||||
"intent_streak_none" = "Non hai ancora una serie attiva. Registra un valore per iniziarne una.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Intervallo personalizzato";
|
||||
"chart_range_from" = "Da";
|
||||
"chart_range_to" = "A";
|
||||
"chart_filtered_prefix" = "Filtrato: %@";
|
||||
"chart_overlay_contributions" = "vs Versamenti";
|
||||
"chart_insight_best_month" = "Miglior mese del periodo: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Condividi i tuoi progressi";
|
||||
"share_percent_only" = "Solo percentuali";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Storia";
|
||||
"share_card_cta" = "Condividi";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Rendimento totale";
|
||||
"share_metric_yoy" = "Quest'anno";
|
||||
"share_metric_since_inception" = "Dall'inizio";
|
||||
"share_cta_headline" = "Monitora il tuo — gratis";
|
||||
"share_cta_sub" = "Scansiona per Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Al %@";
|
||||
"share_theme_dark" = "Scuro";
|
||||
"share_theme_light" = "Chiaro";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valore di oggi";
|
||||
"add_source_value_footer" = "Salvato come primo check-in: i tuoi progressi iniziano oggi.";
|
||||
"quick_update_add_first_source" = "Aggiungi la tua prima fonte";
|
||||
@@ -0,0 +1,679 @@
|
||||
"app_name" = "Portfolio Journal";
|
||||
"ok" = "OK";
|
||||
"cancel" = "キャンセル";
|
||||
"save" = "保存";
|
||||
"delete" = "削除";
|
||||
"edit" = "編集";
|
||||
"add" = "追加";
|
||||
"done" = "完了";
|
||||
"close" = "閉じる";
|
||||
"continue" = "続ける";
|
||||
"skip" = "スキップ";
|
||||
"error" = "エラー";
|
||||
"success" = "成功";
|
||||
"loading" = "読み込み中...";
|
||||
"loading_data" = "データを読み込み中...";
|
||||
"tab_dashboard" = "ホーム";
|
||||
"tab_sources" = "ソース";
|
||||
"tab_charts" = "チャート";
|
||||
"tab_settings" = "設定";
|
||||
"dashboard_title" = "ホーム";
|
||||
"total_portfolio_value" = "ポートフォリオ合計額";
|
||||
"today" = "今日";
|
||||
"returns" = "リターン";
|
||||
"by_category" = "カテゴリ別";
|
||||
"pending_updates" = "未更新";
|
||||
"see_all" = "すべて表示";
|
||||
"sources_title" = "ソース";
|
||||
"add_source" = "ソースを追加";
|
||||
"source_name" = "ソース名";
|
||||
"select_category" = "カテゴリを選択";
|
||||
"initial_value" = "初期金額";
|
||||
"initial_value_optional" = "初期金額(任意)";
|
||||
"reminder_frequency" = "リマインダー頻度";
|
||||
"source_limit_warning" = "ソース数の上限に達しました。Premium にアップグレードすると無制限になります。";
|
||||
"no_sources" = "投資ソースがありません";
|
||||
"no_sources_message" = "最初の投資ソースを追加してポートフォリオの追跡を始めましょう。";
|
||||
"add_snapshot" = "スナップショットを追加";
|
||||
"edit_snapshot" = "スナップショットを編集";
|
||||
"snapshot_date" = "日付";
|
||||
"snapshot_value" = "金額";
|
||||
"snapshot_contribution" = "追加資金";
|
||||
"contribution_optional" = "追加資金(任意)";
|
||||
"notes" = "メモ";
|
||||
"notes_optional" = "メモ(任意)";
|
||||
"previous_value" = "前回: %@";
|
||||
"change_from_previous" = "前回からの変化";
|
||||
"charts_title" = "チャート";
|
||||
"evolution" = "推移";
|
||||
"allocation" = "配分";
|
||||
"performance" = "パフォーマンス";
|
||||
"drawdown" = "ドローダウン";
|
||||
"volatility" = "ボラティリティ";
|
||||
"prediction" = "予測";
|
||||
"portfolio_evolution" = "ポートフォリオ推移";
|
||||
"asset_allocation" = "資産配分";
|
||||
"performance_by_category" = "カテゴリ別パフォーマンス";
|
||||
"drawdown_analysis" = "ドローダウン分析";
|
||||
"prediction_12_month" = "12か月予測";
|
||||
"not_enough_data" = "データが不足しています";
|
||||
"cagr" = "CAGR";
|
||||
"twr" = "TWR";
|
||||
"max_drawdown" = "最大ドローダウン";
|
||||
"sharpe_ratio" = "シャープレシオ";
|
||||
"win_rate" = "勝率";
|
||||
"avg_monthly" = "月平均";
|
||||
"best_month" = "最高の月";
|
||||
"worst_month" = "最悪の月";
|
||||
"premium" = "Premium";
|
||||
"upgrade_to_premium" = "Premium にアップグレード";
|
||||
"unlock_full_potential" = "すべての機能を解放";
|
||||
"one_time_purchase" = "買い切り";
|
||||
"includes_family_sharing" = "ファミリー共有対応";
|
||||
"upgrade_now" = "今すぐアップグレード";
|
||||
"restore_purchases" = "購入を復元";
|
||||
"premium_active" = "Premium 有効";
|
||||
"premium_feature" = "Premium 機能";
|
||||
"unlock" = "ロック解除";
|
||||
"feature_unlimited_sources" = "無制限のソース";
|
||||
"feature_unlimited_sources_desc" = "好きなだけ投資を追跡できます";
|
||||
"feature_full_history" = "完全な履歴";
|
||||
"feature_full_history_desc" = "投資履歴をすべて確認できます";
|
||||
"feature_advanced_charts" = "高度なチャート";
|
||||
"feature_advanced_charts_desc" = "詳細分析チャート 5 種類";
|
||||
"feature_predictions" = "予測";
|
||||
"feature_predictions_desc" = "AI による12か月予測";
|
||||
"feature_export" = "データを書き出し";
|
||||
"feature_export_desc" = "CSV と JSON に書き出し";
|
||||
"feature_no_ads" = "広告なし";
|
||||
"feature_no_ads_desc" = "ずっと広告なしで使えます";
|
||||
"paywall_benefit_history_title" = "完全な履歴";
|
||||
"paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認";
|
||||
"paywall_benefit_charts_title" = "傾向が見えるチャート";
|
||||
"paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認";
|
||||
"paywall_benefit_forecasts_title" = "12か月予測";
|
||||
"paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます";
|
||||
"paywall_benefit_noads_title" = "広告は一切なし";
|
||||
"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験";
|
||||
"settings_title" = "設定";
|
||||
"subscription" = "サブスクリプション";
|
||||
"notifications" = "通知";
|
||||
"default_reminder_time" = "デフォルトの通知時刻";
|
||||
"data" = "データ";
|
||||
"export_data" = "データを書き出し";
|
||||
"total_sources" = "ソース合計";
|
||||
"total_snapshots" = "スナップショット合計";
|
||||
"storage_used" = "使用ストレージ";
|
||||
"about" = "このアプリについて";
|
||||
"version" = "バージョン";
|
||||
"privacy_policy" = "プライバシーポリシー";
|
||||
"terms_of_service" = "利用規約";
|
||||
"support" = "サポート";
|
||||
"rate_app" = "アプリを評価";
|
||||
"danger_zone" = "危険ゾーン";
|
||||
"reset_all_data" = "すべてのデータをリセット";
|
||||
"reset_confirmation" = "すべての投資データが完全に削除されます。この操作は元に戻せません。";
|
||||
"frequency_monthly" = "毎月";
|
||||
"frequency_quarterly" = "四半期ごと";
|
||||
"frequency_semiannual" = "半年ごと";
|
||||
"frequency_annual" = "毎年";
|
||||
"frequency_custom" = "カスタム";
|
||||
"frequency_never" = "なし";
|
||||
"every_n_months" = "%d か月ごと";
|
||||
"category_stocks" = "株式";
|
||||
"category_bonds" = "債券";
|
||||
"category_real_estate" = "不動産";
|
||||
"category_crypto" = "暗号資産";
|
||||
"category_cash" = "現金";
|
||||
"category_etfs" = "ETF";
|
||||
"category_retirement" = "退職資産";
|
||||
"category_other" = "その他";
|
||||
"uncategorized" = "未分類";
|
||||
"time_1m" = "1M";
|
||||
"time_3m" = "3M";
|
||||
"time_6m" = "6M";
|
||||
"time_1y" = "1Y";
|
||||
"time_all" = "すべて";
|
||||
"export_format" = "形式を選択";
|
||||
"export_csv" = "CSV";
|
||||
"export_csv_desc" = "Excel、Google Sheets に対応";
|
||||
"export_json" = "JSON";
|
||||
"export_json_desc" = "バックアップ用の完全なデータ構造";
|
||||
"onboarding_track_title" = "投資をまとめて管理";
|
||||
"onboarding_track_desc" = "株式、債券、不動産、暗号資産など、すべての投資ソースを一か所で追跡できます。";
|
||||
"onboarding_visualize_title" = "成長を可視化";
|
||||
"onboarding_visualize_desc" = "美しいチャートでポートフォリオの推移、配分、パフォーマンスを確認できます。";
|
||||
"onboarding_reminders_title" = "更新を忘れない";
|
||||
"onboarding_reminders_desc" = "毎月、四半期、またはカスタムで、定期的に投資を記録するためのリマインダーを設定できます。";
|
||||
"onboarding_sync_title" = "どこでも同期";
|
||||
"onboarding_sync_desc" = "データは iCloud で自動同期され、すべての Apple デバイスで利用できます。";
|
||||
"get_started" = "始める";
|
||||
"onboarding_clarity_title" = "今の立ち位置がすぐ分かる";
|
||||
"onboarding_clarity_desc" = "総資産、実質リターン、配分をいつでも最新の状態で確認できます。";
|
||||
"onboarding_habit_title" = "月に5分で十分";
|
||||
"onboarding_habit_desc" = "月に一度金額を入力するだけ。計算は Portfolio Journal が行い、進捗を見せてくれます。";
|
||||
"onboarding_calm_title" = "ノイズを無視して、流れを見る。";
|
||||
"onboarding_calm_desc" = "日々の値動きは本当の物語ではありません。数か月、数年の成長こそが大切です。";
|
||||
"onboarding_goals_title" = "お金の目標に近づく";
|
||||
"onboarding_goals_desc" = "目標を設定し、節目を追跡し、どこまで進んだかを把握できます。";
|
||||
"error_generic" = "エラーが発生しました。もう一度お試しください。";
|
||||
"error_no_purchases" = "復元できる購入が見つかりません";
|
||||
"error_purchase_failed" = "購入に失敗しました: %@";
|
||||
"error_export_failed" = "書き出しに失敗しました。もう一度お試しください。";
|
||||
"placeholder_source_name" = "例: Vanguard 401k";
|
||||
"placeholder_value" = "0.00";
|
||||
"placeholder_notes" = "メモを追加...";
|
||||
"mood_energized_title" = "絶好調";
|
||||
"mood_confident_title" = "自信あり";
|
||||
"mood_balanced_title" = "安定";
|
||||
"mood_cautious_title" = "慎重";
|
||||
"mood_stressed_title" = "ストレス";
|
||||
"mood_energized_detail" = "無敵な気分";
|
||||
"mood_confident_detail" = "順調で落ち着いている";
|
||||
"mood_balanced_detail" = "冷静で辛抱強い";
|
||||
"mood_cautious_detail" = "動きを見守っている";
|
||||
"mood_stressed_detail" = "一度リセットしたい";
|
||||
"achievement_streak_3_title" = "3か月連続";
|
||||
"achievement_streak_3_detail" = "3か月連続で期限内にチェックインしました。";
|
||||
"achievement_streak_6_title" = "半年連続";
|
||||
"achievement_streak_6_detail" = "6回連続で期限内にチェックインしました。";
|
||||
"achievement_streak_12_title" = "1年の勢い";
|
||||
"achievement_streak_12_detail" = "1年間、一度も期限を逃しませんでした。";
|
||||
"achievement_perfect_on_time_title" = "一度も遅れなし";
|
||||
"achievement_perfect_on_time_detail" = "すべてのチェックインが期限前に完了しました。";
|
||||
"achievement_clutch_finish_title" = "ギリギリ成功";
|
||||
"achievement_clutch_finish_detail" = "残り数時間で提出、それでも期限内。";
|
||||
"achievement_early_bird_title" = "早め派";
|
||||
"achievement_early_bird_detail" = "平均するとかなり余裕を持って終えています。";
|
||||
"achievements_title" = "実績";
|
||||
"achievements_view_all" = "すべての実績を見る";
|
||||
"achievements_nav_title" = "実績";
|
||||
"achievements_progress_title" = "進捗";
|
||||
"achievements_unlocked_title" = "解除済み";
|
||||
"achievements_unlocked_empty" = "チェックインを完了して実績を解除しましょう。";
|
||||
"achievements_locked_title" = "未解除";
|
||||
"achievements_locked_empty" = "すべての実績を解除しました。すばらしいです。";
|
||||
"rating_accessibility" = "評価 %d / 5";
|
||||
"achievements_unlocked_count" = "%d / %d を解除";
|
||||
"last_check_in" = "前回のチェックイン: %@";
|
||||
"next_check_in" = "次回のチェックイン: %@";
|
||||
"on_time_rate" = "%@ が期限内";
|
||||
"on_time_count" = "%d/%d が期限内";
|
||||
"tightest_finish" = "最もギリギリだった完了: 締切の %@ 前。";
|
||||
"date_today" = "今日";
|
||||
"date_yesterday" = "昨日";
|
||||
"date_never" = "なし";
|
||||
"calendar_event_title" = "%@: 月次チェックイン";
|
||||
"calendar_event_notes" = "%@ を開いて月次チェックインを完了してください。";
|
||||
"checkin_enjoying_dialog_title" = "Portfolio Journal をどのくらい気に入っていますか?";
|
||||
"checkin_enjoying_dialog_message" = "新しい実績おめでとうございます。ご意見は改善に役立ちます。";
|
||||
"not_now" = "今はしない";
|
||||
"rating_1_star" = "1つ星";
|
||||
"rating_n_stars" = "%dつ星";
|
||||
"app_store_review_title" = "App Store にレビューを残しますか?";
|
||||
"app_store_review_message" = "5つ星ありがとうございます。ほかの投資家がアプリを見つけやすくなります。";
|
||||
"write_review" = "レビューを書く";
|
||||
"save_1_snapshot" = "1件の snapshot を保存";
|
||||
"save_n_snapshots" = "%d件の snapshot を保存";
|
||||
"checkin_update_month" = "%@ を更新";
|
||||
"checkin_start_new" = "開始";
|
||||
"Home" = "ホーム";
|
||||
"Sources" = "ソース";
|
||||
"Charts" = "チャート";
|
||||
"Settings" = "設定";
|
||||
"Journal" = "ジャーナル";
|
||||
"Search monthly notes" = "月次メモを検索";
|
||||
"Monthly Check-ins" = "月次チェックイン";
|
||||
"No monthly notes yet." = "まだ月次メモがありません。";
|
||||
"No matching notes." = "一致するメモがありません。";
|
||||
"Jump to month" = "月へ移動";
|
||||
"Today" = "今日";
|
||||
"Mood not set" = "気分未設定";
|
||||
"No rating" = "評価なし";
|
||||
"No note yet." = "まだメモがありません。";
|
||||
"Monthly Note" = "月次メモ";
|
||||
"Open Full Note" = "全文を開く";
|
||||
"Duplicate Previous" = "前回を複製";
|
||||
"Save" = "保存";
|
||||
"Monthly Check-in" = "月次チェックイン";
|
||||
"This Month" = "今月";
|
||||
"No check-in yet this month" = "今月はまだチェックインがありません";
|
||||
"Start your first check-in anytime." = "いつでも最初のチェックインを始められます。";
|
||||
"Mark Check-in Complete" = "チェックインを完了にする";
|
||||
"Editing stays open. New check-ins unlock after 70% of the month." = "編集は開いたままです。新しいチェックインは月の70%経過後に解放されます。";
|
||||
"Momentum & Streaks" = "勢いと連続記録";
|
||||
"Log a check-in to start a streak" = "チェックインを記録して連続記録を始めましょう";
|
||||
"Streak" = "連続記録";
|
||||
"On-time in a row" = "連続期限内";
|
||||
"Best" = "最高";
|
||||
"Personal best" = "自己ベスト";
|
||||
"Avg early" = "平均余裕";
|
||||
"vs deadline" = "締切比";
|
||||
"On-time score" = "期限内スコア";
|
||||
"Achievements" = "実績";
|
||||
"View all achievements" = "すべての実績を見る";
|
||||
"Monthly Pulse" = "月次パルス";
|
||||
"Optional" = "任意";
|
||||
"Rate this month" = "今月を評価";
|
||||
"How did it feel?" = "どんな気分でしたか?";
|
||||
"Monthly Summary" = "月次サマリー";
|
||||
"Starting" = "開始";
|
||||
"Ending" = "終了";
|
||||
"Contributions" = "追加資金";
|
||||
"Net Performance" = "純パフォーマンス";
|
||||
"Update Sources" = "ソースを更新";
|
||||
"Add sources to start your monthly check-in." = "月次チェックインを始めるにはソースを追加してください。";
|
||||
"Updated this cycle" = "このサイクルで更新済み";
|
||||
"Needs update" = "更新が必要";
|
||||
"Snapshot Notes" = "スナップショットのメモ";
|
||||
"No snapshot notes for this month." = "今月のスナップショットメモはありません。";
|
||||
"Source" = "ソース";
|
||||
"Your full portfolio,\nfully clear" = "あなたのポートフォリオ全体を、\nもっと明確に";
|
||||
"One payment. Every feature. Forever." = "一度の支払い。すべての機能を。ずっと。";
|
||||
"Get Full Access" = "フルアクセスを取得";
|
||||
"Restore Purchases" = "購入を復元";
|
||||
"Payment charged to your Apple ID account." = "料金は Apple ID アカウントに請求されます。";
|
||||
"Terms" = "利用規約";
|
||||
"Privacy" = "プライバシー";
|
||||
"· one-time · Family Sharing" = "· 買い切り · ファミリー共有";
|
||||
"Full access, one payment" = "フルアクセスを一度の支払いで";
|
||||
"Unlimited sources, advanced charts & more" = "無制限のソース、高度なチャートなど";
|
||||
"See full access" = "フルアクセスを見る";
|
||||
"Batch Update" = "一括更新";
|
||||
"Current value" = "現在の金額";
|
||||
"Contribution this period (optional)" = "今回の追加資金(任意)";
|
||||
"Include Contribution" = "追加資金を含める";
|
||||
"New capital added" = "新たに追加した資金";
|
||||
"Contribution (Optional)" = "追加資金(任意)";
|
||||
"Track new capital added to separate it from investment growth." = "投資の成長と分けるため、新たに追加した資金を記録します。";
|
||||
"Monthly Highlights" = "今月のハイライト";
|
||||
"Best Performer" = "最高のパフォーマー";
|
||||
"Worst Performer" = "最も低調だったもの";
|
||||
"Best Contributor" = "最大の貢献元";
|
||||
"Update Check-in" = "チェックインを更新";
|
||||
"Completed %@" = "%@ に完了";
|
||||
|
||||
// MARK: - Missing keys added (1.3.x)
|
||||
"Checking…" = "確認中…";
|
||||
"Clear Filters" = "フィルターをクリア";
|
||||
"Force Upload to iCloud" = "iCloudへ強制アップロード";
|
||||
"No export yet" = "まだエクスポートなし";
|
||||
"Not synced yet" = "まだ同期されていません";
|
||||
"Refresh" = "更新";
|
||||
"Search sources" = "ソースを検索";
|
||||
"Syncing with iCloud..." = "iCloudと同期中...";
|
||||
"Uploading..." = "アップロード中...";
|
||||
"Verify iCloud Setup" = "iCloud設定を確認";
|
||||
"add_source_name_footer" = "ソースとは追跡したい投資のことです:株式、ETF、普通預金、不動産、暗号資産など。";
|
||||
"add_source_name_placeholder" = "例:MSCI World ETF、普通預金、マンション...";
|
||||
"categories_empty" = "カテゴリーがまだありません。";
|
||||
"category_has_sources_warning" = "ソースがあるカテゴリーは削除できません。先にすべてのソースを削除または再割り当てしてください。";
|
||||
"category_name_placeholder" = "例:緊急資金";
|
||||
"chart_yoy_empty" = "比較を表示するには、異なる年のスナップショットを追加してください。";
|
||||
"chart_yoy_title" = "年次比較";
|
||||
"chart_yoy_estimated_note" = "* 推定(年末予測)";
|
||||
"checking_icloud" = "iCloudを確認中...";
|
||||
"contributions_vs_returns_invested" = "投資額";
|
||||
"contributions_vs_returns_returns" = "市場リターン";
|
||||
"contributions_vs_returns_title" = "投資額 vs. リターン";
|
||||
"csv_enter_value" = "値を入力";
|
||||
"csv_field_category" = "カテゴリー";
|
||||
"csv_field_contribution" = "追加投資";
|
||||
"csv_field_date" = "日付";
|
||||
"csv_field_notes" = "メモ";
|
||||
"csv_field_source" = "ソース名(必須)";
|
||||
"csv_field_value" = "値(必須)";
|
||||
"csv_mapping_subtitle" = "CSVの列をPortfolio Journalのフィールドに対応付けてください";
|
||||
"csv_no_column" = "— 未マッピング —";
|
||||
"csv_no_date_hint" = "すべての行が今日のスナップショットとしてインポートされます。";
|
||||
"csv_optional_section" = "任意フィールド";
|
||||
"csv_preview_section" = "CSVプレビュー";
|
||||
"csv_required_section" = "必須フィールド";
|
||||
"csv_use_today" = "今日の日付を使用";
|
||||
"goal_achieved_notification_body" = "目標を達成しました:%@";
|
||||
"goal_achieved_notification_title" = "目標達成! 🎉";
|
||||
"goal_archive" = "アーカイブ";
|
||||
"goal_delete_confirm" = "削除";
|
||||
"goal_delete_message" = "この目標は完全に削除されます。この操作は取り消せません。";
|
||||
"goal_delete_title" = "目標を削除";
|
||||
"goal_unarchive" = "元に戻す";
|
||||
"goals_all_active_achieved" = "すべてのアクティブな目標が達成されました。";
|
||||
"goals_empty_archived" = "アーカイブされた目標はありません。";
|
||||
"goals_filter_active" = "アクティブ";
|
||||
"goals_filter_all" = "すべて";
|
||||
"goals_filter_archived" = "アーカイブ済み";
|
||||
"icloud_check_description" = "他のデバイスにデータがある場合は、iCloudを有効にしてここで復元してください。";
|
||||
"icloud_check_title" = "すでにPortfolio Journalをお使いですか?";
|
||||
"icloud_enabled_description" = "アプリを閉じて再度開いてください。データはiCloudから自動的に読み込まれます。";
|
||||
"icloud_enabled_title" = "iCloudが有効になりました";
|
||||
"monthly_checkin_notification_body" = "今月の値を記録して、ポートフォリオの成長を確認しましょう。";
|
||||
"monthly_checkin_notification_title" = "月次アップデートの時間です";
|
||||
"onboarding_add_first_source" = "最初の投資を追加";
|
||||
"onboarding_import_data" = "既存のデータをインポート";
|
||||
"onboarding_quickstart_subtitle" = "最初の投資ソースを追加して、ポートフォリオの追跡を始めましょう。";
|
||||
"onboarding_quickstart_title" = "もうすぐ完了";
|
||||
"reengagement_body" = "数分で投資の状況を把握できます。";
|
||||
"reengagement_goal_title" = "順調です 📈";
|
||||
"reengagement_goal_body" = "このペースなら%2$@までに「%1$@」に到達します。あとどれくらいか見てみましょう。";
|
||||
"reengagement_yoy_title" = "この成長を見て 👀";
|
||||
"reengagement_yoy_body" = "この1年で%@上昇しています。トレンドをチェックしましょう。";
|
||||
"reengagement_month_title" = "好調な月 📈";
|
||||
"reengagement_month_body" = "今月、ポートフォリオが%@増えました。ちょっと見てみましょう。";
|
||||
"reengagement_milestone_title" = "あと少し 🎯";
|
||||
"reengagement_milestone_body" = "もうすぐ%@です。今日はどこまで来たでしょう?";
|
||||
"reengagement_streak_title" = "連続記録を継続 🔥";
|
||||
"reengagement_streak_body" = "%dか月連続で記録しています。ここでやめないで。";
|
||||
"reengagement_neutral_title" = "何か変化は? 👀";
|
||||
"reengagement_neutral_body" = "ポートフォリオに動きがありました。何が変わったか見てみましょう。";
|
||||
"reengagement_title" = "ポートフォリオが待っています";
|
||||
"snapshot_duplicate_add" = "とにかく追加";
|
||||
"snapshot_contribution_propagate_title" = "拠出を適用";
|
||||
"snapshot_contribution_propagate_message" = "%@ を他のスナップショットにも拠出として適用しますか?";
|
||||
"snapshot_contribution_propagate_forward" = "以降に適用";
|
||||
"snapshot_contribution_propagate_backward" = "以前に適用";
|
||||
"snapshot_contribution_propagate_all" = "すべてに適用";
|
||||
"snapshot_contribution_propagate_this" = "このスナップショットのみ";
|
||||
"snapshot_duplicate_message" = "今月のスナップショットはすでにあります。別のスナップショットを追加しますか?";
|
||||
"snapshot_duplicate_replace" = "既存を置き換え";
|
||||
"snapshot_duplicate_title" = "スナップショットがすでに存在します";
|
||||
"sources_filter_all" = "すべて";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "毎月のポートフォリオサマリー";
|
||||
"monthly_summary_notification_body" = "今月の投資パフォーマンスを確認しましょう。";
|
||||
"streak_badge" = "%dヶ月連続";
|
||||
"goals_empty_add_cta" = "最初の目標を追加";
|
||||
"journal_empty_title" = "まだ記録がありません";
|
||||
"journal_empty_body" = "スナップショットの更新により月次エントリが自動的に作成されます。最初のソースを追加して始めましょう。";
|
||||
"quick_update_title" = "クイック更新";
|
||||
"quick_update_section_header" = "現在の値を入力";
|
||||
"quick_update_section_footer" = "値が入力されたソースのみ更新されます。";
|
||||
"quick_update_placeholder" = "新しい値";
|
||||
"quick_update_save" = "すべて保存";
|
||||
"quick_update_no_sources" = "ソースなし";
|
||||
"quick_update_no_sources_body" = "クイック更新を使用するには、まず投資ソースを追加してください。";
|
||||
"update_available_title" = "アップデートあり";
|
||||
"update_available_body" = "バージョン%@がApp Storeで利用可能です。";
|
||||
"whats_new_title" = "1.4の新機能";
|
||||
"whats_new_subtitle" = "軌道を保つための改善点。";
|
||||
"whats_new_quick_update_title" = "ポートフォリオのクイック更新";
|
||||
"whats_new_quick_update_body" = "一つの画面からすべてのソースをワンタップで更新できます。";
|
||||
"whats_new_streak_title" = "更新ストリーク";
|
||||
"whats_new_streak_body" = "何ヶ月連続でポートフォリオを最新の状態に保てたか追跡します。";
|
||||
"whats_new_goals_title" = "スマートな目標とジャーナル";
|
||||
"whats_new_goals_body" = "改善された空の状態でより素早く始められます。";
|
||||
"whats_new_continue" = "始めよう";
|
||||
"source_monthly_contribution_title" = "月次積立額";
|
||||
"source_monthly_contribution_placeholder" = "例: 500";
|
||||
"source_monthly_contribution_not_set" = "未設定";
|
||||
"source_monthly_contribution_hint" = "クイック更新で自動入力されます";
|
||||
"quick_update_contribution_label" = "積立";
|
||||
"quick_update_contribution_placeholder" = "金額";
|
||||
"source_monthly_contribution_apply_title" = "積立の適用";
|
||||
"source_monthly_contribution_apply_message" = "積立なしの過去のスナップショットすべてに%@を適用しますか?";
|
||||
"source_monthly_contribution_apply_retroactive" = "過去のスナップショットにも適用";
|
||||
"source_monthly_contribution_apply_forward" = "今後のみ適用";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "インサイト";
|
||||
"insight_milestone_title" = "目標まであと少し";
|
||||
"insight_milestone_value" = "%2$@まであと%1$@";
|
||||
"insight_ytd_title" = "年初来";
|
||||
"insight_market_gains_title" = "市場利益";
|
||||
"insight_market_gains_value" = "市場から+%@";
|
||||
"insight_streak_title" = "追跡連続記録";
|
||||
"insight_streak_value" = "%dヶ月連続";
|
||||
"insight_forecast_title" = "目標に向けて順調";
|
||||
"insight_allocation_title" = "配分のずれ";
|
||||
"insight_allocation_over_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を上回っています";
|
||||
"insight_allocation_under_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を下回っています";
|
||||
"insight_allocation_detail" = "計画に合わせてリバランスしましょう";
|
||||
"insight_concentration_title" = "集中リスク";
|
||||
"insight_concentration_value" = "%1$@がポートフォリオの%2$.0f%%を占めています";
|
||||
"insight_concentration_detail" = "分散を検討しましょう";
|
||||
"insight_best_performer_title" = "最も好調";
|
||||
"insight_worst_performer_title" = "要注意";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "分散";
|
||||
"insight_diversification_single_value" = "1つの資産に集中 — 追加を検討しましょう";
|
||||
"insight_diversification_good_value" = "%d個のカテゴリに分散";
|
||||
"goal_projection_projected" = "予測: %@";
|
||||
"goal_projection_add_contributions" = "積立を追加して達成しましょう";
|
||||
"goal_chip_ahead" = "前倒し";
|
||||
"goal_chip_on_track" = "順調";
|
||||
"goal_chip_behind" = "遅れ気味";
|
||||
"goal_chip_at_this_pace" = "このペースだと…";
|
||||
"notification_milestone_title" = "ポートフォリオ達成!🎉";
|
||||
"notification_milestone_body" = "ポートフォリオが%@を突破しました。おめでとうございます!";
|
||||
|
||||
"streak_protection_notification_title" = "連続記録を守ろう 🔥";
|
||||
"streak_protection_notification_body" = "今月のチェックインがまだ完了していません。2分で連続記録を守れます。";
|
||||
|
||||
"paywall_benefit_accounts_title" = "複数アカウント";
|
||||
"paywall_benefit_accounts_subtitle" = "家族用・事業用にポートフォリオを分けて管理";
|
||||
"paywall_benefit_family_title" = "ファミリー共有";
|
||||
"paywall_benefit_family_subtitle" = "1回の購入で家族5人まで利用可能";
|
||||
"paywall_plan_annual" = "年額";
|
||||
"paywall_plan_annual_per_year" = "%@ / 年";
|
||||
"paywall_plan_lifetime" = "買い切り";
|
||||
"paywall_plan_lifetime_badge" = "ベストバリュー";
|
||||
"paywall_trial_days" = "日間";
|
||||
"paywall_trial_weeks" = "週間";
|
||||
"paywall_trial_months" = "か月";
|
||||
"paywall_trial_years" = "年間";
|
||||
"paywall_trial_format" = "%d%@無料";
|
||||
|
||||
"chart_history_locked" = "さらに%dか月分の履歴 — プレミアムで解除";
|
||||
|
||||
"chart_group_overview" = "概要";
|
||||
"chart_group_analyze" = "分析";
|
||||
"chart_group_risk" = "リスク";
|
||||
"chart_group_forecast" = "予測";
|
||||
"kpi_total_value" = "合計金額";
|
||||
"kpi_period_return" = "期間リターン";
|
||||
"kpi_cagr" = "CAGR";
|
||||
"kpi_volatility" = "ボラティリティ";
|
||||
"kpi_max_drawdown" = "最大下落率";
|
||||
"chart_zoom_in" = "拡大";
|
||||
"chart_zoom_out" = "縮小";
|
||||
"chart_zoom_reset" = "ズームをリセット";
|
||||
|
||||
"quick_update_paste_suggestion" = "%@ を %@ に貼り付け";
|
||||
"quick_update_next_badge" = "次";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "%2$@に%1$@を保存しました。";
|
||||
"intent_amount_invalid" = "金額はゼロより大きい必要があります。いくらですか?";
|
||||
"Log Portfolio Value" = "ポートフォリオの金額を記録";
|
||||
"Record a value for one of your sources without opening the app." = "アプリを開かずにソースの金額を記録します。";
|
||||
"Amount" = "金額";
|
||||
"How much?" = "いくらですか?";
|
||||
"Log Value" = "金額を記録";
|
||||
"Update Portfolio" = "ポートフォリオを更新";
|
||||
"Check Portfolio" = "ポートフォリオを確認";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "チャートを共有";
|
||||
"chart_share_text" = "私の%@チャート — Portfolio Journalで記録";
|
||||
"chart_share_tagline" = "iPhoneとiPadのためのプライベートで穏やかな資産管理。";
|
||||
"chart_share_scan" = "スキャンしてダウンロード";
|
||||
"chart_locked_title" = "%@はプレミアムチャートです";
|
||||
"chart_locked_cta" = "プレミアムを解除";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "%@のチェックイン完了";
|
||||
"checkin_done_next" = "次回:%@";
|
||||
"checkin_next_due" = "次回のチェックイン:%@";
|
||||
"checkin_days_left" = "あと%d日";
|
||||
"checkin_due_today" = "本日期限";
|
||||
"checkin_overdue" = "期限切れ";
|
||||
"dash_chart_yoy" = "年別比較";
|
||||
"home_chart_locked_sub" = "Premium で解除してこのチャートを表示できます。";
|
||||
"checkin_sources_progress" = "%2$d件中%1$d件のソースを更新済み";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "先月";
|
||||
"guided_paste_amount" = "%@を貼り付け";
|
||||
"guided_next" = "次へ";
|
||||
"guided_same_as_last" = "先月と同じ";
|
||||
"guided_skip" = "スキップ";
|
||||
"guided_reflection_title" = "今月はどんな気分ですか?";
|
||||
"guided_reflection_subtitle" = "未来の自分への簡単なメモ。";
|
||||
"guided_note_placeholder" = "今月について正直な一言…";
|
||||
"guided_finish" = "チェックインを完了";
|
||||
"guided_done_title" = "%@のチェックイン完了";
|
||||
"guided_streak" = "%dか月連続";
|
||||
"guided_share_month" = "今月をシェア";
|
||||
"guided_done_cta" = "完了";
|
||||
"journal_no_note" = "まだメモがありません。";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "バージョン%@";
|
||||
"whats_new_150_redesign_title" = "より穏やかで洗練されたデザイン";
|
||||
"whats_new_150_redesign_body" = "ホームを刷新。総資産を主役に、チェックインを中心に、あらゆる場所のノイズを削減しました。";
|
||||
"whats_new_150_guided_title" = "ガイド付き月次チェックイン";
|
||||
"whats_new_150_guided_body" = "1画面1ソース、リアルタイム差分、簡単な振り返り — 月5分の習慣を再設計しました。";
|
||||
"whats_new_150_rings_title" = "目標リングとジャーナルタイムライン";
|
||||
"whats_new_150_rings_body" = "フィットネス風の進捗リングと、投資の歩みを綴るムードタイムライン。";
|
||||
"whats_new_142_ocr_title" = "スクリーンショットから更新";
|
||||
"whats_new_142_ocr_body" = "銀行アプリのスクリーンショットを共有するだけ。残高はデバイス上で認識され、外部には送信されません。";
|
||||
"whats_new_142_siri_title" = "Siriに対応";
|
||||
"whats_new_142_siri_body" = "「Indexaの残高を記録」— アプリを開かずに音声やショートカットで記録できます。";
|
||||
"whats_new_142_charts_title" = "チャートを一新";
|
||||
"whats_new_142_charts_body" = "iPadのネイティブサイドバー、チャートごとの最適なKPI、美しい画像での共有ボタン。";
|
||||
"whats_new_141_extension_title" = "アプリを切り替えずに更新";
|
||||
"whats_new_141_extension_body" = "新しい共有拡張機能で、銀行アプリから直接Portfolio Journalへ値を送れます。";
|
||||
"whats_new_141_ipad_title" = "iPadでより快適に";
|
||||
"whats_new_141_ipad_body" = "大画面向けにチャートを再設計。ピンチズームと値ラベルに対応。";
|
||||
"whats_new_141_icloud_title" = "さらに同期";
|
||||
"whats_new_141_icloud_body" = "毎月の積立額と配分目標がiCloudでデバイス間同期されるようになりました。";
|
||||
"whats_new_title_format" = "バージョン%@の新機能";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "数秒ですべて更新";
|
||||
"tip_quick_update_body" = "クイック更新なら全ソースを1画面で。クリップボードから値を貼り付けるだけ。";
|
||||
"tip_ocr_title" = "スクリーンショットの共有でもOK";
|
||||
"tip_ocr_body" = "銀行アプリでスクショ → 共有 → Portfolio Journal。残高はデバイス上で読み取られます。";
|
||||
"tip_siri_title" = "次は声で試してみて";
|
||||
"tip_siri_body" = "Siriに「Portfolio Journalで口座の残高を記録」と話しかけるだけ。アプリを開く必要はありません。";
|
||||
"tip_chart_share_title" = "このチャートを共有";
|
||||
"tip_chart_share_body" = "あなたのデータで美しい画像を生成。XやWhatsAppにそのまま共有できます。";
|
||||
"allocation_total" = "合計";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "一部のレコードがiCloudに拒否されました。多くの場合iCloudのスキーマ設定が完了していないためで、数時間の使用後または次回のアップデートで解消されます。";
|
||||
"icloud_hint_quota" = "iCloudの容量がいっぱいです。空き容量を確保して再試行してください。";
|
||||
"icloud_hint_network" = "iCloudに接続できませんでした。接続を確認して再試行してください。";
|
||||
"icloud_hint_partial_generic" = "今回一部のレコードを同期できませんでした。アプリが自動的に再試行します。";
|
||||
"icloud_status_error" = "iCloud同期の問題";
|
||||
"icloud_status_syncing" = "iCloudと同期中";
|
||||
"icloud_show_details" = "技術的な詳細を表示";
|
||||
"yoy_vs_prev" = "%@比";
|
||||
"yoy_best_year" = "最高の年";
|
||||
"yoy_worst_year" = "最悪の年";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "%@を貼り付け";
|
||||
"quick_update_scan" = "スキャン";
|
||||
"quick_update_done_kbd" = "完了";
|
||||
"quick_update_scanning" = "画像を読み取り中…";
|
||||
"quick_update_pick_amount" = "どの金額?";
|
||||
"quick_update_no_amount_found" = "画像から金額を認識できませんでした。";
|
||||
"icloud_copy_diagnostics" = "iCloud診断をコピー";
|
||||
"icloud_diagnostics_copied" = "診断をクリップボードにコピーしました";
|
||||
|
||||
"Smooth Gaps in Charts" = "グラフの空白を補間";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "%dか月分のデータがありません";
|
||||
"gaps_missing_count" = "%d件不足";
|
||||
"gaps_range_between" = "%@〜%@の間";
|
||||
"Fill in missing data" = "不足しているデータを入力";
|
||||
"Missing Data" = "不足データ";
|
||||
"Fill Gap" = "空白を埋める";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "グラフはこれらの月を推定しています。正確な履歴を保つため実際の値を追加してください。";
|
||||
"Dismiss" = "閉じる";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%dか月";
|
||||
"Monthly Summary" = "月間サマリー";
|
||||
"Portfolio Value" = "ポートフォリオの価値";
|
||||
"Tracked with" = "記録に使用";
|
||||
"Share monthly summary" = "月間サマリーを共有";
|
||||
"since last check-in" = "前回のチェックイン以降";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "新しくインポートするものはありません。データはすでに最新です。";
|
||||
"import_preview_sources" = "%d件のソースを作成";
|
||||
"import_preview_snapshots_new" = "%d件のスナップショットを作成";
|
||||
"import_preview_snapshots_update" = "%d件のスナップショットを更新";
|
||||
"import_preview_categories" = "%d件のカテゴリーを作成";
|
||||
"import_preview_goals" = "%d件の目標を作成";
|
||||
"import_preview_journal_new" = "%d件のジャーナルを作成";
|
||||
"import_preview_journal_update" = "%d件のジャーナルを更新";
|
||||
"Review Import" = "インポートの確認";
|
||||
"Reviewing import…" = "インポートを確認中…";
|
||||
"Import" = "インポート";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "推定";
|
||||
"gaps_autofill_cta" = "%dか月を自動入力";
|
||||
"Add manually" = "手動で追加";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "自動入力は2つの記録の間の欠けている月を推定します(推定として表示)。または実際の値を自分で入力できます。";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "自動バックアップ";
|
||||
"Off" = "オフ";
|
||||
"Daily" = "毎日";
|
||||
"Weekly" = "毎週";
|
||||
"Monthly" = "毎月";
|
||||
"Last auto backup" = "最後の自動バックアップ";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "残高を隠す";
|
||||
"Show balances" = "残高を表示";
|
||||
"Balance hidden" = "残高は非表示";
|
||||
"Reveal your balances" = "残高を表示";
|
||||
"Privacy" = "プライバシー";
|
||||
"Hide Balances" = "残高を隠す";
|
||||
"Require Face ID to Reveal" = "表示にFace IDを要求";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示にFace IDを要求することもできます。";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "まだポートフォリオの金額が記録されていません。";
|
||||
"intent_networth_dialog" = "あなたの純資産は%@です。";
|
||||
"intent_streak" = "%dか月連続で記録中です。";
|
||||
"intent_streak_with_date" = "%dか月連続で記録中です。最後のチェックインは%@でした。";
|
||||
"intent_streak_none" = "まだ連続記録がありません。値を記録して始めましょう。";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "カスタム期間";
|
||||
"chart_range_from" = "開始";
|
||||
"chart_range_to" = "終了";
|
||||
"chart_filtered_prefix" = "フィルター: %@";
|
||||
"chart_overlay_contributions" = "拠出額と比較";
|
||||
"chart_insight_best_month" = "期間内のベスト月: %1$@(%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "成果をシェア";
|
||||
"share_percent_only" = "パーセントのみ";
|
||||
"share_format_post" = "投稿";
|
||||
"share_format_story" = "ストーリー";
|
||||
"share_card_cta" = "シェア";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "トータルリターン";
|
||||
"share_metric_yoy" = "今年";
|
||||
"share_metric_since_inception" = "開始来";
|
||||
"share_cta_headline" = "あなたも無料で記録";
|
||||
"share_cta_sub" = "スキャンでPortfolio Journalを入手";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "%@時点";
|
||||
"share_theme_dark" = "ダーク";
|
||||
"share_theme_light" = "ライト";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "今日の評価額";
|
||||
"add_source_value_footer" = "最初のチェックインとして保存され、今日から進捗の記録が始まります。";
|
||||
"quick_update_add_first_source" = "最初の投資先を追加";
|
||||
@@ -0,0 +1,679 @@
|
||||
"app_name" = "Portfolio Journal";
|
||||
"ok" = "OK";
|
||||
"cancel" = "Cancelar";
|
||||
"save" = "Salvar";
|
||||
"delete" = "Excluir";
|
||||
"edit" = "Editar";
|
||||
"add" = "Adicionar";
|
||||
"done" = "Concluído";
|
||||
"close" = "Fechar";
|
||||
"continue" = "Continuar";
|
||||
"skip" = "Pular";
|
||||
"error" = "Erro";
|
||||
"success" = "Sucesso";
|
||||
"loading" = "Carregando...";
|
||||
"loading_data" = "Carregando seus dados...";
|
||||
"tab_dashboard" = "Início";
|
||||
"tab_sources" = "Fontes";
|
||||
"tab_charts" = "Gráficos";
|
||||
"tab_settings" = "Ajustes";
|
||||
"dashboard_title" = "Início";
|
||||
"total_portfolio_value" = "Valor total da carteira";
|
||||
"today" = "hoje";
|
||||
"returns" = "Retornos";
|
||||
"by_category" = "Por categoria";
|
||||
"pending_updates" = "Atualizações pendentes";
|
||||
"see_all" = "Ver tudo";
|
||||
"sources_title" = "Fontes";
|
||||
"add_source" = "Adicionar fonte";
|
||||
"source_name" = "Nome da fonte";
|
||||
"select_category" = "Selecionar categoria";
|
||||
"initial_value" = "Valor inicial";
|
||||
"initial_value_optional" = "Valor inicial (opcional)";
|
||||
"reminder_frequency" = "Frequência do lembrete";
|
||||
"source_limit_warning" = "Limite de fontes atingido. Faça upgrade para Premium para fontes ilimitadas.";
|
||||
"no_sources" = "Nenhuma fonte de investimento";
|
||||
"no_sources_message" = "Adicione sua primeira fonte de investimento para começar a acompanhar sua carteira.";
|
||||
"add_snapshot" = "Adicionar snapshot";
|
||||
"edit_snapshot" = "Editar snapshot";
|
||||
"snapshot_date" = "Data";
|
||||
"snapshot_value" = "Valor";
|
||||
"snapshot_contribution" = "Contribuição";
|
||||
"contribution_optional" = "Contribuição (opcional)";
|
||||
"notes" = "Notas";
|
||||
"notes_optional" = "Notas (opcionais)";
|
||||
"previous_value" = "Anterior: %@";
|
||||
"change_from_previous" = "Mudança em relação ao anterior";
|
||||
"charts_title" = "Gráficos";
|
||||
"evolution" = "Evolução";
|
||||
"allocation" = "Alocação";
|
||||
"performance" = "Desempenho";
|
||||
"drawdown" = "Drawdown";
|
||||
"volatility" = "Volatilidade";
|
||||
"prediction" = "Previsão";
|
||||
"portfolio_evolution" = "Evolução da carteira";
|
||||
"asset_allocation" = "Alocação de ativos";
|
||||
"performance_by_category" = "Desempenho por categoria";
|
||||
"drawdown_analysis" = "Análise de drawdown";
|
||||
"prediction_12_month" = "Previsão de 12 meses";
|
||||
"not_enough_data" = "Dados insuficientes";
|
||||
"cagr" = "CAGR";
|
||||
"twr" = "TWR";
|
||||
"max_drawdown" = "Drawdown máximo";
|
||||
"sharpe_ratio" = "Índice de Sharpe";
|
||||
"win_rate" = "Taxa de acerto";
|
||||
"avg_monthly" = "Média mensal";
|
||||
"best_month" = "Melhor mês";
|
||||
"worst_month" = "Pior mês";
|
||||
"premium" = "Premium";
|
||||
"upgrade_to_premium" = "Fazer upgrade para Premium";
|
||||
"unlock_full_potential" = "Desbloqueie todo o potencial";
|
||||
"one_time_purchase" = "Compra única";
|
||||
"includes_family_sharing" = "Inclui Compartilhamento Familiar";
|
||||
"upgrade_now" = "Fazer upgrade agora";
|
||||
"restore_purchases" = "Restaurar compras";
|
||||
"premium_active" = "Premium ativo";
|
||||
"premium_feature" = "Recurso Premium";
|
||||
"unlock" = "Desbloquear";
|
||||
"feature_unlimited_sources" = "Fontes ilimitadas";
|
||||
"feature_unlimited_sources_desc" = "Acompanhe quantos investimentos quiser";
|
||||
"feature_full_history" = "Histórico completo";
|
||||
"feature_full_history_desc" = "Acesse todo o seu histórico de investimentos";
|
||||
"feature_advanced_charts" = "Gráficos avançados";
|
||||
"feature_advanced_charts_desc" = "5 tipos de gráficos analíticos detalhados";
|
||||
"feature_predictions" = "Previsões";
|
||||
"feature_predictions_desc" = "Previsões de 12 meses com IA";
|
||||
"feature_export" = "Exportar dados";
|
||||
"feature_export_desc" = "Exporte para CSV e JSON";
|
||||
"feature_no_ads" = "Sem anúncios";
|
||||
"feature_no_ads_desc" = "Experiência sem anúncios para sempre";
|
||||
"paywall_benefit_history_title" = "Seu histórico completo";
|
||||
"paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia";
|
||||
"paywall_benefit_charts_title" = "Gráficos que revelam padrões";
|
||||
"paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar";
|
||||
"paywall_benefit_forecasts_title" = "Previsões de 12 meses";
|
||||
"paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo";
|
||||
"paywall_benefit_noads_title" = "Sem anúncios, nunca";
|
||||
"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações";
|
||||
"settings_title" = "Ajustes";
|
||||
"subscription" = "Assinatura";
|
||||
"notifications" = "Notificações";
|
||||
"default_reminder_time" = "Horário padrão do lembrete";
|
||||
"data" = "Dados";
|
||||
"export_data" = "Exportar dados";
|
||||
"total_sources" = "Total de fontes";
|
||||
"total_snapshots" = "Total de snapshots";
|
||||
"storage_used" = "Armazenamento usado";
|
||||
"about" = "Sobre";
|
||||
"version" = "Versão";
|
||||
"privacy_policy" = "Política de privacidade";
|
||||
"terms_of_service" = "Termos de serviço";
|
||||
"support" = "Suporte";
|
||||
"rate_app" = "Avaliar app";
|
||||
"danger_zone" = "Zona de perigo";
|
||||
"reset_all_data" = "Redefinir todos os dados";
|
||||
"reset_confirmation" = "Isso excluirá permanentemente todos os seus dados de investimento. Esta ação não pode ser desfeita.";
|
||||
"frequency_monthly" = "Mensal";
|
||||
"frequency_quarterly" = "Trimestral";
|
||||
"frequency_semiannual" = "Semestral";
|
||||
"frequency_annual" = "Anual";
|
||||
"frequency_custom" = "Personalizada";
|
||||
"frequency_never" = "Nunca";
|
||||
"every_n_months" = "A cada %d mês(es)";
|
||||
"category_stocks" = "Ações";
|
||||
"category_bonds" = "Títulos";
|
||||
"category_real_estate" = "Imóveis";
|
||||
"category_crypto" = "Cripto";
|
||||
"category_cash" = "Caixa";
|
||||
"category_etfs" = "ETFs";
|
||||
"category_retirement" = "Aposentadoria";
|
||||
"category_other" = "Outro";
|
||||
"uncategorized" = "Sem categoria";
|
||||
"time_1m" = "1M";
|
||||
"time_3m" = "3M";
|
||||
"time_6m" = "6M";
|
||||
"time_1y" = "1A";
|
||||
"time_all" = "Tudo";
|
||||
"export_format" = "Selecionar formato";
|
||||
"export_csv" = "CSV";
|
||||
"export_csv_desc" = "Compatível com Excel e Google Sheets";
|
||||
"export_json" = "JSON";
|
||||
"export_json_desc" = "Estrutura completa para backup";
|
||||
"onboarding_track_title" = "Acompanhe seus investimentos";
|
||||
"onboarding_track_desc" = "Monitore todas as suas fontes de investimento em um só lugar. Ações, títulos, imóveis, cripto e mais.";
|
||||
"onboarding_visualize_title" = "Visualize seu crescimento";
|
||||
"onboarding_visualize_desc" = "Belos gráficos mostram a evolução, alocação e desempenho da sua carteira ao longo do tempo.";
|
||||
"onboarding_reminders_title" = "Nunca perca uma atualização";
|
||||
"onboarding_reminders_desc" = "Defina lembretes para acompanhar seus investimentos regularmente. Mensal, trimestral ou personalizado.";
|
||||
"onboarding_sync_title" = "Sincronize em todos os lugares";
|
||||
"onboarding_sync_desc" = "Seus dados sincronizam automaticamente via iCloud em todos os seus dispositivos Apple.";
|
||||
"get_started" = "Começar";
|
||||
"onboarding_clarity_title" = "Saiba exatamente onde você está";
|
||||
"onboarding_clarity_desc" = "Veja seu patrimônio total, retornos reais e alocação, sempre atualizados.";
|
||||
"onboarding_habit_title" = "5 minutos por mês são suficientes";
|
||||
"onboarding_habit_desc" = "Registre seus valores uma vez por mês. O Portfolio Journal faz as contas e mostra seu progresso.";
|
||||
"onboarding_calm_title" = "Ignore o ruído. Acompanhe a tendência.";
|
||||
"onboarding_calm_desc" = "Oscilações diárias não contam a história real. Seu crescimento ao longo de meses e anos conta.";
|
||||
"onboarding_goals_title" = "Alcance seus objetivos financeiros";
|
||||
"onboarding_goals_desc" = "Defina metas, acompanhe marcos e veja exatamente o quanto você avançou.";
|
||||
"error_generic" = "Ocorreu um erro. Tente novamente.";
|
||||
"error_no_purchases" = "Nenhuma compra encontrada para restaurar";
|
||||
"error_purchase_failed" = "Compra falhou: %@";
|
||||
"error_export_failed" = "A exportação falhou. Tente novamente.";
|
||||
"placeholder_source_name" = "ex.: Vanguard 401k";
|
||||
"placeholder_value" = "0.00";
|
||||
"placeholder_notes" = "Adicionar notas...";
|
||||
"mood_energized_title" = "A mil";
|
||||
"mood_confident_title" = "Confiante";
|
||||
"mood_balanced_title" = "Estável";
|
||||
"mood_cautious_title" = "Cauteloso";
|
||||
"mood_stressed_title" = "Estressado";
|
||||
"mood_energized_detail" = "Me sentindo imbatível";
|
||||
"mood_confident_detail" = "No caminho e tranquilo";
|
||||
"mood_balanced_detail" = "Calmo e paciente";
|
||||
"mood_cautious_detail" = "Observando os movimentos";
|
||||
"mood_stressed_detail" = "Preciso respirar";
|
||||
"achievement_streak_3_title" = "Sequência de 3 meses";
|
||||
"achievement_streak_3_detail" = "Você manteve seus check-ins em dia por três meses seguidos.";
|
||||
"achievement_streak_6_title" = "Boa fase de meio ano";
|
||||
"achievement_streak_6_detail" = "Seis check-ins consecutivos no prazo.";
|
||||
"achievement_streak_12_title" = "Um ano de ritmo";
|
||||
"achievement_streak_12_detail" = "Um ano inteiro sem perder o prazo.";
|
||||
"achievement_perfect_on_time_title" = "Nunca atrasado";
|
||||
"achievement_perfect_on_time_detail" = "Todos os check-ins foram enviados antes do prazo.";
|
||||
"achievement_clutch_finish_title" = "No limite";
|
||||
"achievement_clutch_finish_detail" = "Enviado com poucas horas de sobra, mas ainda no prazo.";
|
||||
"achievement_early_bird_title" = "Adiantado";
|
||||
"achievement_early_bird_detail" = "Em média você termina com bastante tempo sobrando.";
|
||||
"achievements_title" = "Conquistas";
|
||||
"achievements_view_all" = "Ver todas as conquistas";
|
||||
"achievements_nav_title" = "Conquistas";
|
||||
"achievements_progress_title" = "Progresso";
|
||||
"achievements_unlocked_title" = "Desbloqueadas";
|
||||
"achievements_unlocked_empty" = "Complete check-ins para desbloquear conquistas.";
|
||||
"achievements_locked_title" = "Bloqueadas";
|
||||
"achievements_locked_empty" = "Todas as conquistas desbloqueadas. Bom trabalho.";
|
||||
"rating_accessibility" = "Avaliação %d de 5";
|
||||
"achievements_unlocked_count" = "%d de %d desbloqueadas";
|
||||
"last_check_in" = "Último check-in: %@";
|
||||
"next_check_in" = "Próximo check-in: %@";
|
||||
"on_time_rate" = "%@ no prazo";
|
||||
"on_time_count" = "%d/%d no prazo";
|
||||
"tightest_finish" = "Fechamento mais apertado: %@ antes do prazo.";
|
||||
"date_today" = "Hoje";
|
||||
"date_yesterday" = "Ontem";
|
||||
"date_never" = "Nunca";
|
||||
"calendar_event_title" = "%@: Check-in mensal";
|
||||
"calendar_event_notes" = "Abra %@ e conclua seu check-in mensal.";
|
||||
"checkin_enjoying_dialog_title" = "Quanto você está gostando do Portfolio Journal?";
|
||||
"checkin_enjoying_dialog_message" = "Parabéns pela sua nova conquista. Seu feedback nos ajuda a melhorar.";
|
||||
"not_now" = "Agora não";
|
||||
"rating_1_star" = "1 estrela";
|
||||
"rating_n_stars" = "%d estrelas";
|
||||
"app_store_review_title" = "Gostaria de deixar uma avaliação na App Store?";
|
||||
"app_store_review_message" = "Obrigado pelas 5 estrelas. Isso realmente ajuda outros investidores a descobrir o app.";
|
||||
"write_review" = "Escrever avaliação";
|
||||
"save_1_snapshot" = "Salvar 1 snapshot";
|
||||
"save_n_snapshots" = "Salvar %d snapshots";
|
||||
"checkin_update_month" = "Atualizar %@";
|
||||
"checkin_start_new" = "Iniciar";
|
||||
"Home" = "Início";
|
||||
"Sources" = "Fontes";
|
||||
"Charts" = "Gráficos";
|
||||
"Settings" = "Ajustes";
|
||||
"Journal" = "Diário";
|
||||
"Search monthly notes" = "Buscar notas mensais";
|
||||
"Monthly Check-ins" = "Check-ins mensais";
|
||||
"No monthly notes yet." = "Ainda não há notas mensais.";
|
||||
"No matching notes." = "Nenhuma nota correspondente.";
|
||||
"Jump to month" = "Ir para o mês";
|
||||
"Today" = "Hoje";
|
||||
"Mood not set" = "Humor não definido";
|
||||
"No rating" = "Sem avaliação";
|
||||
"No note yet." = "Ainda sem nota.";
|
||||
"Monthly Note" = "Nota mensal";
|
||||
"Open Full Note" = "Abrir nota completa";
|
||||
"Duplicate Previous" = "Duplicar anterior";
|
||||
"Save" = "Salvar";
|
||||
"Monthly Check-in" = "Check-in mensal";
|
||||
"This Month" = "Este mês";
|
||||
"No check-in yet this month" = "Ainda não há check-in este mês";
|
||||
"Start your first check-in anytime." = "Comece seu primeiro check-in quando quiser.";
|
||||
"Mark Check-in Complete" = "Marcar check-in como concluído";
|
||||
"Editing stays open. New check-ins unlock after 70% of the month." = "A edição permanece aberta. Novos check-ins são liberados após 70 % do mês.";
|
||||
"Momentum & Streaks" = "Ritmo e sequências";
|
||||
"Log a check-in to start a streak" = "Registre um check-in para começar uma sequência";
|
||||
"Streak" = "Sequência";
|
||||
"On-time in a row" = "No prazo em sequência";
|
||||
"Best" = "Melhor";
|
||||
"Personal best" = "Recorde pessoal";
|
||||
"Avg early" = "Antecedência média";
|
||||
"vs deadline" = "vs prazo";
|
||||
"On-time score" = "Pontuação de prazo";
|
||||
"Achievements" = "Conquistas";
|
||||
"View all achievements" = "Ver todas as conquistas";
|
||||
"Monthly Pulse" = "Pulso mensal";
|
||||
"Optional" = "Opcional";
|
||||
"Rate this month" = "Avalie este mês";
|
||||
"How did it feel?" = "Como foi?";
|
||||
"Monthly Summary" = "Resumo mensal";
|
||||
"Starting" = "Início";
|
||||
"Ending" = "Final";
|
||||
"Contributions" = "Contribuições";
|
||||
"Net Performance" = "Desempenho líquido";
|
||||
"Update Sources" = "Atualizar fontes";
|
||||
"Add sources to start your monthly check-in." = "Adicione fontes para começar seu check-in mensal.";
|
||||
"Updated this cycle" = "Atualizado neste ciclo";
|
||||
"Needs update" = "Precisa de atualização";
|
||||
"Snapshot Notes" = "Notas de snapshot";
|
||||
"No snapshot notes for this month." = "Não há notas de snapshot neste mês.";
|
||||
"Source" = "Fonte";
|
||||
"Your full portfolio,\nfully clear" = "Sua carteira completa,\ntotalmente clara";
|
||||
"One payment. Every feature. Forever." = "Um pagamento. Todos os recursos. Para sempre.";
|
||||
"Get Full Access" = "Obter acesso completo";
|
||||
"Restore Purchases" = "Restaurar compras";
|
||||
"Payment charged to your Apple ID account." = "O pagamento será cobrado na sua conta Apple ID.";
|
||||
"Terms" = "Termos";
|
||||
"Privacy" = "Privacidade";
|
||||
"· one-time · Family Sharing" = "· pagamento único · Compartilhamento Familiar";
|
||||
"Full access, one payment" = "Acesso total, um pagamento";
|
||||
"Unlimited sources, advanced charts & more" = "Fontes ilimitadas, gráficos avançados e mais";
|
||||
"See full access" = "Ver acesso completo";
|
||||
"Batch Update" = "Atualização em lote";
|
||||
"Current value" = "Valor atual";
|
||||
"Contribution this period (optional)" = "Contribuição deste período (opcional)";
|
||||
"Include Contribution" = "Incluir contribuição";
|
||||
"New capital added" = "Novo capital adicionado";
|
||||
"Contribution (Optional)" = "Contribuição (opcional)";
|
||||
"Track new capital added to separate it from investment growth." = "Registre o novo capital adicionado para separar do crescimento do investimento.";
|
||||
"Monthly Highlights" = "Destaques do mês";
|
||||
"Best Performer" = "Melhor desempenho";
|
||||
"Worst Performer" = "Pior desempenho";
|
||||
"Best Contributor" = "Maior contribuinte";
|
||||
"Update Check-in" = "Atualizar check-in";
|
||||
"Completed %@" = "Concluído %@";
|
||||
|
||||
// MARK: - Missing keys added (1.3.x)
|
||||
"Checking…" = "Verificando…";
|
||||
"Clear Filters" = "Limpar filtros";
|
||||
"Force Upload to iCloud" = "Forçar envio ao iCloud";
|
||||
"No export yet" = "Ainda não exportado";
|
||||
"Not synced yet" = "Ainda não sincronizado";
|
||||
"Refresh" = "Atualizar";
|
||||
"Search sources" = "Buscar fontes";
|
||||
"Syncing with iCloud..." = "Sincronizando com iCloud...";
|
||||
"Uploading..." = "Enviando...";
|
||||
"Verify iCloud Setup" = "Verificar configuração do iCloud";
|
||||
"add_source_name_footer" = "Uma fonte é qualquer investimento que você deseja acompanhar: ações, ETFs, poupança, imóveis, criptomoedas e mais.";
|
||||
"add_source_name_placeholder" = "ex.: ETF MSCI World, Poupança, Apartamento...";
|
||||
"categories_empty" = "Nenhuma categoria ainda.";
|
||||
"category_has_sources_warning" = "Não é possível excluir uma categoria que possui fontes. Remova ou reatribua todas as fontes primeiro.";
|
||||
"category_name_placeholder" = "ex.: Reserva de emergência";
|
||||
"chart_yoy_empty" = "Adicione mais snapshots em anos diferentes para ver a comparação.";
|
||||
"chart_yoy_title" = "Ano a ano";
|
||||
"chart_yoy_estimated_note" = "* estimado (previsão de fim de ano)";
|
||||
"checking_icloud" = "Verificando iCloud...";
|
||||
"contributions_vs_returns_invested" = "Investido";
|
||||
"contributions_vs_returns_returns" = "Retorno do mercado";
|
||||
"contributions_vs_returns_title" = "Investido vs. Retorno";
|
||||
"csv_enter_value" = "Inserir valor";
|
||||
"csv_field_category" = "Categoria";
|
||||
"csv_field_contribution" = "Contribuição";
|
||||
"csv_field_date" = "Data";
|
||||
"csv_field_notes" = "Notas";
|
||||
"csv_field_source" = "Nome da fonte (obrigatório)";
|
||||
"csv_field_value" = "Valor (obrigatório)";
|
||||
"csv_mapping_subtitle" = "Associe as colunas do CSV aos campos do Portfolio Journal";
|
||||
"csv_no_column" = "— Não mapeado —";
|
||||
"csv_no_date_hint" = "Todas as linhas serão importadas como um snapshot de hoje.";
|
||||
"csv_optional_section" = "Campos opcionais";
|
||||
"csv_preview_section" = "Prévia do CSV";
|
||||
"csv_required_section" = "Campos obrigatórios";
|
||||
"csv_use_today" = "Usar data de hoje";
|
||||
"goal_achieved_notification_body" = "Você atingiu sua meta: %@";
|
||||
"goal_achieved_notification_title" = "Meta atingida! 🎉";
|
||||
"goal_archive" = "Arquivar";
|
||||
"goal_delete_confirm" = "Excluir";
|
||||
"goal_delete_message" = "Esta meta será excluída permanentemente. Esta ação não pode ser desfeita.";
|
||||
"goal_delete_title" = "Excluir meta";
|
||||
"goal_unarchive" = "Restaurar";
|
||||
"goals_all_active_achieved" = "Todas as metas ativas foram alcançadas.";
|
||||
"goals_empty_archived" = "Nenhuma meta arquivada.";
|
||||
"goals_filter_active" = "Ativas";
|
||||
"goals_filter_all" = "Todas";
|
||||
"goals_filter_archived" = "Arquivadas";
|
||||
"icloud_check_description" = "Se você tem dados em outro dispositivo, ative o iCloud para restaurá-los aqui.";
|
||||
"icloud_check_title" = "Já usa o Portfolio Journal?";
|
||||
"icloud_enabled_description" = "Feche o app e abra novamente. Seus dados serão carregados automaticamente do iCloud.";
|
||||
"icloud_enabled_title" = "iCloud ativado";
|
||||
"monthly_checkin_notification_body" = "Registre os valores deste mês e acompanhe o crescimento do seu portfólio.";
|
||||
"monthly_checkin_notification_title" = "Hora da sua atualização mensal";
|
||||
"onboarding_add_first_source" = "Adicionar meu primeiro investimento";
|
||||
"onboarding_import_data" = "Importar dados existentes";
|
||||
"onboarding_quickstart_subtitle" = "Adicione sua primeira fonte de investimento para começar a acompanhar seu portfólio.";
|
||||
"onboarding_quickstart_title" = "Quase lá";
|
||||
"reengagement_body" = "Alguns minutos são suficientes para manter o controle dos seus investimentos.";
|
||||
"reengagement_goal_title" = "Você está no caminho certo 📈";
|
||||
"reengagement_goal_body" = "Nesse ritmo você vai alcançar \"%@\" até %@. Veja o quanto falta.";
|
||||
"reengagement_yoy_title" = "Olha esse crescimento 👀";
|
||||
"reengagement_yoy_body" = "Você está %@ acima no último ano. Confira a tendência.";
|
||||
"reengagement_month_title" = "Bom mês 📈";
|
||||
"reengagement_month_body" = "Sua carteira cresceu %@ este mês. Dê uma olhada.";
|
||||
"reengagement_milestone_title" = "Quase lá 🎯";
|
||||
"reengagement_milestone_body" = "Você está quase em %@. Quão perto está hoje?";
|
||||
"reengagement_streak_title" = "Mantenha a sequência 🔥";
|
||||
"reengagement_streak_body" = "Você registrou %d meses seguidos. Não pare agora.";
|
||||
"reengagement_neutral_title" = "Alguma novidade? 👀";
|
||||
"reengagement_neutral_body" = "Houve movimento na sua carteira. Veja o que mudou.";
|
||||
"reengagement_title" = "Seu portfólio está esperando";
|
||||
"snapshot_duplicate_add" = "Adicionar mesmo assim";
|
||||
"snapshot_contribution_propagate_title" = "Aplicar contribuição";
|
||||
"snapshot_contribution_propagate_message" = "Aplicar %@ como contribuição também a outros registros?";
|
||||
"snapshot_contribution_propagate_forward" = "Aplicar adiante";
|
||||
"snapshot_contribution_propagate_backward" = "Aplicar para trás";
|
||||
"snapshot_contribution_propagate_all" = "Aplicar a todos";
|
||||
"snapshot_contribution_propagate_this" = "Apenas este registro";
|
||||
"snapshot_duplicate_message" = "Você já tem um snapshot para este mês. Deseja adicionar outro?";
|
||||
"snapshot_duplicate_replace" = "Substituir existente";
|
||||
"snapshot_duplicate_title" = "Snapshot já existe";
|
||||
"sources_filter_all" = "Todas";
|
||||
|
||||
// MARK: - 1.4.0 Features
|
||||
"monthly_summary_notification_title" = "Seu resumo mensal do portfólio";
|
||||
"monthly_summary_notification_body" = "Veja como seus investimentos se saíram este mês.";
|
||||
"streak_badge" = "Sequência de %d meses";
|
||||
"goals_empty_add_cta" = "Adicionar minha primeira meta";
|
||||
"journal_empty_title" = "Nenhum registro ainda";
|
||||
"journal_empty_body" = "As atualizações de snapshots criam entradas mensais automaticamente. Adicione sua primeira fonte para começar.";
|
||||
"quick_update_title" = "Atualização rápida";
|
||||
"quick_update_section_header" = "Insira os valores atuais";
|
||||
"quick_update_section_footer" = "Apenas as fontes com um valor inserido serão atualizadas.";
|
||||
"quick_update_placeholder" = "Novo valor";
|
||||
"quick_update_save" = "Salvar tudo";
|
||||
"quick_update_no_sources" = "Sem fontes";
|
||||
"quick_update_no_sources_body" = "Adicione fontes de investimento primeiro para usar a atualização rápida.";
|
||||
"update_available_title" = "Atualização disponível";
|
||||
"update_available_body" = "A versão %@ está disponível na App Store.";
|
||||
"whats_new_title" = "Novidades no 1.4";
|
||||
"whats_new_subtitle" = "Melhorias para te ajudar a manter o rumo.";
|
||||
"whats_new_quick_update_title" = "Atualização rápida do portfólio";
|
||||
"whats_new_quick_update_body" = "Atualize todas as suas fontes em uma única tela com um toque.";
|
||||
"whats_new_streak_title" = "Sequência de atualizações";
|
||||
"whats_new_streak_body" = "Acompanhe quantos meses consecutivos você manteve seu portfólio atualizado.";
|
||||
"whats_new_goals_title" = "Metas e diário mais inteligentes";
|
||||
"whats_new_goals_body" = "Estados vazios melhorados ajudam você a começar mais rápido.";
|
||||
"whats_new_continue" = "Vamos lá!";
|
||||
"source_monthly_contribution_title" = "Aporte Mensal";
|
||||
"source_monthly_contribution_placeholder" = "ex. 500";
|
||||
"source_monthly_contribution_not_set" = "Não configurado";
|
||||
"source_monthly_contribution_hint" = "Preenchido automaticamente na Atualização Rápida";
|
||||
"quick_update_contribution_label" = "Aporte";
|
||||
"quick_update_contribution_placeholder" = "Valor";
|
||||
"source_monthly_contribution_apply_title" = "Aplicar Aporte";
|
||||
"source_monthly_contribution_apply_message" = "Aplicar %@ a todos os snapshots anteriores sem aporte?";
|
||||
"source_monthly_contribution_apply_retroactive" = "Aplicar a Todos os Snapshots Anteriores";
|
||||
"source_monthly_contribution_apply_forward" = "Apenas daqui para frente";
|
||||
|
||||
// MARK: - Portfolio Insights
|
||||
"insights_section_title" = "Insights";
|
||||
"insight_milestone_title" = "Meta à vista";
|
||||
"insight_milestone_value" = "%1$@ para %2$@";
|
||||
"insight_ytd_title" = "No ano";
|
||||
"insight_market_gains_title" = "Ganhos de mercado";
|
||||
"insight_market_gains_value" = "+%@ dos mercados";
|
||||
"insight_streak_title" = "Sequência de atualizações";
|
||||
"insight_streak_value" = "%d meses consecutivos";
|
||||
"insight_forecast_title" = "Caminhando para";
|
||||
"insight_allocation_title" = "Desvio de alocação";
|
||||
"insight_allocation_over_value" = "%1$@ está em %2$.0f%% — acima da sua meta de %3$.0f%%";
|
||||
"insight_allocation_under_value" = "%1$@ está em %2$.0f%% — abaixo da sua meta de %3$.0f%%";
|
||||
"insight_allocation_detail" = "Rebalanceie para seguir o plano";
|
||||
"insight_concentration_title" = "Risco de concentração";
|
||||
"insight_concentration_value" = "%1$@ representa %2$.0f%% da sua carteira";
|
||||
"insight_concentration_detail" = "Considere diversificar";
|
||||
"insight_best_performer_title" = "Melhor desempenho";
|
||||
"insight_worst_performer_title" = "Requer atenção";
|
||||
"insight_performer_value" = "%1$@ · %2$+.1f%%";
|
||||
"insight_diversification_title" = "Diversificação";
|
||||
"insight_diversification_single_value" = "Tudo em uma fonte — considere adicionar outra";
|
||||
"insight_diversification_good_value" = "Distribuído em %d categorias";
|
||||
"goal_projection_projected" = "Previsão: %@";
|
||||
"goal_projection_add_contributions" = "Adicione aportes para alcançar";
|
||||
"goal_chip_ahead" = "Adiantado";
|
||||
"goal_chip_on_track" = "No caminho";
|
||||
"goal_chip_behind" = "Atrasado";
|
||||
"goal_chip_at_this_pace" = "Neste ritmo…";
|
||||
"notification_milestone_title" = "Meta de portfólio! 🎉";
|
||||
"notification_milestone_body" = "Seu portfólio acabou de ultrapassar %@. Parabéns!";
|
||||
|
||||
"streak_protection_notification_title" = "Não quebre sua sequência 🔥";
|
||||
"streak_protection_notification_body" = "O check-in deste mês ainda está pendente. Leva 2 minutos para manter sua sequência viva.";
|
||||
|
||||
"paywall_benefit_accounts_title" = "Várias contas";
|
||||
"paywall_benefit_accounts_subtitle" = "Carteiras separadas para família ou negócios";
|
||||
"paywall_benefit_family_title" = "Compartilhamento Familiar";
|
||||
"paywall_benefit_family_subtitle" = "Uma compra, até 5 membros da família";
|
||||
"paywall_plan_annual" = "Anual";
|
||||
"paywall_plan_annual_per_year" = "%@ / ano";
|
||||
"paywall_plan_lifetime" = "Vitalício";
|
||||
"paywall_plan_lifetime_badge" = "MELHOR VALOR";
|
||||
"paywall_trial_days" = "dias";
|
||||
"paywall_trial_weeks" = "semanas";
|
||||
"paywall_trial_months" = "meses";
|
||||
"paywall_trial_years" = "anos";
|
||||
"paywall_trial_format" = "%d %@ grátis";
|
||||
|
||||
"chart_history_locked" = "%d meses a mais de histórico — desbloqueie com o Premium";
|
||||
|
||||
"chart_group_overview" = "Visão geral";
|
||||
"chart_group_analyze" = "Análise";
|
||||
"chart_group_risk" = "Risco";
|
||||
"chart_group_forecast" = "Previsão";
|
||||
"kpi_total_value" = "Valor total";
|
||||
"kpi_period_return" = "Retorno período";
|
||||
"kpi_cagr" = "CAGR";
|
||||
"kpi_volatility" = "Volatilidade";
|
||||
"kpi_max_drawdown" = "Queda máx.";
|
||||
"chart_zoom_in" = "Ampliar";
|
||||
"chart_zoom_out" = "Reduzir";
|
||||
"chart_zoom_reset" = "Redefinir zoom";
|
||||
|
||||
"quick_update_paste_suggestion" = "Colar %@ em %@";
|
||||
"quick_update_next_badge" = "PRÓXIMA";
|
||||
|
||||
// App Intents (1.4.2)
|
||||
"intent_logged_dialog" = "%1$@ salvo para %2$@.";
|
||||
"intent_amount_invalid" = "O valor deve ser maior que zero. Quanto?";
|
||||
"Log Portfolio Value" = "Registrar valor da carteira";
|
||||
"Record a value for one of your sources without opening the app." = "Registre um valor para uma de suas fontes sem abrir o app.";
|
||||
"Amount" = "Valor";
|
||||
"How much?" = "Quanto?";
|
||||
"Log Value" = "Registrar valor";
|
||||
"Update Portfolio" = "Atualizar carteira";
|
||||
"Check Portfolio" = "Verificar carteira";
|
||||
|
||||
// Chart share (1.4.2)
|
||||
"chart_share_button" = "Compartilhar gráfico";
|
||||
"chart_share_text" = "Meu gráfico de %@ — feito com Portfolio Journal";
|
||||
"chart_share_tagline" = "Acompanhamento de investimentos privado e tranquilo para iPhone e iPad.";
|
||||
"chart_share_scan" = "Escaneie para baixar";
|
||||
"chart_locked_title" = "%@ é um gráfico Premium";
|
||||
"chart_locked_cta" = "Desbloquear Premium";
|
||||
|
||||
// Calm 2.0 Home (1.5.0)
|
||||
"checkin_done_title" = "Check-in de %@ concluído";
|
||||
"checkin_done_next" = "Próximo: %@";
|
||||
"checkin_next_due" = "Próximo check-in: %@";
|
||||
"checkin_days_left" = "faltam %d dias";
|
||||
"checkin_due_today" = "Hoje";
|
||||
"checkin_overdue" = "Atrasado";
|
||||
"dash_chart_yoy" = "Ano vs Ano";
|
||||
"home_chart_locked_sub" = "Desbloqueie com Premium para ver este gráfico.";
|
||||
"checkin_sources_progress" = "%1$d de %2$d fontes atualizadas";
|
||||
|
||||
// Guided check-in (1.5.0 Fase 2)
|
||||
"guided_previous_value" = "Mês passado";
|
||||
"guided_paste_amount" = "Colar %@";
|
||||
"guided_next" = "Avançar";
|
||||
"guided_same_as_last" = "Igual ao mês passado";
|
||||
"guided_skip" = "Pular";
|
||||
"guided_reflection_title" = "Como está sendo este mês?";
|
||||
"guided_reflection_subtitle" = "Uma nota rápida para o seu eu do futuro.";
|
||||
"guided_note_placeholder" = "Uma linha sincera sobre este mês…";
|
||||
"guided_finish" = "Concluir check-in";
|
||||
"guided_done_title" = "Check-in de %@ concluído";
|
||||
"guided_streak" = "%d meses seguidos";
|
||||
"guided_share_month" = "Compartilhar este mês";
|
||||
"guided_done_cta" = "Concluído";
|
||||
"journal_no_note" = "Ainda sem nota.";
|
||||
|
||||
// What's New catalog (1.4.1–1.5.0)
|
||||
"whats_new_version_header" = "Versão %@";
|
||||
"whats_new_150_redesign_title" = "Um visual mais calmo e limpo";
|
||||
"whats_new_150_redesign_body" = "Início redesenhado com seu valor total em destaque, o check-in no centro e menos ruído em tudo.";
|
||||
"whats_new_150_guided_title" = "Check-in mensal guiado";
|
||||
"whats_new_150_guided_body" = "Uma fonte por etapa, deltas ao vivo e uma reflexão rápida — seu ritual de 5 minutos, reinventado.";
|
||||
"whats_new_150_rings_title" = "Anéis de metas & timeline";
|
||||
"whats_new_150_rings_body" = "Anéis de progresso estilo Fitness para metas e uma timeline da sua história como investidor.";
|
||||
"whats_new_142_ocr_title" = "Atualize com uma captura de tela";
|
||||
"whats_new_142_ocr_body" = "Compartilhe uma captura do seu banco — o saldo é reconhecido no aparelho, nada sai do seu iPhone.";
|
||||
"whats_new_142_siri_title" = "Siri no seu idioma";
|
||||
"whats_new_142_siri_body" = "\"Registre o saldo da Indexa\" — anote valores por voz ou pelos Atalhos, sem abrir o app.";
|
||||
"whats_new_142_charts_title" = "Gráficos renovados";
|
||||
"whats_new_142_charts_body" = "Barra lateral nativa no iPad, KPIs sob medida por gráfico e compartilhamento com belas imagens.";
|
||||
"whats_new_141_extension_title" = "Atualize sem trocar de app";
|
||||
"whats_new_141_extension_body" = "Compartilhe um valor do app do seu banco direto no Portfolio Journal com a nova extensão.";
|
||||
"whats_new_141_ipad_title" = "Melhor no iPad";
|
||||
"whats_new_141_ipad_body" = "Gráficos redesenhados para a tela grande, com zoom e rótulos de valor.";
|
||||
"whats_new_141_icloud_title" = "Mais sincronizado";
|
||||
"whats_new_141_icloud_body" = "Aportes mensais e metas de alocação agora sincronizam via iCloud.";
|
||||
"whats_new_title_format" = "Novidades da %@";
|
||||
|
||||
// TipKit (1.5.0 Fase 4)
|
||||
"tip_quick_update_title" = "Atualize tudo em segundos";
|
||||
"tip_quick_update_body" = "O Quick Update lista todas as suas fontes em uma tela — cole valores da área de transferência e pronto.";
|
||||
"tip_ocr_title" = "Ou compartilhe uma captura de tela";
|
||||
"tip_ocr_body" = "Do app do seu banco: captura → Compartilhar → Portfolio Journal. O saldo é lido no aparelho.";
|
||||
"tip_siri_title" = "Na próxima, experimente por voz";
|
||||
"tip_siri_body" = "Diga à Siri \"Registre o saldo da minha conta no Portfolio Journal\" — sem abrir o app.";
|
||||
"tip_chart_share_title" = "Compartilhe este gráfico";
|
||||
"tip_chart_share_body" = "Gera uma bela imagem com seus dados, pronta para o X ou WhatsApp.";
|
||||
"allocation_total" = "Total";
|
||||
|
||||
// iCloud error hints (1.5.0)
|
||||
"icloud_hint_schema" = "O iCloud rejeitou alguns registros. Geralmente significa que o esquema do iCloud ainda está sendo configurado — costuma resolver em algumas horas ou após a próxima atualização do app.";
|
||||
"icloud_hint_quota" = "Seu armazenamento do iCloud está cheio. Libere espaço e tente novamente.";
|
||||
"icloud_hint_network" = "Não foi possível acessar o iCloud. Verifique sua conexão e tente novamente.";
|
||||
"icloud_hint_partial_generic" = "Alguns registros não sincronizaram desta vez. O app tentará novamente automaticamente.";
|
||||
"icloud_status_error" = "Problema de sincronização com o iCloud";
|
||||
"icloud_status_syncing" = "Sincronizando com o iCloud";
|
||||
"icloud_show_details" = "Mostrar detalhes técnicos";
|
||||
"yoy_vs_prev" = "vs %@";
|
||||
"yoy_best_year" = "Melhor ano";
|
||||
"yoy_worst_year" = "Pior ano";
|
||||
|
||||
// Quick Update paste/scan (1.5.0)
|
||||
"quick_update_paste_value" = "Colar %@";
|
||||
"quick_update_scan" = "Escanear";
|
||||
"quick_update_done_kbd" = "Concluir";
|
||||
"quick_update_scanning" = "Lendo imagem…";
|
||||
"quick_update_pick_amount" = "Qual valor?";
|
||||
"quick_update_no_amount_found" = "Nenhum valor reconhecido na imagem.";
|
||||
"icloud_copy_diagnostics" = "Copiar diagnóstico do iCloud";
|
||||
"icloud_diagnostics_copied" = "Diagnóstico copiado para a área de transferência";
|
||||
|
||||
"Smooth Gaps in Charts" = "Suavizar lacunas nos gráficos";
|
||||
|
||||
// MARK: - Data Gaps (1.5.0)
|
||||
"gaps_banner_title" = "Faltam dados de %d meses";
|
||||
"gaps_missing_count" = "%d faltando";
|
||||
"gaps_range_between" = "Entre %@ e %@";
|
||||
"Fill in missing data" = "Preencher dados faltantes";
|
||||
"Missing Data" = "Dados faltantes";
|
||||
"Fill Gap" = "Preencher lacuna";
|
||||
"The chart estimates these months. Add real snapshots to keep your history accurate." = "O gráfico estima esses meses. Adicione valores reais para manter seu histórico preciso.";
|
||||
"Dismiss" = "Dispensar";
|
||||
|
||||
// MARK: - Shareable Monthly Summary (1.5.0)
|
||||
"share_summary_streak_badge" = "%d m";
|
||||
"Monthly Summary" = "Resumo mensal";
|
||||
"Portfolio Value" = "Valor da carteira";
|
||||
"Tracked with" = "Registrado com";
|
||||
"Share monthly summary" = "Compartilhar resumo mensal";
|
||||
"since last check-in" = "desde o último check-in";
|
||||
|
||||
// MARK: - Import Preview (1.5.0)
|
||||
"import_preview_nothing" = "Nada novo para importar. Seus dados já estão atualizados.";
|
||||
"import_preview_sources" = "Criar %d fontes";
|
||||
"import_preview_snapshots_new" = "Criar %d registros";
|
||||
"import_preview_snapshots_update" = "Atualizar %d registros";
|
||||
"import_preview_categories" = "Criar %d categorias";
|
||||
"import_preview_goals" = "Criar %d metas";
|
||||
"import_preview_journal_new" = "Criar %d entradas do diário";
|
||||
"import_preview_journal_update" = "Atualizar %d entradas do diário";
|
||||
"Review Import" = "Revisar importação";
|
||||
"Reviewing import…" = "Revisando importação…";
|
||||
"Import" = "Importar";
|
||||
|
||||
/* 1.6.0 gap auto-fill */
|
||||
"gaps_estimated_note" = "Estimado";
|
||||
"gaps_autofill_cta" = "Preencher %d meses";
|
||||
"Add manually" = "Adicionar manualmente";
|
||||
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "O preenchimento automático estima os meses faltantes entre dois registros (marcados como estimados). Ou insira os valores reais você mesmo.";
|
||||
|
||||
/* 1.6.0 auto-backup */
|
||||
"Auto Backup" = "Backup automático";
|
||||
"Off" = "Desativado";
|
||||
"Daily" = "Diário";
|
||||
"Weekly" = "Semanal";
|
||||
"Monthly" = "Mensal";
|
||||
"Last auto backup" = "Último backup automático";
|
||||
|
||||
/* 1.6.0 hide balances */
|
||||
"Hide balances" = "Ocultar saldos";
|
||||
"Show balances" = "Mostrar saldos";
|
||||
"Balance hidden" = "Saldo oculto";
|
||||
"Reveal your balances" = "Mostrar seus saldos";
|
||||
"Privacy" = "Privacidade";
|
||||
"Hide Balances" = "Ocultar saldos";
|
||||
"Require Face ID to Reveal" = "Exigir Face ID para mostrar";
|
||||
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija Face ID para revelá-los.";
|
||||
|
||||
/* 1.6.0 Siri intents */
|
||||
"intent_networth_empty" = "Você ainda não registrou nenhum valor de carteira.";
|
||||
"intent_networth_dialog" = "Seu patrimônio é %@.";
|
||||
"intent_streak" = "Você está em uma sequência de %d meses.";
|
||||
"intent_streak_with_date" = "Você está em uma sequência de %d meses. Seu último check-in foi em %@.";
|
||||
"intent_streak_none" = "Você ainda não tem uma sequência ativa. Registre um valor para começar uma.";
|
||||
|
||||
// Charts workable (1.6.0)
|
||||
"chart_range_button" = "Período personalizado";
|
||||
"chart_range_from" = "De";
|
||||
"chart_range_to" = "Até";
|
||||
"chart_filtered_prefix" = "Filtrado: %@";
|
||||
"chart_overlay_contributions" = "vs Aportes";
|
||||
"chart_insight_best_month" = "Melhor mês do período: %1$@ (%2$@)";
|
||||
|
||||
// Share cards 2.0 (1.6.0)
|
||||
"share_options_title" = "Compartilhe seu progresso";
|
||||
"share_percent_only" = "Apenas porcentagens";
|
||||
"share_format_post" = "Post";
|
||||
"share_format_story" = "Story";
|
||||
"share_card_cta" = "Compartilhar";
|
||||
|
||||
// Share cards 2.1 — brand header + download CTA (1.6.0)
|
||||
"share_hero_eyebrow" = "Retorno total";
|
||||
"share_metric_yoy" = "Este ano";
|
||||
"share_metric_since_inception" = "Desde o início";
|
||||
"share_cta_headline" = "Acompanhe o seu — grátis";
|
||||
"share_cta_sub" = "Escaneie para obter o Portfolio Journal";
|
||||
|
||||
// Share cards 2.2 — as-of date + theme (1.6.0)
|
||||
"share_as_of" = "Em %@";
|
||||
"share_theme_dark" = "Escuro";
|
||||
"share_theme_light" = "Claro";
|
||||
|
||||
// Activation: first source → first snapshot (1.6.1)
|
||||
"add_source_value_header" = "Valor de hoje";
|
||||
"add_source_value_footer" = "Salvo como seu primeiro check-in — seu progresso começa hoje.";
|
||||
"quick_update_add_first_source" = "Adicionar sua primeira fonte";
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import GoogleMobileAds
|
||||
import UserMessagingPlatform
|
||||
import AppTrackingTransparency
|
||||
import AdSupport
|
||||
|
||||
@@ -12,6 +13,7 @@ class AdMobService: ObservableObject {
|
||||
@Published var isConsentObtained = false
|
||||
@Published var canShowAds = false
|
||||
@Published var isLoading = false
|
||||
@Published var shouldRequestNonPersonalizedAds = false
|
||||
|
||||
// MARK: - Ad Unit IDs
|
||||
|
||||
@@ -25,76 +27,107 @@ class AdMobService: ObservableObject {
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
checkConsentStatus()
|
||||
setupResetObserver()
|
||||
Task {
|
||||
await configureConsentAndRequestAdsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Consent Management
|
||||
|
||||
func checkConsentStatus() {
|
||||
// Check if we already have consent
|
||||
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
|
||||
isConsentObtained = consentStatus
|
||||
canShowAds = consentStatus
|
||||
}
|
||||
|
||||
func requestConsent() async {
|
||||
if #available(iOS 14.5, *) {
|
||||
let status = await ATTrackingManager.requestTrackingAuthorization()
|
||||
await configureConsentAndRequestAdsIfNeeded()
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
case .denied, .restricted:
|
||||
// Can still show non-personalized ads
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
case .notDetermined:
|
||||
// Will be asked again later
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
func presentPrivacyOptions() async {
|
||||
guard let root = topViewController() else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
ConsentForm.presentPrivacyOptionsForm(from: root) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
} else {
|
||||
// iOS 14.4 and earlier - consent assumed
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
|
||||
updateConsentState()
|
||||
}
|
||||
|
||||
// MARK: - GDPR Consent (UMP SDK)
|
||||
|
||||
func requestGDPRConsent() async {
|
||||
// Implement UMP SDK consent flow if targeting EU users
|
||||
// This is a simplified version - full implementation requires UMP SDK
|
||||
|
||||
let isEUUser = isUserInEU()
|
||||
|
||||
if isEUUser {
|
||||
// Show GDPR consent dialog
|
||||
// For now, assume consent if user continues
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
} else {
|
||||
isConsentObtained = true
|
||||
canShowAds = true
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
|
||||
func resetConsent() {
|
||||
ConsentInformation.shared.reset()
|
||||
UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained)
|
||||
isConsentObtained = false
|
||||
canShowAds = false
|
||||
shouldRequestNonPersonalizedAds = false
|
||||
}
|
||||
|
||||
private func isUserInEU() -> Bool {
|
||||
let euCountries = [
|
||||
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||||
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||||
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
|
||||
"NO", "CH"
|
||||
]
|
||||
private func setupResetObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .didResetData,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.resetConsent()
|
||||
await self.configureConsentAndRequestAdsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let countryCode = Locale.current.region?.identifier ?? ""
|
||||
return euCountries.contains(countryCode)
|
||||
private func configureConsentAndRequestAdsIfNeeded() async {
|
||||
isLoading = true
|
||||
let parameters = RequestParameters()
|
||||
await withCheckedContinuation { continuation in
|
||||
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
if let root = topViewController() {
|
||||
await withCheckedContinuation { continuation in
|
||||
ConsentForm.loadAndPresentIfRequired(from: root) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateConsentState()
|
||||
if canShowAds {
|
||||
await requestTrackingIfNeeded()
|
||||
updateConsentState()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func updateConsentState() {
|
||||
let consentInfo = ConsentInformation.shared
|
||||
canShowAds = consentInfo.canRequestAds
|
||||
isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired
|
||||
|
||||
let trackingDenied: Bool
|
||||
if #available(iOS 14.5, *) {
|
||||
trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized
|
||||
} else {
|
||||
trackingDenied = false
|
||||
}
|
||||
|
||||
shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied
|
||||
UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained)
|
||||
}
|
||||
|
||||
private func requestTrackingIfNeeded() async {
|
||||
if #available(iOS 14.5, *) {
|
||||
_ = await ATTrackingManager.requestTrackingAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else {
|
||||
return nil
|
||||
}
|
||||
var current = root
|
||||
while let presented = current.presentedViewController {
|
||||
current = presented
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
@@ -159,7 +192,13 @@ struct BannerAdView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
bannerView.delegate = context.coordinator
|
||||
bannerView.load(Request())
|
||||
let request = Request()
|
||||
if adMobService.shouldRequestNonPersonalizedAds {
|
||||
let extras = Extras()
|
||||
extras.additionalParameters = ["npa": "1"]
|
||||
request.register(extras)
|
||||
}
|
||||
bannerView.load(request)
|
||||
|
||||
return bannerView
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
|
||||
//
|
||||
// Surfaces the core monthly habit outside the app: "Update my portfolio" opens
|
||||
// Quick Update directly, "Check my portfolio" opens the dashboard. Registering an
|
||||
// AppShortcutsProvider also lists these actions in Spotlight and the Shortcuts app
|
||||
// with zero user setup — a discovery/retention surface the app didn't have.
|
||||
|
||||
struct QuickUpdateIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Update Portfolio"
|
||||
static let description = IntentDescription("Open Quick Update to record your latest portfolio values.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Same signal the widget deep link uses; ContentView routes it to the sheet.
|
||||
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenDashboardIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Check Portfolio"
|
||||
static let description = IntentDescription("Open the dashboard to see your net worth and charts.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
NotificationCenter.default.post(name: .openDashboard, object: nil)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parametric logging (headless)
|
||||
|
||||
/// An investment source exposed to Siri/Shortcuts, backed by the App Group
|
||||
/// mirror the Share Extension already uses — no Core Data access needed at
|
||||
/// resolution time, so it works instantly even before the app launches.
|
||||
struct SourceEntity: AppEntity {
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Source")
|
||||
static let defaultQuery = SourceEntityQuery()
|
||||
|
||||
let id: UUID
|
||||
let name: String
|
||||
let currencyCode: String
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
|
||||
init(_ info: SharedSourceInfo) {
|
||||
id = info.id
|
||||
name = info.name
|
||||
currencyCode = info.currencyCode
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceEntityQuery: EntityQuery {
|
||||
func entities(for identifiers: [UUID]) async throws -> [SourceEntity] {
|
||||
SharedQuickUpdateStore.readMirror()
|
||||
.filter { identifiers.contains($0.id) }
|
||||
.map(SourceEntity.init)
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [SourceEntity] {
|
||||
// Pending-first, mirroring the Share Extension's ordering.
|
||||
SharedQuickUpdateStore.readMirror()
|
||||
.sorted { a, b in
|
||||
if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth }
|
||||
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
||||
}
|
||||
.map(SourceEntity.init)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Log 15000 for Indexa" — records a snapshot without opening any UI. Runs in
|
||||
/// the app process in the background: the value goes through the same pending
|
||||
/// queue as the Share Extension and is ingested into Core Data immediately.
|
||||
struct LogSnapshotIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Log Portfolio Value"
|
||||
static let description = IntentDescription("Record a value for one of your sources without opening the app.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "Source")
|
||||
var source: SourceEntity
|
||||
|
||||
@Parameter(title: "Amount", requestValueDialog: "How much?")
|
||||
var amount: Double
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Log \(\.$amount) for \(\.$source)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
guard amount > 0 else {
|
||||
throw $amount.needsValueError(IntentDialog(stringLiteral: String(localized: "intent_amount_invalid")))
|
||||
}
|
||||
|
||||
SharedQuickUpdateStore.appendPending(PendingQuickUpdate(
|
||||
sourceId: source.id,
|
||||
amount: amount,
|
||||
capturedAt: Date()
|
||||
))
|
||||
// We're inside the app process: materialize the snapshot right away.
|
||||
SharedQuickUpdateSync.ingestPending()
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = source.currencyCode
|
||||
let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)"
|
||||
|
||||
let text = String(format: String(localized: "intent_logged_dialog"), formatted, source.name)
|
||||
return .result(dialog: IntentDialog(stringLiteral: text))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Net worth / streak (read-only, from the shared App Group store)
|
||||
|
||||
/// Reads aggregate portfolio facts from the App Group Core Data store the same
|
||||
/// way the widget does: a read-only container over the shared SQLite file. This
|
||||
/// lets the intents answer instantly without touching the app's CloudKit stack.
|
||||
enum SharedPortfolioReader {
|
||||
private static let storeFileName = "PortfolioJournal.sqlite"
|
||||
|
||||
struct Snapshot {
|
||||
let totalValue: Decimal
|
||||
let currencyCode: String
|
||||
let streak: Int
|
||||
let lastCheckIn: Date?
|
||||
}
|
||||
|
||||
private static func sharedStoreURL() -> URL? {
|
||||
FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier)?
|
||||
.appendingPathComponent(storeFileName)
|
||||
}
|
||||
|
||||
private static func makeContainer() -> NSPersistentContainer? {
|
||||
guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"),
|
||||
let model = NSManagedObjectModel(contentsOf: modelURL),
|
||||
let storeURL = sharedStoreURL(),
|
||||
FileManager.default.fileExists(atPath: storeURL.path) else {
|
||||
return nil
|
||||
}
|
||||
let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model)
|
||||
let description = NSPersistentStoreDescription(url: storeURL)
|
||||
description.isReadOnly = true
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
var loadError: Error?
|
||||
container.loadPersistentStores { _, error in loadError = error }
|
||||
return loadError == nil ? container : nil
|
||||
}
|
||||
|
||||
static func read() -> Snapshot {
|
||||
guard let container = makeContainer() else {
|
||||
return Snapshot(totalValue: 0, currencyCode: "EUR", streak: 0, lastCheckIn: nil)
|
||||
}
|
||||
let context = container.viewContext
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Currency
|
||||
var currency = "EUR"
|
||||
let settingsRequest = NSFetchRequest<NSManagedObject>(entityName: "AppSettings")
|
||||
settingsRequest.fetchLimit = 1
|
||||
if let settings = try? context.fetch(settingsRequest).first,
|
||||
let code = settings.value(forKey: "currency") as? String, !code.isEmpty {
|
||||
currency = code
|
||||
}
|
||||
|
||||
// Latest snapshot value per source → total net worth.
|
||||
// Also collect the set of (year, month) that have snapshot data for streak.
|
||||
let snapRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
|
||||
snapRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)]
|
||||
let snapshots = (try? context.fetch(snapRequest)) ?? []
|
||||
|
||||
var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal)] = [:]
|
||||
var monthComponents = Set<DateComponents>()
|
||||
for snap in snapshots {
|
||||
guard let source = snap.value(forKey: "source") as? NSManagedObject else { continue }
|
||||
let date = (snap.value(forKey: "date") as? Date) ?? .distantPast
|
||||
let value = (snap.value(forKey: "value") as? NSDecimalNumber)?.decimalValue ?? .zero
|
||||
let key = source.objectID
|
||||
if let existing = latestBySource[key] {
|
||||
if date >= existing.date { latestBySource[key] = (date, value) }
|
||||
} else {
|
||||
latestBySource[key] = (date, value)
|
||||
}
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
monthComponents.insert(DateComponents(year: c.year, month: c.month))
|
||||
}
|
||||
let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value }
|
||||
|
||||
// Streak: consecutive months with data counting back from most recent.
|
||||
let sortedMonthsDesc = monthComponents
|
||||
.compactMap { calendar.date(from: $0) }
|
||||
.sorted(by: >)
|
||||
var streak = 0
|
||||
if let mostRecent = sortedMonthsDesc.first {
|
||||
streak = 1
|
||||
var current = mostRecent
|
||||
for i in 1..<max(sortedMonthsDesc.count, 1) {
|
||||
guard i < sortedMonthsDesc.count,
|
||||
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
|
||||
let prev = sortedMonthsDesc[i]
|
||||
let pc = calendar.dateComponents([.year, .month], from: prev)
|
||||
let ec = calendar.dateComponents([.year, .month], from: expected)
|
||||
if pc.year == ec.year && pc.month == ec.month {
|
||||
streak += 1
|
||||
current = prev
|
||||
} else { break }
|
||||
}
|
||||
}
|
||||
|
||||
// Last check-in from the most recent completed JournalEntry.
|
||||
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
|
||||
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
|
||||
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
|
||||
journalRequest.fetchLimit = 1
|
||||
let lastCheckIn = (try? context.fetch(journalRequest))?.first?
|
||||
.value(forKey: "completionTime") as? Date
|
||||
|
||||
return Snapshot(totalValue: totalValue, currencyCode: currency, streak: streak, lastCheckIn: lastCheckIn)
|
||||
}
|
||||
}
|
||||
|
||||
/// "What's my net worth in Portfolio Journal" — reads the total portfolio value
|
||||
/// from the shared store and speaks it back. Runs headless (no app launch).
|
||||
struct NetWorthIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Check Net Worth"
|
||||
// ITMS-90626: intent descriptions must not contain the word "Siri".
|
||||
static let description = IntentDescription("Get your total portfolio value.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let snapshot = SharedPortfolioReader.read()
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = snapshot.currencyCode
|
||||
let formatted = formatter.string(from: NSDecimalNumber(decimal: snapshot.totalValue))
|
||||
?? "\(snapshot.totalValue)"
|
||||
|
||||
let text: String
|
||||
if snapshot.totalValue == 0 {
|
||||
text = String(localized: "intent_networth_empty")
|
||||
} else {
|
||||
text = String(format: String(localized: "intent_networth_dialog"), formatted)
|
||||
}
|
||||
return .result(dialog: IntentDialog(stringLiteral: text))
|
||||
}
|
||||
}
|
||||
|
||||
/// "What's my streak in Portfolio Journal" — reports the current check-in streak
|
||||
/// and when you last logged. Nice-to-have engagement surface. Runs headless.
|
||||
struct StreakIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Check Streak"
|
||||
// ITMS-90626: intent descriptions must not contain the word "Siri".
|
||||
static let description = IntentDescription("Get your current check-in streak.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let snapshot = SharedPortfolioReader.read()
|
||||
let text: String
|
||||
if snapshot.streak >= 1 {
|
||||
if let last = snapshot.lastCheckIn {
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .medium
|
||||
df.timeStyle = .none
|
||||
text = String(format: String(localized: "intent_streak_with_date"), snapshot.streak, df.string(from: last))
|
||||
} else {
|
||||
text = String(format: String(localized: "intent_streak"), snapshot.streak)
|
||||
}
|
||||
} else {
|
||||
text = String(localized: "intent_streak_none")
|
||||
}
|
||||
return .result(dialog: IntentDialog(stringLiteral: text))
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioJournalShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: QuickUpdateIntent(),
|
||||
phrases: [
|
||||
"Update my portfolio in \(.applicationName)",
|
||||
"Add a snapshot in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Update Portfolio",
|
||||
systemImageName: "plus.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: OpenDashboardIntent(),
|
||||
phrases: [
|
||||
"Check my portfolio in \(.applicationName)",
|
||||
"Show my net worth in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Check Portfolio",
|
||||
systemImageName: "chart.line.uptrend.xyaxis"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: LogSnapshotIntent(),
|
||||
phrases: [
|
||||
"Log a value in \(.applicationName)",
|
||||
"Log my \(\.$source) balance in \(.applicationName)",
|
||||
"Update \(\.$source) in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Log Value",
|
||||
systemImageName: "square.and.pencil"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: NetWorthIntent(),
|
||||
phrases: [
|
||||
"What's my net worth in \(.applicationName)",
|
||||
"How much is my portfolio worth in \(.applicationName)",
|
||||
"Get my net worth from \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Net Worth",
|
||||
systemImageName: "eurosign.circle.fill"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: StreakIntent(),
|
||||
phrases: [
|
||||
"What's my streak in \(.applicationName)",
|
||||
"Check my check-in streak in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Streak",
|
||||
systemImageName: "flame.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
// MARK: - Feature discovery (TipKit, Calm 2.0 Fase 4)
|
||||
//
|
||||
// The app's most differentiating features (quick update, screenshot OCR, Siri,
|
||||
// chart sharing) were invisible unless you stumbled on them. One contextual tip
|
||||
// each, shown at most once a day (see Tips.configure in PortfolioJournalApp),
|
||||
// auto-invalidated after display or when the action is performed.
|
||||
|
||||
struct QuickUpdateTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_quick_update_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_quick_update_body")) }
|
||||
var image: Image? { Image(systemName: "bolt.circle.fill") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(2)] }
|
||||
}
|
||||
|
||||
struct ScreenshotOCRTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_ocr_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_ocr_body")) }
|
||||
var image: Image? { Image(systemName: "text.viewfinder") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(2)] }
|
||||
}
|
||||
|
||||
struct SiriTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_siri_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_siri_body")) }
|
||||
var image: Image? { Image(systemName: "mic.fill") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(1)] }
|
||||
}
|
||||
|
||||
struct ChartShareTip: Tip {
|
||||
var title: Text { Text(String(localized: "tip_chart_share_title")) }
|
||||
var message: Text? { Text(String(localized: "tip_chart_share_body")) }
|
||||
var image: Image? { Image(systemName: "square.and.arrow.up") }
|
||||
var options: [any TipOption] { [MaxDisplayCount(2)] }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class AppUpdateService: ObservableObject {
|
||||
static let shared = AppUpdateService()
|
||||
@Published var updateAvailable = false
|
||||
@Published var latestVersion: String?
|
||||
|
||||
private init() {}
|
||||
|
||||
func checkForUpdate() {
|
||||
guard let bundleId = Bundle.main.bundleIdentifier else { return }
|
||||
let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let results = json["results"] as? [[String: Any]],
|
||||
let first = results.first,
|
||||
let appStoreVersion = first["version"] as? String,
|
||||
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
else { return }
|
||||
|
||||
if appStoreVersion.compare(currentVersion, options: .numeric) == .orderedDescending {
|
||||
self.updateAvailable = true
|
||||
self.latestVersion = appStoreVersion
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
enum BackupLocation: String {
|
||||
case local = "On device"
|
||||
case iCloud = "iCloud"
|
||||
}
|
||||
|
||||
struct BackupRecord: Identifiable {
|
||||
let id: String
|
||||
let url: URL
|
||||
let date: Date
|
||||
let size: Int64
|
||||
let location: BackupLocation
|
||||
}
|
||||
|
||||
class BackupService {
|
||||
static let shared = BackupService()
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let dateProvider: () -> Date
|
||||
private let localBaseDirectoryProvider: () -> URL?
|
||||
private let iCloudBaseDirectoryProvider: () -> URL?
|
||||
private let exportProvider: () -> String
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd-HHmmss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private init() {
|
||||
fileManager = .default
|
||||
dateProvider = Date.init
|
||||
localBaseDirectoryProvider = { [fileManager] in
|
||||
fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
}
|
||||
iCloudBaseDirectoryProvider = { [fileManager] in
|
||||
fileManager.url(forUbiquityContainerIdentifier: nil)
|
||||
}
|
||||
exportProvider = {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let sources = BackupService.fetchAllSources(in: context)
|
||||
let categories = BackupService.fetchAllCategories(in: context)
|
||||
return ExportService.shared.exportToJSON(sources: sources, categories: categories)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
dateProvider: @escaping () -> Date,
|
||||
localBaseDirectoryProvider: @escaping () -> URL?,
|
||||
iCloudBaseDirectoryProvider: @escaping () -> URL?,
|
||||
exportProvider: @escaping () -> String
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.dateProvider = dateProvider
|
||||
self.localBaseDirectoryProvider = localBaseDirectoryProvider
|
||||
self.iCloudBaseDirectoryProvider = iCloudBaseDirectoryProvider
|
||||
self.exportProvider = exportProvider
|
||||
}
|
||||
|
||||
func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] {
|
||||
let timestamp = dateFormatter.string(from: dateProvider())
|
||||
let fileName = "backup-\(timestamp).json"
|
||||
|
||||
let content = exportProvider()
|
||||
|
||||
var records: [BackupRecord] = []
|
||||
|
||||
if let localDir = backupDirectory() {
|
||||
let localURL = localDir.appendingPathComponent(fileName)
|
||||
write(content: content, to: localURL)
|
||||
pruneBackups(in: localDir, keep: retentionCount, location: .local)
|
||||
records.append(contentsOf: listBackups(in: localDir, location: .local))
|
||||
}
|
||||
|
||||
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
|
||||
let iCloudURL = iCloudDir.appendingPathComponent(fileName)
|
||||
write(content: content, to: iCloudURL)
|
||||
pruneBackups(in: iCloudDir, keep: retentionCount, location: .iCloud)
|
||||
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
|
||||
}
|
||||
|
||||
return records.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
/// Runs an automatic backup if auto-backup is enabled and the configured
|
||||
/// interval has elapsed since the last one. Call on app foreground. Reads its
|
||||
/// config from UserDefaults so it needs no coupling to the Settings VM. The
|
||||
/// backup content is the lossless JSON v3 export (goals, contributions, journal).
|
||||
@discardableResult
|
||||
func runScheduledBackupIfNeeded() -> Bool {
|
||||
let d = UserDefaults.standard
|
||||
// `backupsEnabled` is only settable by Premium users, so it also gates this.
|
||||
guard d.bool(forKey: "backupsEnabled") else { return false }
|
||||
let frequencyDays = d.integer(forKey: "autoBackupFrequencyDays") // 0 = off
|
||||
guard frequencyDays > 0 else { return false }
|
||||
|
||||
if let last = d.object(forKey: "lastAutoBackupDate") as? Date,
|
||||
dateProvider().timeIntervalSince(last) < Double(frequencyDays) * 86_400 {
|
||||
return false
|
||||
}
|
||||
|
||||
let retention = (d.object(forKey: "backupRetentionCount") as? Int) ?? 10
|
||||
let includeICloud = d.bool(forKey: "cloudSyncEnabled")
|
||||
_ = createBackup(retentionCount: retention, includeICloud: includeICloud)
|
||||
d.set(dateProvider(), forKey: "lastAutoBackupDate")
|
||||
return true
|
||||
}
|
||||
|
||||
func listAllBackups(includeICloud: Bool) -> [BackupRecord] {
|
||||
var records: [BackupRecord] = []
|
||||
if let localDir = backupDirectory() {
|
||||
records.append(contentsOf: listBackups(in: localDir, location: .local))
|
||||
}
|
||||
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
|
||||
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
|
||||
}
|
||||
return records.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private func backupDirectory() -> URL? {
|
||||
guard let base = localBaseDirectoryProvider() else {
|
||||
return nil
|
||||
}
|
||||
let dir = base.appendingPathComponent("Backups", isDirectory: true)
|
||||
ensureDirectoryExists(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func iCloudBackupDirectory() -> URL? {
|
||||
guard let base = iCloudBaseDirectoryProvider() else { return nil }
|
||||
let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true)
|
||||
ensureDirectoryExists(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func ensureDirectoryExists(_ url: URL) {
|
||||
if !fileManager.fileExists(atPath: url.path) {
|
||||
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func write(content: String, to url: URL) {
|
||||
do {
|
||||
try content.write(to: url, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
print("Backup write failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func listBackups(in directory: URL, location: BackupLocation) -> [BackupRecord] {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return files.compactMap { url in
|
||||
guard url.lastPathComponent.hasPrefix("backup-"),
|
||||
url.pathExtension.lowercased() == "json" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = url.deletingPathExtension().lastPathComponent
|
||||
let date = parseDate(from: name) ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date()
|
||||
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map { Int64($0) } ?? 0
|
||||
|
||||
return BackupRecord(
|
||||
id: "\(location.rawValue)-\(name)",
|
||||
url: url,
|
||||
date: date,
|
||||
size: size,
|
||||
location: location
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneBackups(in directory: URL, keep: Int, location: BackupLocation) {
|
||||
guard keep > 0 else { return }
|
||||
let backups = listBackups(in: directory, location: location).sorted { $0.date > $1.date }
|
||||
let toDelete = backups.dropFirst(keep)
|
||||
for backup in toDelete {
|
||||
try? fileManager.removeItem(at: backup.url)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseDate(from filename: String) -> Date? {
|
||||
let parts = filename.split(separator: "-")
|
||||
guard parts.count >= 3 else { return nil }
|
||||
let dateString = "\(parts[1])-\(parts[2])"
|
||||
return dateFormatter.date(from: dateString)
|
||||
}
|
||||
|
||||
private static func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -376,7 +376,7 @@ class CalculationService {
|
||||
sources: [InvestmentSource],
|
||||
totalPortfolioValue: Decimal
|
||||
) -> [CategoryMetrics] {
|
||||
categories.map { category in
|
||||
let rawMetrics = categories.map { category in
|
||||
let categorySources = sources.filter { $0.category?.id == category.id }
|
||||
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
|
||||
let metrics = calculateCategoryMetrics(from: allSnapshots)
|
||||
@@ -396,6 +396,24 @@ class CalculationService {
|
||||
metrics: metrics
|
||||
)
|
||||
}
|
||||
|
||||
let filtered = rawMetrics.filter { metric in
|
||||
metric.totalValue > 0 && sources.contains { $0.category?.id == metric.id }
|
||||
}
|
||||
|
||||
var deduped: [String: CategoryMetrics] = [:]
|
||||
for metric in filtered {
|
||||
let key = metric.categoryName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if let existing = deduped[key] {
|
||||
if metric.totalValue > existing.totalValue {
|
||||
deduped[key] = metric
|
||||
}
|
||||
} else {
|
||||
deduped[key] = metric
|
||||
}
|
||||
}
|
||||
|
||||
return Array(deduped.values)
|
||||
}
|
||||
|
||||
private struct SeriesPoint {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Environment flag for image export
|
||||
//
|
||||
// ImageRenderer can't rasterize Metal-backed `.drawingGroup()` layers (they come
|
||||
// out blank). Chart views check this flag and skip drawingGroup during export.
|
||||
|
||||
private struct ChartImageExportKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var chartImageExport: Bool {
|
||||
get { self[ChartImageExportKey.self] }
|
||||
set { self[ChartImageExportKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// GPU-rasterize for scroll performance, except while exporting a share
|
||||
/// image (ImageRenderer can't rasterize Metal layers — they come out blank).
|
||||
@ViewBuilder
|
||||
func chartDrawingGroup(disabledForExport isExporting: Bool) -> some View {
|
||||
if isExporting {
|
||||
self
|
||||
} else {
|
||||
drawingGroup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart share service
|
||||
//
|
||||
// Renders the current chart into a branded card (app mark, KPIs, QR to the App
|
||||
// Store) and presents the system share sheet. Reuses the QR/App Store plumbing
|
||||
// from GoalShareService.
|
||||
|
||||
@MainActor
|
||||
final class ChartShareService {
|
||||
static let shared = ChartShareService()
|
||||
private init() {}
|
||||
|
||||
static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")!
|
||||
|
||||
func share<Content: View>(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) {
|
||||
FirebaseService.shared.logShare(type: "chart")
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
let card = ChartShareCardView(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
stats: stats,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: Self.appStoreShareURL, size: 200),
|
||||
chart: chart()
|
||||
)
|
||||
|
||||
let renderer = ImageRenderer(content: card)
|
||||
renderer.scale = 3
|
||||
renderer.proposedSize = ProposedViewSize(width: 720, height: nil)
|
||||
|
||||
let shareText = String(format: String(localized: "chart_share_text"), title)
|
||||
+ "\n" + Self.appStoreShareURL.absoluteString
|
||||
|
||||
var items: [Any] = []
|
||||
if let image = renderer.uiImage {
|
||||
items.append(image)
|
||||
}
|
||||
items.append(shareText)
|
||||
|
||||
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
activityVC.excludedActivityTypes = [.addToReadingList, .assignToContact, .openInIBooks]
|
||||
|
||||
// iPad: activity sheet requires a popover anchor.
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
popover.permittedArrowDirections = []
|
||||
}
|
||||
|
||||
var presenter = viewController
|
||||
while let presented = presenter.presentedViewController {
|
||||
presenter = presented
|
||||
}
|
||||
presenter.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Branded card
|
||||
|
||||
struct ChartShareCardView<Content: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let stats: [ChartStat]
|
||||
let qrCodeImage: UIImage?
|
||||
let chart: Content
|
||||
|
||||
private static var dateLabel: String {
|
||||
Date().formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
// Header: brand + chart title
|
||||
HStack(spacing: 12) {
|
||||
if let brandMark = UIImage(named: "BrandMark") {
|
||||
Image(uiImage: brandMark)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Portfolio Journal")
|
||||
.font(.headline)
|
||||
Text("\(title) · \(Self.dateLabel)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
chart
|
||||
.environment(\.chartImageExport, true)
|
||||
|
||||
if !stats.isEmpty {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(stats.indices, id: \.self) { i in
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(stats[i].label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(stats[i].value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(stats[i].color)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer: tagline + QR to the App Store
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(String(localized: "chart_share_tagline"))
|
||||
.font(.footnote.weight(.medium))
|
||||
Text(verbatim: "portfoliojournal.app")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
Spacer()
|
||||
if let qrCodeImage {
|
||||
VStack(spacing: 4) {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
Text(String(localized: "chart_share_scan"))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 720)
|
||||
.background(Color(.systemBackground))
|
||||
.environment(\.colorScheme, .light)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
class ExportService {
|
||||
static let shared = ExportService()
|
||||
@@ -63,7 +64,7 @@ class ExportService {
|
||||
) -> String {
|
||||
var exportData: [String: Any] = [:]
|
||||
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
||||
exportData["version"] = 2
|
||||
exportData["version"] = 3
|
||||
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
|
||||
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
|
||||
@@ -92,14 +93,23 @@ class ExportService {
|
||||
"icon": category?.icon ?? "chart.pie.fill"
|
||||
]
|
||||
|
||||
if let target = category?.allocationTarget?.doubleValue, target > 0 {
|
||||
categoryDict["allocationTarget"] = target
|
||||
}
|
||||
|
||||
var sourcesArray: [[String: Any]] = []
|
||||
for source in categorySources {
|
||||
var sourceDict: [String: Any] = [
|
||||
"name": source.name,
|
||||
"isActive": source.isActive,
|
||||
"notificationFrequency": source.notificationFrequency
|
||||
"notificationFrequency": source.notificationFrequency,
|
||||
"customFrequencyMonths": Int(source.customFrequencyMonths)
|
||||
]
|
||||
|
||||
if let contribution = source.monthlyContribution {
|
||||
sourceDict["monthlyContribution"] = contribution.doubleValue
|
||||
}
|
||||
|
||||
var snapshotsArray: [[String: Any]] = []
|
||||
for snapshot in source.snapshotsArray {
|
||||
var snapshotDict: [String: Any] = [
|
||||
@@ -134,6 +144,54 @@ class ExportService {
|
||||
|
||||
exportData["accounts"] = accountsArray
|
||||
|
||||
// Goals (all accounts)
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
let goalContext = CoreDataStack.shared.viewContext
|
||||
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
let allGoals = (try? goalContext.fetch(goalRequest)) ?? []
|
||||
var goalsArray: [[String: Any]] = []
|
||||
for goal in allGoals {
|
||||
var goalDict: [String: Any] = [
|
||||
"name": goal.name,
|
||||
"isActive": goal.isActive,
|
||||
"account": goal.account?.name as Any
|
||||
]
|
||||
if let amount = goal.targetAmount {
|
||||
goalDict["targetAmount"] = amount.doubleValue
|
||||
}
|
||||
if let date = goal.targetDate {
|
||||
goalDict["targetDate"] = isoFormatter.string(from: date)
|
||||
}
|
||||
goalsArray.append(goalDict)
|
||||
}
|
||||
exportData["goals"] = goalsArray
|
||||
|
||||
// Journal entries (all)
|
||||
let journalRequest: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
|
||||
let allJournalEntries = (try? goalContext.fetch(journalRequest)) ?? []
|
||||
var journalArray: [[String: Any]] = []
|
||||
for entry in allJournalEntries {
|
||||
guard let monthKey = entry.monthKey, !monthKey.isEmpty else { continue }
|
||||
var entryDict: [String: Any] = ["monthKey": monthKey]
|
||||
if let note = entry.note, !note.isEmpty {
|
||||
entryDict["note"] = note
|
||||
}
|
||||
if let mood = entry.moodRaw, !mood.isEmpty {
|
||||
entryDict["mood"] = mood
|
||||
}
|
||||
if entry.rating > 0 {
|
||||
entryDict["rating"] = Int(entry.rating)
|
||||
}
|
||||
if let completionTime = entry.completionTime {
|
||||
entryDict["completionTime"] = isoFormatter.string(from: completionTime)
|
||||
}
|
||||
if let createdAt = entry.createdAt {
|
||||
entryDict["createdAt"] = isoFormatter.string(from: createdAt)
|
||||
}
|
||||
journalArray.append(entryDict)
|
||||
}
|
||||
exportData["journalEntries"] = journalArray
|
||||
|
||||
// Add summary
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
exportData["summary"] = [
|
||||
|
||||
@@ -70,7 +70,7 @@ class FirebaseService {
|
||||
func logPaywallShown(trigger: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("paywall_shown", parameters: [
|
||||
"trigger": trigger
|
||||
"paywall_trigger": trigger
|
||||
])
|
||||
}
|
||||
|
||||
@@ -163,6 +163,31 @@ class FirebaseService {
|
||||
])
|
||||
}
|
||||
|
||||
/// Per-step onboarding funnel event — lets us see exactly where users drop off
|
||||
/// inside onboarding (only the final `onboarding_completed` existed before).
|
||||
func logOnboardingStep(step: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_step", parameters: [
|
||||
"step": step
|
||||
])
|
||||
}
|
||||
|
||||
func logOnboardingSkipped(atStep step: Int) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("onboarding_skipped", parameters: [
|
||||
"step": step
|
||||
])
|
||||
}
|
||||
|
||||
/// Viral loop measurement: fired whenever the user opens a share sheet with
|
||||
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
|
||||
func logShare(type: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("content_shared", parameters: [
|
||||
"share_type": type
|
||||
])
|
||||
}
|
||||
|
||||
func logWidgetUsed(widgetType: String) {
|
||||
guard isConfigured else { return }
|
||||
Analytics.logEvent("widget_used", parameters: [
|
||||
|
||||
@@ -1,44 +1,193 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
class GoalShareService {
|
||||
static let shared = GoalShareService()
|
||||
|
||||
/// App Store URL - auto-redirects to user's country
|
||||
/// NOTE: 6757678318 is the real ADAM id (verified via App Store Connect API).
|
||||
/// The previous id (6744983373) returned a 404, breaking the share QR loop.
|
||||
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318")!
|
||||
|
||||
/// Website URL - has Open Graph tags for social media previews
|
||||
static let websiteURL = URL(string: "https://portfoliojournal.app")!
|
||||
|
||||
/// Share page base URL - parameters will be appended
|
||||
static let sharePageBaseURL = "https://portfoliojournal.app/share"
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Build share URL with goal parameters for dynamic OG tags
|
||||
static func buildShareURL(goalName: String, progressPercent: Int) -> URL {
|
||||
var components = URLComponents(string: sharePageBaseURL)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "goal", value: goalName),
|
||||
URLQueryItem(name: "progress", value: String(progressPercent))
|
||||
]
|
||||
return components.url ?? URL(string: sharePageBaseURL)!
|
||||
}
|
||||
|
||||
/// Generate a QR code image for the App Store URL
|
||||
static func generateQRCode(for url: URL, size: CGFloat = 60) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
|
||||
filter.message = Data(url.absoluteString.utf8)
|
||||
filter.correctionLevel = "M"
|
||||
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
|
||||
// Scale up the QR code
|
||||
let scale = size / outputImage.extent.size.width
|
||||
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||||
|
||||
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func shareGoal(
|
||||
name: String,
|
||||
progress: Double,
|
||||
currentValue: Decimal,
|
||||
targetValue: Decimal
|
||||
targetValue: Decimal,
|
||||
targetDate: Date? = nil,
|
||||
estimatedCompletionDate: Date? = nil,
|
||||
privacyMode: Bool = false
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: "goal")
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let viewController = windowScene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate QR code for the card
|
||||
let qrCodeImage = Self.generateQRCode(for: Self.appStoreURL, size: 200)
|
||||
|
||||
let card = GoalShareCardView(
|
||||
name: name,
|
||||
progress: progress,
|
||||
currentValue: currentValue,
|
||||
targetValue: targetValue
|
||||
targetValue: targetValue,
|
||||
targetDate: targetDate,
|
||||
estimatedCompletionDate: estimatedCompletionDate,
|
||||
privacyMode: privacyMode,
|
||||
qrCodeImage: qrCodeImage
|
||||
)
|
||||
|
||||
let progressPercent = Int(progress * 100)
|
||||
let shareURL = Self.buildShareURL(goalName: name, progressPercent: progressPercent)
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
let renderer = ImageRenderer(content: card)
|
||||
let scale = viewController.view.window?.windowScene?.screen.scale
|
||||
?? viewController.traitCollection.displayScale
|
||||
renderer.scale = scale
|
||||
|
||||
if let image = renderer.uiImage {
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
viewController.present(activityVC, animated: true)
|
||||
// Use combined item provider for text + image together
|
||||
let shareItem = GoalShareItem(
|
||||
image: image,
|
||||
goalName: name,
|
||||
progressPercent: progressPercent,
|
||||
shareURL: shareURL
|
||||
)
|
||||
presentShareSheet(items: [shareItem], from: viewController)
|
||||
} else {
|
||||
// Fallback to text only
|
||||
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
|
||||
presentShareSheet(items: [text], from: viewController)
|
||||
}
|
||||
} else {
|
||||
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
|
||||
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
|
||||
viewController.present(activityVC, animated: true)
|
||||
// iOS 15 fallback - text only
|
||||
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
|
||||
presentShareSheet(items: [text], from: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildShareText(goalName: String, progressPercent: Int, shareURL: URL) -> String {
|
||||
return """
|
||||
I'm \(progressPercent)% towards my "\(goalName)" goal! 🎯
|
||||
|
||||
Track your investment goals with Portfolio Journal.
|
||||
\(shareURL.absoluteString)
|
||||
"""
|
||||
}
|
||||
|
||||
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
|
||||
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
|
||||
// Exclude some activities that don't make sense for goal sharing
|
||||
activityVC.excludedActivityTypes = [
|
||||
.addToReadingList,
|
||||
.assignToContact,
|
||||
.openInIBooks
|
||||
]
|
||||
|
||||
// iPad support - prevent crash by setting popover source
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
popover.permittedArrowDirections = []
|
||||
}
|
||||
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined share item that provides both image and text together
|
||||
private class GoalShareItem: NSObject, UIActivityItemSource {
|
||||
let image: UIImage
|
||||
let goalName: String
|
||||
let progressPercent: Int
|
||||
let shareURL: URL
|
||||
|
||||
init(image: UIImage, goalName: String, progressPercent: Int, shareURL: URL) {
|
||||
self.image = image
|
||||
self.goalName = goalName
|
||||
self.progressPercent = progressPercent
|
||||
self.shareURL = shareURL
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
return image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
// For most activities, return the image
|
||||
// The text will be provided via LPLinkMetadata or as a separate item
|
||||
return image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||
return "My Investment Goal Progress - Portfolio Journal"
|
||||
}
|
||||
|
||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||
let metadata = LPLinkMetadata()
|
||||
metadata.title = "I'm \(progressPercent)% towards my \"\(goalName)\" goal! 🎯"
|
||||
metadata.originalURL = shareURL
|
||||
metadata.url = shareURL
|
||||
metadata.imageProvider = NSItemProvider(object: image)
|
||||
|
||||
// Set icon
|
||||
if let appIcon = UIImage(named: "BrandMark") {
|
||||
metadata.iconProvider = NSItemProvider(object: appIcon)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
|
||||
import LinkPresentation
|
||||
|
||||
@@ -16,7 +16,13 @@ class IAPService: ObservableObject {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// Lifetime unlock (non-consumable). Existing product — keep the id stable.
|
||||
static let premiumProductID = "com.portfoliojournal.premium"
|
||||
/// Annual auto-renewable subscription with intro free trial. Acts as the primary
|
||||
/// offer; lifetime becomes the "best value" anchor. Must be created in App Store
|
||||
/// Connect with this exact id — until then the paywall gracefully shows lifetime only.
|
||||
static let annualProductID = "com.portfoliojournal.premium.annual"
|
||||
static let allProductIDs: Set<String> = [premiumProductID, annualProductID]
|
||||
static let premiumPrice = "€4.69"
|
||||
|
||||
// MARK: - Private Properties
|
||||
@@ -49,6 +55,13 @@ class IAPService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) {
|
||||
isPremium = value
|
||||
isFamilyShared = familyShared
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
updateListenerTask?.cancel()
|
||||
}
|
||||
@@ -57,7 +70,7 @@ class IAPService: ObservableObject {
|
||||
|
||||
func loadProducts() async {
|
||||
do {
|
||||
products = try await Product.products(for: [Self.premiumProductID])
|
||||
products = try await Product.products(for: Self.allProductIDs)
|
||||
print("Loaded \(products.count) products")
|
||||
} catch {
|
||||
print("Failed to load products: \(error)")
|
||||
@@ -66,10 +79,16 @@ class IAPService: ObservableObject {
|
||||
|
||||
// MARK: - Purchase
|
||||
|
||||
/// Purchases the lifetime unlock (backwards-compatible entry point).
|
||||
func purchase() async throws {
|
||||
guard let product = products.first else {
|
||||
guard let product = premiumProduct ?? products.first else {
|
||||
throw IAPError.productNotFound
|
||||
}
|
||||
try await purchase(product)
|
||||
}
|
||||
|
||||
/// Purchases a specific product (annual subscription or lifetime unlock).
|
||||
func purchase(_ product: Product) async throws {
|
||||
|
||||
purchaseState = .purchasing
|
||||
|
||||
@@ -133,6 +152,13 @@ class IAPService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TestFlight Detection
|
||||
|
||||
/// TestFlight builds use a "sandboxReceipt" instead of the production "receipt".
|
||||
private var isRunningOnTestFlight: Bool {
|
||||
Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
||||
}
|
||||
|
||||
// MARK: - Update Premium Status
|
||||
|
||||
func updatePremiumStatus() async {
|
||||
@@ -147,10 +173,21 @@ class IAPService: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
|
||||
// TestFlight builds always get Premium so testers can evaluate all features
|
||||
if isRunningOnTestFlight {
|
||||
isPremium = true
|
||||
isFamilyShared = false
|
||||
sharedDefaults?.set(true, forKey: "premiumUnlocked")
|
||||
return
|
||||
}
|
||||
|
||||
var entitledProductID = Self.premiumProductID
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result {
|
||||
if transaction.productID == Self.premiumProductID {
|
||||
// Lifetime unlock OR an active annual subscription both grant premium.
|
||||
if Self.allProductIDs.contains(transaction.productID) {
|
||||
isEntitled = true
|
||||
entitledProductID = transaction.productID
|
||||
familyShared = transaction.ownershipType == .familyShared
|
||||
break
|
||||
}
|
||||
@@ -165,7 +202,7 @@ class IAPService: ObservableObject {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
PremiumStatus.updateStatus(
|
||||
isPremium: isEntitled,
|
||||
productIdentifier: Self.premiumProductID,
|
||||
productIdentifier: entitledProductID,
|
||||
transactionId: nil,
|
||||
isFamilyShared: familyShared,
|
||||
in: context
|
||||
@@ -216,9 +253,34 @@ class IAPService: ObservableObject {
|
||||
products.first { $0.id == Self.premiumProductID }
|
||||
}
|
||||
|
||||
/// Annual subscription, nil until the product exists in App Store Connect.
|
||||
var annualProduct: Product? {
|
||||
products.first { $0.id == Self.annualProductID }
|
||||
}
|
||||
|
||||
var formattedPrice: String {
|
||||
premiumProduct?.displayPrice ?? Self.premiumPrice
|
||||
}
|
||||
|
||||
var formattedAnnualPrice: String? {
|
||||
annualProduct?.displayPrice
|
||||
}
|
||||
|
||||
/// Localized description of the annual intro offer ("7 days free"), if configured.
|
||||
var annualTrialDescription: String? {
|
||||
guard let intro = annualProduct?.subscription?.introductoryOffer,
|
||||
intro.paymentMode == .freeTrial else { return nil }
|
||||
let period = intro.period
|
||||
let unit: String
|
||||
switch period.unit {
|
||||
case .day: unit = String(localized: "paywall_trial_days")
|
||||
case .week: unit = String(localized: "paywall_trial_weeks")
|
||||
case .month: unit = String(localized: "paywall_trial_months")
|
||||
case .year: unit = String(localized: "paywall_trial_years")
|
||||
@unknown default: unit = ""
|
||||
}
|
||||
return String(format: String(localized: "paywall_trial_format"), period.value, unit)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IAP Error
|
||||
@@ -253,4 +315,27 @@ extension IAPService {
|
||||
("xmark.circle", "No Ads", "Ad-free experience forever"),
|
||||
("person.2", "Family Sharing", "Share with up to 5 family members")
|
||||
]
|
||||
|
||||
/// Condensed benefits shown on the paywall (outcome-focused). Multiple Accounts and
|
||||
/// Family Sharing are key differentiators vs competitors — keep them visible here.
|
||||
static var paywallBenefits: [(icon: String, title: String, subtitle: String)] {[
|
||||
("clock.arrow.circlepath",
|
||||
String(localized: "paywall_benefit_history_title"),
|
||||
String(localized: "paywall_benefit_history_subtitle")),
|
||||
("chart.bar.xaxis",
|
||||
String(localized: "paywall_benefit_charts_title"),
|
||||
String(localized: "paywall_benefit_charts_subtitle")),
|
||||
("wand.and.stars",
|
||||
String(localized: "paywall_benefit_forecasts_title"),
|
||||
String(localized: "paywall_benefit_forecasts_subtitle")),
|
||||
("person.2",
|
||||
String(localized: "paywall_benefit_accounts_title"),
|
||||
String(localized: "paywall_benefit_accounts_subtitle")),
|
||||
("person.3",
|
||||
String(localized: "paywall_benefit_family_title"),
|
||||
String(localized: "paywall_benefit_family_subtitle")),
|
||||
("xmark.circle",
|
||||
String(localized: "paywall_benefit_noads_title"),
|
||||
String(localized: "paywall_benefit_noads_subtitle"))
|
||||
]}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,25 @@ class ImportService {
|
||||
case json
|
||||
}
|
||||
|
||||
/// Read-only summary of what an import WOULD do, computed against existing
|
||||
/// data without mutating anything. Presented to the user for confirmation
|
||||
/// before the real import runs.
|
||||
struct ImportPreview {
|
||||
var sourcesToCreate: Int = 0
|
||||
var snapshotsToCreate: Int = 0
|
||||
var snapshotsToUpdate: Int = 0
|
||||
var categoriesToCreate: Int = 0
|
||||
var goalsToCreate: Int = 0
|
||||
var journalToCreate: Int = 0
|
||||
var journalToUpdate: Int = 0
|
||||
|
||||
var hasAnything: Bool {
|
||||
sourcesToCreate > 0 || snapshotsToCreate > 0 || snapshotsToUpdate > 0 ||
|
||||
categoriesToCreate > 0 || goalsToCreate > 0 ||
|
||||
journalToCreate > 0 || journalToUpdate > 0
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportedAccount {
|
||||
let name: String
|
||||
let currency: String?
|
||||
@@ -44,11 +63,14 @@ class ImportService {
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
let icon: String?
|
||||
let allocationTarget: Double?
|
||||
let sources: [ImportedSource]
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let monthlyContribution: Decimal?
|
||||
let customFrequencyMonths: Int?
|
||||
let snapshots: [ImportedSnapshot]
|
||||
}
|
||||
|
||||
@@ -59,6 +81,23 @@ class ImportService {
|
||||
let notes: String?
|
||||
}
|
||||
|
||||
struct ImportedGoal {
|
||||
let name: String
|
||||
let targetAmount: Decimal?
|
||||
let targetDate: Date?
|
||||
let isActive: Bool
|
||||
let accountName: String?
|
||||
}
|
||||
|
||||
struct ImportedJournalEntry {
|
||||
let monthKey: String
|
||||
let note: String?
|
||||
let mood: String?
|
||||
let rating: Int?
|
||||
let completionTime: Date?
|
||||
let createdAt: Date?
|
||||
}
|
||||
|
||||
func importData(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
@@ -79,7 +118,14 @@ class ImportService {
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
let goals = parseGoals(content)
|
||||
let journal = parseJournal(content)
|
||||
return applyImport(
|
||||
parsed,
|
||||
context: CoreDataStack.shared.viewContext,
|
||||
goals: goals,
|
||||
journal: journal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +139,8 @@ class ImportService {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
var goals: [ImportedGoal] = []
|
||||
var journal: [ImportedJournalEntry] = []
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
@@ -106,6 +154,8 @@ class ImportService {
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
goals = self.parseGoals(content)
|
||||
journal = self.parseJournal(content)
|
||||
}
|
||||
|
||||
let totalSnapshots = parsed.reduce(0) { total, account in
|
||||
@@ -120,7 +170,12 @@ class ImportService {
|
||||
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
||||
}
|
||||
|
||||
let result = self.applyImport(parsed, context: context) { completed in
|
||||
let result = self.applyImport(
|
||||
parsed,
|
||||
context: context,
|
||||
goals: goals,
|
||||
journal: journal
|
||||
) { completed in
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(
|
||||
completed: completed,
|
||||
@@ -147,7 +202,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
static func sampleJSON() -> String {
|
||||
return """
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"currency": "EUR",
|
||||
"accounts": [{
|
||||
"name": "Personal",
|
||||
@@ -171,6 +226,203 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Dry-Run Preview (read-only)
|
||||
|
||||
/// Parses the content and counts what an import WOULD do against existing
|
||||
/// data, WITHOUT mutating anything. Uses the same parse functions and the
|
||||
/// same existence checks as `applyImport`, but only reads.
|
||||
func previewImportAsync(
|
||||
content: String,
|
||||
format: ImportFormat,
|
||||
allowMultipleAccounts: Bool,
|
||||
defaultAccountName: String? = nil
|
||||
) async -> ImportPreview {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let parsed: [ImportedAccount]
|
||||
var goals: [ImportedGoal] = []
|
||||
var journal: [ImportedJournalEntry] = []
|
||||
switch format {
|
||||
case .csv:
|
||||
parsed = self.parseCSV(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
case .json:
|
||||
parsed = self.parseJSON(
|
||||
content,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
goals = self.parseGoals(content)
|
||||
journal = self.parseJournal(content)
|
||||
}
|
||||
let preview = self.computePreview(
|
||||
accounts: parsed,
|
||||
goals: goals,
|
||||
journal: journal,
|
||||
context: context
|
||||
)
|
||||
continuation.resume(returning: preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run for the CSV column-mapping flow.
|
||||
func previewCSVWithMappingAsync(
|
||||
content: String,
|
||||
mapping: CSVMappingConfig,
|
||||
defaultAccountName: String?
|
||||
) async -> ImportPreview {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let preview = self.previewCSV(content)
|
||||
let accounts = self.parseCSVWithMapping(
|
||||
preview: preview,
|
||||
mapping: mapping,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
let result = self.computePreview(
|
||||
accounts: accounts,
|
||||
goals: [],
|
||||
journal: [],
|
||||
context: context
|
||||
)
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only counting pass. Mirrors the existence checks in `applyImport`
|
||||
/// (same source name+account, same-day snapshot, category name, goal
|
||||
/// name+account, journal monthKey) but never writes. Because it never
|
||||
/// saves, it may not perfectly disambiguate multiple new sources/categories
|
||||
/// that collapse to the same name within one import; it errs toward the
|
||||
/// user-facing counts `applyImport` would produce for typical files.
|
||||
private func computePreview(
|
||||
accounts: [ImportedAccount],
|
||||
goals: [ImportedGoal],
|
||||
journal: [ImportedJournalEntry],
|
||||
context: NSManagedObjectContext
|
||||
) -> ImportPreview {
|
||||
var preview = ImportPreview()
|
||||
|
||||
let existingCategories = fetchCategories(in: context)
|
||||
let categoryLookup = buildCategoryLookup(from: existingCategories)
|
||||
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||
|
||||
// Track names we would create so duplicates within the same import file
|
||||
// aren't double-counted.
|
||||
var plannedCategoryKeys = Set<String>()
|
||||
var plannedSourceKeys = Set<String>() // "accountName|sourceName"
|
||||
var accountsByName: [String: Account] = existingAccountsLookup
|
||||
|
||||
// "Other" is created on demand by applyImport when a source has no
|
||||
// resolvable category; only count it if we actually need it.
|
||||
var otherCategoryNeeded = false
|
||||
let otherExists = resolveExistingCategory(named: "Other", lookup: categoryLookup) != nil
|
||||
|
||||
for importedAccount in accounts {
|
||||
let account = existingAccountsLookup[importedAccount.name]
|
||||
accountsByName[importedAccount.name] = account
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
named: importedCategory.name,
|
||||
lookup: categoryLookup
|
||||
)
|
||||
let shouldUseOther = existingCategory == nil &&
|
||||
importedCategory.colorHex == nil &&
|
||||
importedCategory.icon == nil
|
||||
|
||||
if existingCategory == nil {
|
||||
if shouldUseOther {
|
||||
otherCategoryNeeded = otherCategoryNeeded || !otherExists
|
||||
} else {
|
||||
let key = normalizedCategoryName(
|
||||
canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
|
||||
)
|
||||
if !plannedCategoryKeys.contains(key) {
|
||||
plannedCategoryKeys.insert(key)
|
||||
preview.categoriesToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let accountId = account?.safeId
|
||||
let existingSource = fetchSource(
|
||||
named: importedSource.name,
|
||||
accountId: accountId,
|
||||
in: context
|
||||
)
|
||||
let sourceKey = "\(importedAccount.name)|\(importedSource.name)"
|
||||
if existingSource == nil, !plannedSourceKeys.contains(sourceKey) {
|
||||
plannedSourceKeys.insert(sourceKey)
|
||||
preview.sourcesToCreate += 1
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
// Only an EXISTING source can have a same-day snapshot to update.
|
||||
if let existing = existingSource,
|
||||
fetchSnapshot(for: existing, date: snapshot.date, in: context) != nil {
|
||||
preview.snapshotsToUpdate += 1
|
||||
} else {
|
||||
preview.snapshotsToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if otherCategoryNeeded {
|
||||
preview.categoriesToCreate += 1
|
||||
}
|
||||
|
||||
// Goals — dedup by name within account (case-insensitive), same as applyImport.
|
||||
var plannedGoalKeys = Set<String>()
|
||||
for importedGoal in goals {
|
||||
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
|
||||
if !plannedGoalKeys.contains(key) {
|
||||
plannedGoalKeys.insert(key)
|
||||
preview.goalsToCreate += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Journal — dedup by monthKey, same as applyImport.
|
||||
var plannedJournalKeys = Set<String>()
|
||||
for importedEntry in journal {
|
||||
guard !plannedJournalKeys.contains(importedEntry.monthKey) else { continue }
|
||||
plannedJournalKeys.insert(importedEntry.monthKey)
|
||||
if fetchJournalEntry(monthKey: importedEntry.monthKey, in: context) != nil {
|
||||
preview.journalToUpdate += 1
|
||||
} else {
|
||||
preview.journalToCreate += 1
|
||||
}
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
/// Read-only fetch of an existing source by name within an account (or with
|
||||
/// no account). Mirrors applyImport's `name && account?.id` match.
|
||||
private func fetchSource(
|
||||
named name: String,
|
||||
accountId: UUID?,
|
||||
in context: NSManagedObjectContext
|
||||
) -> InvestmentSource? {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "name == %@", name)
|
||||
let matches = (try? context.fetch(request)) ?? []
|
||||
return matches.first { $0.account?.id == accountId }
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseCSV(
|
||||
@@ -230,9 +482,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return grouped.map { accountName, categories in
|
||||
let importedCategories = categories.map { categoryName, sources in
|
||||
let importedSources = sources.map { sourceName, snapshots in
|
||||
ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: nil,
|
||||
icon: nil,
|
||||
allocationTarget: nil,
|
||||
sources: importedSources
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
@@ -274,9 +537,12 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
||||
let colorHex = categoryDict["color"] as? String
|
||||
let icon = categoryDict["icon"] as? String
|
||||
let allocationTarget = categoryDict["allocationTarget"] as? Double
|
||||
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
||||
let sources = sourcesArray.map { sourceDict in
|
||||
let sourceName = sourceDict["name"] as? String ?? "Source"
|
||||
let monthlyContribution = (sourceDict["monthlyContribution"] as? Double).map { Decimal($0) }
|
||||
let customFrequencyMonths = sourceDict["customFrequencyMonths"] as? Int
|
||||
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
||||
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
||||
guard let dateString = snapshotDict["date"] as? String,
|
||||
@@ -295,13 +561,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
return ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: monthlyContribution,
|
||||
customFrequencyMonths: customFrequencyMonths,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
allocationTarget: allocationTarget,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
@@ -344,13 +616,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
return ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
allocationTarget: nil,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
@@ -371,11 +649,70 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return []
|
||||
}
|
||||
|
||||
private func parseGoals(_ content: String) -> [ImportedGoal] {
|
||||
guard let data = content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let goalsArray = json["goals"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return goalsArray.compactMap { goalDict -> ImportedGoal? in
|
||||
guard let name = goalDict["name"] as? String,
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let targetAmount = (goalDict["targetAmount"] as? Double).map { Decimal($0) }
|
||||
let targetDate = (goalDict["targetDate"] as? String)
|
||||
.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
let isActive = goalDict["isActive"] as? Bool ?? true
|
||||
let accountName = goalDict["account"] as? String
|
||||
return ImportedGoal(
|
||||
name: name,
|
||||
targetAmount: targetAmount,
|
||||
targetDate: targetDate,
|
||||
isActive: isActive,
|
||||
accountName: accountName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseJournal(_ content: String) -> [ImportedJournalEntry] {
|
||||
guard let data = content.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let journalArray = json["journalEntries"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return journalArray.compactMap { entryDict -> ImportedJournalEntry? in
|
||||
guard let monthKey = entryDict["monthKey"] as? String,
|
||||
!monthKey.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let note = entryDict["note"] as? String
|
||||
let mood = entryDict["mood"] as? String
|
||||
let rating = entryDict["rating"] as? Int
|
||||
let completionTime = (entryDict["completionTime"] as? String)
|
||||
.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
let createdAt = (entryDict["createdAt"] as? String)
|
||||
.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
return ImportedJournalEntry(
|
||||
monthKey: monthKey,
|
||||
note: note,
|
||||
mood: mood,
|
||||
rating: rating,
|
||||
completionTime: completionTime,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Apply Import
|
||||
|
||||
private func applyImport(
|
||||
_ accounts: [ImportedAccount],
|
||||
context: NSManagedObjectContext,
|
||||
goals: [ImportedGoal] = [],
|
||||
journal: [ImportedJournalEntry] = [],
|
||||
snapshotProgress: ((Int) -> Void)? = nil
|
||||
) -> ImportResult {
|
||||
let accountRepository = AccountRepository(context: context)
|
||||
@@ -383,6 +720,15 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
let sourceRepository = InvestmentSourceRepository(context: context)
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
|
||||
// Batch import: suppress the per-row widget refresh (each does a synchronous
|
||||
// WAL checkpoint on the main context) to avoid hanging the main thread on
|
||||
// large imports. Refresh once when everything is done.
|
||||
CoreDataStack.shared.suppressWidgetRefresh = true
|
||||
defer {
|
||||
CoreDataStack.shared.suppressWidgetRefresh = false
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
}
|
||||
|
||||
var accountsCreated = 0
|
||||
var sourcesCreated = 0
|
||||
var snapshotsCreated = 0
|
||||
@@ -404,6 +750,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
// Build lookup of existing accounts by fetching directly from database
|
||||
let existingAccountsLookup = fetchAccountsLookup(in: context)
|
||||
|
||||
var accountsByName: [String: Account] = existingAccountsLookup
|
||||
|
||||
for importedAccount in accounts {
|
||||
let existingAccount = existingAccountsLookup[importedAccount.name]
|
||||
let account: Account
|
||||
@@ -419,6 +767,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
accountsCreated += 1
|
||||
}
|
||||
accountsByName[importedAccount.name] = account
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
@@ -438,6 +787,10 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
))
|
||||
categoryLookup[normalizedCategoryName(category.name)] = category
|
||||
|
||||
if let allocationTarget = importedCategory.allocationTarget {
|
||||
category.allocationTarget = NSNumber(value: allocationTarget)
|
||||
}
|
||||
|
||||
for importedSource in importedCategory.sources {
|
||||
let accountId = account.safeId
|
||||
let existingSource = sourceRepository.sources.first(where: {
|
||||
@@ -454,6 +807,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
sourcesCreated += 1
|
||||
}
|
||||
|
||||
if let monthlyContribution = importedSource.monthlyContribution {
|
||||
source.monthlyContribution = NSDecimalNumber(decimal: monthlyContribution)
|
||||
}
|
||||
if let customFrequencyMonths = importedSource.customFrequencyMonths {
|
||||
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||
}
|
||||
|
||||
for snapshot in importedSource.snapshots {
|
||||
// Check if a snapshot with the same date already exists for this source
|
||||
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
|
||||
@@ -475,7 +835,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
date: snapshot.date,
|
||||
value: snapshot.value,
|
||||
contribution: snapshot.contribution,
|
||||
notes: snapshot.notes
|
||||
notes: snapshot.notes,
|
||||
batch: true
|
||||
)
|
||||
snapshotsCreated += 1
|
||||
}
|
||||
@@ -494,6 +855,47 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
}
|
||||
|
||||
// Goals: create directly on the background context. Dedup by name within
|
||||
// the same account (case-insensitive), mirroring GoalRepository behaviour.
|
||||
for importedGoal in goals {
|
||||
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { continue }
|
||||
guard let targetAmount = importedGoal.targetAmount else { continue }
|
||||
|
||||
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
|
||||
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
|
||||
let goal = Goal(context: context)
|
||||
goal.name = trimmedName
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.targetDate = importedGoal.targetDate
|
||||
goal.account = resolvedAccount
|
||||
goal.isActive = importedGoal.isActive
|
||||
}
|
||||
}
|
||||
|
||||
// Journal entries: create directly on the background context. Dedup by
|
||||
// monthKey; update fields when an entry already exists.
|
||||
for importedEntry in journal {
|
||||
let entry = fetchJournalEntry(monthKey: importedEntry.monthKey, in: context)
|
||||
?? JournalEntry(context: context)
|
||||
entry.monthKey = importedEntry.monthKey
|
||||
if let note = importedEntry.note, !note.isEmpty {
|
||||
entry.note = note
|
||||
}
|
||||
if let mood = importedEntry.mood, !mood.isEmpty {
|
||||
entry.moodRaw = mood
|
||||
}
|
||||
if let rating = importedEntry.rating {
|
||||
entry.rating = Int16(rating)
|
||||
}
|
||||
if let completionTime = importedEntry.completionTime {
|
||||
entry.completionTime = completionTime
|
||||
}
|
||||
if let createdAt = importedEntry.createdAt {
|
||||
entry.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
@@ -502,12 +904,26 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
}
|
||||
|
||||
if !completionDatesByMonth.isEmpty {
|
||||
for (monthKey, completionDate) in completionDatesByMonth {
|
||||
// Snapshot-derived completion marking is a FALLBACK for imports that carry no
|
||||
// journal (CSV, or JSON without journalEntries): it marks months that have
|
||||
// snapshots as "checked in" so the Home card looks right. When the import DOES
|
||||
// bring journal entries, they already carry their real completionTime and were
|
||||
// created on the background context above — running setCompletionDate here (on
|
||||
// viewContext, which hasn't merged those yet) would create DUPLICATE
|
||||
// JournalEntry rows for the same month. So skip it when journal was imported.
|
||||
if journal.isEmpty, !completionDatesByMonth.isEmpty {
|
||||
// MonthlyCheckInStore mutates CoreDataStack.shared.viewContext (main queue).
|
||||
// applyImport runs on a BACKGROUND context, so calling setCompletionDate
|
||||
// here directly was a Core Data concurrency violation → the imported months'
|
||||
// JournalEntry.completionTime never persisted, leaving the "next check-in"
|
||||
// stuck on an old entry. Run it on the viewContext's own queue.
|
||||
CoreDataStack.shared.viewContext.performAndWait {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
if let monthDate = formatter.date(from: monthKey) {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
||||
for (monthKey, completionDate) in completionDatesByMonth {
|
||||
if let monthDate = formatter.date(from: monthKey) {
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +1002,37 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
/// Fetch an existing goal by name (case-insensitive) within the same account.
|
||||
private func fetchGoal(
|
||||
named name: String,
|
||||
account: Account?,
|
||||
in context: NSManagedObjectContext
|
||||
) -> Goal? {
|
||||
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
if let account = account {
|
||||
request.predicate = NSPredicate(
|
||||
format: "name ==[c] %@ AND account == %@", name, account
|
||||
)
|
||||
} else {
|
||||
request.predicate = NSPredicate(
|
||||
format: "name ==[c] %@ AND account == nil", name
|
||||
)
|
||||
}
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
/// Fetch an existing journal entry by monthKey.
|
||||
private func fetchJournalEntry(
|
||||
monthKey: String,
|
||||
in context: NSManagedObjectContext
|
||||
) -> JournalEntry? {
|
||||
let request: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "monthKey == %@", monthKey)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
private func canonicalCategoryName(for rawName: String) -> String? {
|
||||
let normalized = normalizedCategoryName(rawName)
|
||||
for mapping in categoryAliasMappings {
|
||||
@@ -655,6 +1102,195 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - CSV Column Mapping
|
||||
|
||||
/// Parsed preview data for the mapping UI
|
||||
struct CSVPreview {
|
||||
let rows: [[String]]
|
||||
let columnCount: Int
|
||||
|
||||
func headers(hasHeaderRow: Bool) -> [String] {
|
||||
if hasHeaderRow, let first = rows.first {
|
||||
return first
|
||||
}
|
||||
return (0..<columnCount).map { "Column \($0 + 1)" }
|
||||
}
|
||||
|
||||
func sampleRow(hasHeaderRow: Bool) -> [String]? {
|
||||
let start = hasHeaderRow ? 1 : 0
|
||||
return rows.count > start ? rows[start] : nil
|
||||
}
|
||||
}
|
||||
|
||||
func previewCSV(_ content: String) -> CSVPreview {
|
||||
let rows = parseCSVRows(content)
|
||||
let columnCount = rows.first?.count ?? 0
|
||||
return CSVPreview(rows: rows, columnCount: columnCount)
|
||||
}
|
||||
|
||||
// Maps each app field to a CSV column index or a constant value
|
||||
struct CSVMappingConfig {
|
||||
// -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index
|
||||
static let notMapped = -1
|
||||
static let constant = -2
|
||||
static let useToday = -3
|
||||
|
||||
var hasHeaderRow: Bool = true
|
||||
var sourceIndex: Int = notMapped
|
||||
var sourceConstant: String = ""
|
||||
var valueIndex: Int = notMapped
|
||||
var dateIndex: Int = useToday // default: use today
|
||||
var categoryIndex: Int = constant
|
||||
var categoryConstant: String = "Other" // default category name when constant
|
||||
var contributionIndex: Int = notMapped
|
||||
var notesIndex: Int = notMapped
|
||||
|
||||
var isValid: Bool {
|
||||
let sourceOk: Bool = {
|
||||
if sourceIndex == Self.constant { return !sourceConstant.isEmpty }
|
||||
return sourceIndex >= 0
|
||||
}()
|
||||
let valueOk = valueIndex >= 0
|
||||
return sourceOk && valueOk
|
||||
}
|
||||
}
|
||||
|
||||
func importCSVWithMapping(
|
||||
content: String,
|
||||
mapping: CSVMappingConfig,
|
||||
defaultAccountName: String?
|
||||
) -> ImportResult {
|
||||
let preview = previewCSV(content)
|
||||
let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
|
||||
return applyImport(accounts, context: CoreDataStack.shared.viewContext)
|
||||
}
|
||||
|
||||
func importCSVWithMappingAsync(
|
||||
content: String,
|
||||
mapping: CSVMappingConfig,
|
||||
defaultAccountName: String?,
|
||||
progress: @escaping (ImportProgress) -> Void
|
||||
) async -> ImportResult {
|
||||
await withCheckedContinuation { continuation in
|
||||
CoreDataStack.shared.performBackgroundTask { context in
|
||||
let preview = self.previewCSV(content)
|
||||
let accounts = self.parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
|
||||
|
||||
let totalSnapshots = accounts.reduce(0) { t, a in
|
||||
t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } }
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
||||
}
|
||||
|
||||
let result = self.applyImport(accounts, context: context) { completed in
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(
|
||||
completed: completed,
|
||||
total: totalSnapshots,
|
||||
message: "Imported \(completed) of \(totalSnapshots) snapshots"
|
||||
))
|
||||
}
|
||||
}
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseCSVWithMapping(
|
||||
preview: CSVPreview,
|
||||
mapping: CSVMappingConfig,
|
||||
defaultAccountName: String?
|
||||
) -> [ImportedAccount] {
|
||||
let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows
|
||||
let fallbackAccount = defaultAccountName ?? "Personal"
|
||||
|
||||
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
|
||||
|
||||
for row in dataRows {
|
||||
// Source name
|
||||
let sourceName: String
|
||||
if mapping.sourceIndex == CSVMappingConfig.constant {
|
||||
guard !mapping.sourceConstant.isEmpty else { continue }
|
||||
sourceName = mapping.sourceConstant
|
||||
} else if mapping.sourceIndex >= 0 {
|
||||
guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue }
|
||||
sourceName = v
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Value
|
||||
guard mapping.valueIndex >= 0,
|
||||
let valStr = row.safeValue(at: mapping.valueIndex),
|
||||
let value = parseDecimal(valStr) else { continue }
|
||||
|
||||
// Date
|
||||
let date: Date
|
||||
if mapping.dateIndex == CSVMappingConfig.useToday {
|
||||
date = Calendar.current.startOfDay(for: Date())
|
||||
} else if mapping.dateIndex >= 0,
|
||||
let dateStr = row.safeValue(at: mapping.dateIndex),
|
||||
let parsed = parseDate(dateStr) {
|
||||
date = parsed
|
||||
} else {
|
||||
date = Calendar.current.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
// Category
|
||||
let categoryName: String
|
||||
if mapping.categoryIndex == CSVMappingConfig.constant {
|
||||
categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant
|
||||
} else if mapping.categoryIndex >= 0,
|
||||
let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty {
|
||||
categoryName = v
|
||||
} else {
|
||||
categoryName = "Other"
|
||||
}
|
||||
|
||||
// Contribution
|
||||
let contribution: Decimal? = mapping.contributionIndex >= 0
|
||||
? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal)
|
||||
: nil
|
||||
|
||||
// Notes
|
||||
let notes: String? = mapping.notesIndex >= 0
|
||||
? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 }
|
||||
: nil
|
||||
|
||||
let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes)
|
||||
grouped[fallbackAccount, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
|
||||
}
|
||||
|
||||
return grouped.map { accountName, categories in
|
||||
let importedCategories = categories.map { categoryName, sources in
|
||||
let importedSources = sources.map { sourceName, snapshots in
|
||||
ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: nil,
|
||||
icon: nil,
|
||||
allocationTarget: nil,
|
||||
sources: importedSources
|
||||
)
|
||||
}
|
||||
return ImportedAccount(
|
||||
name: accountName,
|
||||
currency: nil,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly,
|
||||
customFrequencyMonths: 1,
|
||||
categories: importedCategories
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CSV Helpers
|
||||
|
||||
private func parseCSVRows(_ content: String) -> [[String]] {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
|
||||
/// On-device, privacy-friendly rule-based insights engine.
|
||||
///
|
||||
/// Produces a prioritized list of `PortfolioInsight`s purely from local data —
|
||||
/// no network, no AI calls. The `DashboardViewModel` gathers the inputs from
|
||||
/// Core Data and passes them in; this keeps the rules pure and testable.
|
||||
enum InsightsEngine {
|
||||
|
||||
/// One category's allocation snapshot for the allocation-vs-target rule.
|
||||
struct CategorySlice {
|
||||
let name: String
|
||||
let percentageOfPortfolio: Double
|
||||
/// User's target allocation percentage, if set.
|
||||
let targetPercentage: Double?
|
||||
/// Trailing return percentage over the analysed window, if computable.
|
||||
let returnPercentage: Double?
|
||||
}
|
||||
|
||||
/// One source's weight for the concentration-risk rule.
|
||||
struct SourceSlice {
|
||||
let name: String
|
||||
let percentageOfPortfolio: Double
|
||||
}
|
||||
|
||||
struct Input {
|
||||
let totalValue: Decimal
|
||||
let categories: [CategorySlice]
|
||||
let sources: [SourceSlice]
|
||||
/// Number of categories that actually hold value.
|
||||
let fundedCategoryCount: Int
|
||||
/// Number of sources that actually hold value.
|
||||
let fundedSourceCount: Int
|
||||
let yearChangePercentage: Double
|
||||
let allTimeReturn: Decimal
|
||||
let updateStreak: Int
|
||||
let forecast: PortfolioForecast?
|
||||
}
|
||||
|
||||
// Thresholds
|
||||
private static let concentrationThreshold = 0.40 // one source > 40% of portfolio
|
||||
private static let allocationDriftThreshold = 5.0 // > 5 percentage points off target
|
||||
private static let milestoneNearThreshold = 0.90 // within 10% of next milestone
|
||||
|
||||
static func makeInsights(_ input: Input, limit: Int = 4) -> [PortfolioInsight] {
|
||||
guard input.totalValue > 0 else { return [] }
|
||||
var result: [PortfolioInsight] = []
|
||||
|
||||
result.append(contentsOf: milestoneInsight(input))
|
||||
result.append(contentsOf: allocationDriftInsight(input))
|
||||
result.append(contentsOf: concentrationInsight(input))
|
||||
result.append(contentsOf: performerInsights(input))
|
||||
result.append(contentsOf: diversificationInsight(input))
|
||||
result.append(contentsOf: streakInsight(input))
|
||||
result.append(contentsOf: ytdInsight(input))
|
||||
result.append(contentsOf: marketGainsInsight(input))
|
||||
result.append(contentsOf: forecastInsight(input))
|
||||
|
||||
// Prioritize: attention first, then positive, then neutral; stable within tone.
|
||||
let sorted = result.enumerated().sorted { lhs, rhs in
|
||||
if lhs.element.tone.priority != rhs.element.tone.priority {
|
||||
return lhs.element.tone.priority > rhs.element.tone.priority
|
||||
}
|
||||
return lhs.offset < rhs.offset
|
||||
}.map(\.element)
|
||||
|
||||
return Array(sorted.prefix(limit))
|
||||
}
|
||||
|
||||
// MARK: - Rules
|
||||
|
||||
private static func milestoneInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000,
|
||||
2_500_000, 5_000_000, 10_000_000]
|
||||
guard let next = milestones.first(where: { $0 > input.totalValue }) else { return [] }
|
||||
let pct = NSDecimalNumber(decimal: input.totalValue / next).doubleValue
|
||||
guard pct >= milestoneNearThreshold else { return [] }
|
||||
let gap = next - input.totalValue
|
||||
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
|
||||
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
|
||||
return [PortfolioInsight(
|
||||
id: "milestone",
|
||||
systemImage: "flag.checkered",
|
||||
title: String(localized: "insight_milestone_title"),
|
||||
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
|
||||
accentColor: .orange,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func allocationDriftInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
// Pick the category most off its target (over or under).
|
||||
let drifts = input.categories.compactMap { cat -> (CategorySlice, Double)? in
|
||||
guard let target = cat.targetPercentage, target > 0 else { return nil }
|
||||
return (cat, cat.percentageOfPortfolio - target)
|
||||
}
|
||||
guard let (cat, drift) = drifts.max(by: { abs($0.1) < abs($1.1) }),
|
||||
abs(drift) >= allocationDriftThreshold,
|
||||
let target = cat.targetPercentage else { return [] }
|
||||
|
||||
let over = drift > 0
|
||||
let value = String(
|
||||
format: String(localized: over ? "insight_allocation_over_value" : "insight_allocation_under_value"),
|
||||
cat.name,
|
||||
cat.percentageOfPortfolio,
|
||||
target
|
||||
)
|
||||
return [PortfolioInsight(
|
||||
id: "allocation_\(cat.name)",
|
||||
systemImage: over ? "arrow.up.forward.circle.fill" : "arrow.down.forward.circle.fill",
|
||||
title: String(localized: "insight_allocation_title"),
|
||||
value: value,
|
||||
accentColor: .appWarning,
|
||||
detail: String(localized: "insight_allocation_detail"),
|
||||
tone: .attention
|
||||
)]
|
||||
}
|
||||
|
||||
private static func concentrationInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard let top = input.sources.max(by: { $0.percentageOfPortfolio < $1.percentageOfPortfolio }),
|
||||
top.percentageOfPortfolio >= concentrationThreshold * 100,
|
||||
input.fundedSourceCount >= 2 else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "concentration",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
title: String(localized: "insight_concentration_title"),
|
||||
value: String(format: String(localized: "insight_concentration_value"), top.name, top.percentageOfPortfolio),
|
||||
accentColor: .appWarning,
|
||||
detail: String(localized: "insight_concentration_detail"),
|
||||
tone: .attention
|
||||
)]
|
||||
}
|
||||
|
||||
private static func performerInsights(_ input: Input) -> [PortfolioInsight] {
|
||||
let ranked = input.categories.compactMap { cat -> (CategorySlice, Double)? in
|
||||
guard let ret = cat.returnPercentage else { return nil }
|
||||
return (cat, ret)
|
||||
}
|
||||
guard ranked.count >= 2 else { return [] }
|
||||
var out: [PortfolioInsight] = []
|
||||
|
||||
if let best = ranked.max(by: { $0.1 < $1.1 }), best.1 > 0 {
|
||||
out.append(PortfolioInsight(
|
||||
id: "best_performer",
|
||||
systemImage: "trophy.fill",
|
||||
title: String(localized: "insight_best_performer_title"),
|
||||
value: String(format: String(localized: "insight_performer_value"), best.0.name, best.1),
|
||||
accentColor: .positiveGreen,
|
||||
tone: .positive
|
||||
))
|
||||
}
|
||||
if let worst = ranked.min(by: { $0.1 < $1.1 }), worst.1 < 0 {
|
||||
out.append(PortfolioInsight(
|
||||
id: "worst_performer",
|
||||
systemImage: "chart.line.downtrend.xyaxis",
|
||||
title: String(localized: "insight_worst_performer_title"),
|
||||
value: String(format: String(localized: "insight_performer_value"), worst.0.name, worst.1),
|
||||
accentColor: .negativeRed,
|
||||
tone: .attention
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func diversificationInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
// Encourage diversification only when the portfolio is thin.
|
||||
if input.fundedSourceCount == 1 {
|
||||
return [PortfolioInsight(
|
||||
id: "diversification",
|
||||
systemImage: "square.grid.2x2",
|
||||
title: String(localized: "insight_diversification_title"),
|
||||
value: String(localized: "insight_diversification_single_value"),
|
||||
accentColor: .appPrimary,
|
||||
tone: .neutral
|
||||
)]
|
||||
}
|
||||
if input.fundedCategoryCount >= 4 {
|
||||
return [PortfolioInsight(
|
||||
id: "diversification",
|
||||
systemImage: "square.grid.2x2.fill",
|
||||
title: String(localized: "insight_diversification_title"),
|
||||
value: String(format: String(localized: "insight_diversification_good_value"), input.fundedCategoryCount),
|
||||
accentColor: .positiveGreen,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func streakInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard input.updateStreak >= 3 else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "streak",
|
||||
systemImage: "flame.fill",
|
||||
title: String(localized: "insight_streak_title"),
|
||||
value: String(format: String(localized: "insight_streak_value"), input.updateStreak),
|
||||
accentColor: .orange,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func ytdInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
let ytd = input.yearChangePercentage
|
||||
guard abs(ytd) >= 0.1 else { return [] }
|
||||
let icon = ytd >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
|
||||
return [PortfolioInsight(
|
||||
id: "ytd",
|
||||
systemImage: icon,
|
||||
title: String(localized: "insight_ytd_title"),
|
||||
value: String(format: "%+.1f%%", ytd),
|
||||
accentColor: ytd >= 0 ? .positiveGreen : .negativeRed,
|
||||
tone: ytd >= 0 ? .positive : .neutral
|
||||
)]
|
||||
}
|
||||
|
||||
private static func marketGainsInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard input.allTimeReturn > 0 else { return [] }
|
||||
let gainStr = CurrencyFormatter.format(input.allTimeReturn, style: .currency, maximumFractionDigits: 0)
|
||||
return [PortfolioInsight(
|
||||
id: "market_gains",
|
||||
systemImage: "chart.line.uptrend.xyaxis",
|
||||
title: String(localized: "insight_market_gains_title"),
|
||||
value: String(format: String(localized: "insight_market_gains_value"), gainStr),
|
||||
accentColor: .appPrimary,
|
||||
tone: .positive
|
||||
)]
|
||||
}
|
||||
|
||||
private static func forecastInsight(_ input: Input) -> [PortfolioInsight] {
|
||||
guard let forecast = input.forecast, forecast.forecastValue > input.totalValue else { return [] }
|
||||
return [PortfolioInsight(
|
||||
id: "forecast",
|
||||
systemImage: "wand.and.stars",
|
||||
title: String(localized: "insight_forecast_title"),
|
||||
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
|
||||
accentColor: .purple,
|
||||
tone: .neutral
|
||||
)]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
@@ -31,6 +32,9 @@ class NotificationService: ObservableObject {
|
||||
await MainActor.run {
|
||||
self.isAuthorized = granted
|
||||
}
|
||||
if granted {
|
||||
scheduleMonthlyPerformanceSummary()
|
||||
}
|
||||
return granted
|
||||
} catch {
|
||||
print("Notification authorization error: \(error)")
|
||||
@@ -155,6 +159,18 @@ extension NotificationService {
|
||||
func handleNotificationResponse(_ response: UNNotificationResponse) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Handle batch update deep link from monthly check-in
|
||||
if let action = userInfo["action"] as? String, action == "batchUpdate" {
|
||||
NotificationCenter.default.post(name: .openBatchUpdate, object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle openDashboard deep link from monthly summary notification
|
||||
if let action = userInfo["action"] as? String, action == "openDashboard" {
|
||||
NotificationCenter.default.post(name: .openDashboard, object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let sourceIdString = userInfo["sourceId"] as? String,
|
||||
let sourceId = UUID(uuidString: sourceIdString) else {
|
||||
return
|
||||
@@ -174,6 +190,409 @@ extension NotificationService {
|
||||
extension Notification.Name {
|
||||
static let openSourceDetail = Notification.Name("openSourceDetail")
|
||||
static let didResetData = Notification.Name("didResetData")
|
||||
static let openBatchUpdate = Notification.Name("openBatchUpdate")
|
||||
static let openDashboard = Notification.Name("openDashboard")
|
||||
static let openQuickUpdate = Notification.Name("openQuickUpdate")
|
||||
static let openAddFirstSource = Notification.Name("openAddFirstSource")
|
||||
}
|
||||
|
||||
// MARK: - Re-engagement Notifications
|
||||
|
||||
extension NotificationService {
|
||||
/// Schedules a re-engagement notification 7 days from now.
|
||||
/// Call this every time the app becomes active to reset the timer.
|
||||
///
|
||||
/// Instead of a fixed generic message, the body is built from the user's real
|
||||
/// KPIs (goal ETA, YoY, monthly gain, milestone proximity, update streak) to
|
||||
/// spark curiosity. Among the hooks that currently apply we rotate one per call
|
||||
/// so the copy stays fresh instead of repeating the same line every week.
|
||||
func scheduleReEngagementNotification() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
let hooks = buildReEngagementHooks()
|
||||
if let hook = hooks.isEmpty ? nil : hooks[nextReEngagementRotationIndex(count: hooks.count)] {
|
||||
content.title = hook.title
|
||||
content.body = hook.body
|
||||
} else {
|
||||
// No data yet (brand-new / empty portfolio) → generic fallback.
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
}
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "openDashboard"]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Re-engagement notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances and returns the rotation index over the eligible hooks so a different
|
||||
/// one surfaces each time we (re)schedule.
|
||||
private func nextReEngagementRotationIndex(count: Int) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
let key = "reengagementRotationIndex"
|
||||
let stored = UserDefaults.standard.integer(forKey: key)
|
||||
UserDefaults.standard.set(stored &+ 1, forKey: key)
|
||||
return ((stored % count) + count) % count
|
||||
}
|
||||
|
||||
/// Schedules a non-repeating monthly check-in notification for the 1st of the next month
|
||||
/// that doesn't have a completed check-in. Safe to call on every app activation.
|
||||
func scheduleMonthlyCheckIn() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "monthly_checkin"
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
|
||||
|
||||
// Walk forward from next month to find the first month whose check-in is not done
|
||||
for offset in 1...13 {
|
||||
guard let targetStart = calendar.date(byAdding: .month, value: offset, to: thisMonthStart) else { break }
|
||||
let isDone = MonthlyCheckInStore.completionDate(for: targetStart.adding(days: 1)) != nil
|
||||
if isDone { continue }
|
||||
|
||||
var components = calendar.dateComponents([.year, .month], from: targetStart)
|
||||
components.day = 1
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "monthly_checkin_notification_title")
|
||||
content.body = String(localized: "monthly_checkin_notification_body")
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "batchUpdate"]
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Monthly check-in notification error: \(error)")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// Streak protection: reminds the user on the 25th of the CURRENT month if this
|
||||
/// month's check-in is still pending — loss aversion beats the day-1 nudge alone.
|
||||
/// Safe to call on every app activation (no-op if already done or date passed).
|
||||
func scheduleStreakProtectionReminder() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "streak_protection"
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
|
||||
|
||||
// Already checked in this month → nothing to protect
|
||||
guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return }
|
||||
|
||||
var components = calendar.dateComponents([.year, .month], from: thisMonthStart)
|
||||
components.day = 25
|
||||
components.hour = 18
|
||||
components.minute = 0
|
||||
guard let fireDate = calendar.date(from: components), fireDate > now else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "streak_protection_notification_title")
|
||||
content.body = String(localized: "streak_protection_notification_body")
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "batchUpdate"]
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Streak protection notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
|
||||
func scheduleMonthlyPerformanceSummary() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "monthly_summary"
|
||||
center.getPendingNotificationRequests { [weak self] requests in
|
||||
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "monthly_summary_notification_title")
|
||||
content.body = String(localized: "monthly_summary_notification_body")
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "openDashboard"]
|
||||
|
||||
var components = DateComponents()
|
||||
components.day = 5
|
||||
components.hour = 9
|
||||
components.minute = 0
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
self.center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Monthly summary notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires a one-time local notification celebrating goal achievement.
|
||||
func scheduleGoalAchievedNotification(goalName: String) {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "goal_achieved_notification_title")
|
||||
content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName)
|
||||
content.sound = .default
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
let id = "goal_achieved_\(UUID().uuidString)"
|
||||
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
|
||||
|
||||
center.add(request) { error in
|
||||
if let error = error {
|
||||
print("Goal achieved notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires a notification when the portfolio crosses a round milestone for the first time.
|
||||
func checkAndScheduleMilestoneNotification(portfolioValue: Decimal) {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000]
|
||||
let notifiedKey = "lastNotifiedMilestone"
|
||||
let lastNotified = UserDefaults.standard.double(forKey: notifiedKey)
|
||||
let current = NSDecimalNumber(decimal: portfolioValue).doubleValue
|
||||
|
||||
for milestone in milestones.reversed() {
|
||||
let ms = NSDecimalNumber(decimal: milestone).doubleValue
|
||||
if current >= ms {
|
||||
if ms > lastNotified {
|
||||
UserDefaults.standard.set(ms, forKey: notifiedKey)
|
||||
let milestoneStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "notification_milestone_title")
|
||||
content.body = String(format: String(localized: "notification_milestone_body"), milestoneStr)
|
||||
content.sound = .default
|
||||
content.userInfo = ["action": "openDashboard"]
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "portfolio_milestone_\(Int(ms))",
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
center.add(request) { _ in }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Re-engagement KPI copy
|
||||
|
||||
private extension NotificationService {
|
||||
|
||||
struct ReEngagementHook {
|
||||
let title: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
static let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000]
|
||||
|
||||
static let monthYearFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
static let percentFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 0
|
||||
f.maximumFractionDigits = 1
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Builds the list of KPI-driven hooks that currently apply. A neutral
|
||||
/// curiosity hook is always appended (when the user has data) so the message
|
||||
/// never has to fall back to the bland generic copy and never states a loss.
|
||||
func buildReEngagementHooks() -> [ReEngagementHook] {
|
||||
let ctx = CoreDataStack.shared.viewContext
|
||||
|
||||
let sourceReq: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
sourceReq.predicate = NSPredicate(format: "isActive == YES")
|
||||
let sources = (try? ctx.fetch(sourceReq)) ?? []
|
||||
guard !sources.isEmpty else { return [] }
|
||||
|
||||
let summary = CalculationService.shared.calculatePortfolioSummary(from: sources, snapshots: [])
|
||||
let evolution = monthlyEvolution(from: sources)
|
||||
|
||||
var hooks: [ReEngagementHook] = []
|
||||
|
||||
// 1. Goal ETA — "at this pace you'll reach '<goal>' by <month year>"
|
||||
if let goalHook = goalEtaHook(ctx: ctx, sources: sources) {
|
||||
hooks.append(goalHook)
|
||||
}
|
||||
|
||||
// 2. Year-over-year growth
|
||||
if summary.yearChangePercentage >= 5,
|
||||
let pct = Self.percentFormatter.string(from: NSNumber(value: summary.yearChangePercentage)) {
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_yoy_title"),
|
||||
body: String(format: String(localized: "reengagement_yoy_body"), "\(pct)%")))
|
||||
}
|
||||
|
||||
// 3. Positive monthly change
|
||||
if summary.monthChange > 0 {
|
||||
let amount = CurrencyFormatter.format(summary.monthChange, style: .currency, maximumFractionDigits: 0)
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_month_title"),
|
||||
body: String(format: String(localized: "reengagement_month_body"), amount)))
|
||||
}
|
||||
|
||||
// 4. Close to the next round milestone (within 10% below it)
|
||||
if let msHook = milestoneProximityHook(totalValue: summary.totalValue) {
|
||||
hooks.append(msHook)
|
||||
}
|
||||
|
||||
// 5. Update streak of 3+ consecutive months
|
||||
if let streak = monthlyStreak(evolution: evolution), streak >= 3 {
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_streak_title"),
|
||||
body: String(format: String(localized: "reengagement_streak_body"), streak)))
|
||||
}
|
||||
|
||||
// 6. Neutral curiosity — always available, never states a loss.
|
||||
hooks.append(ReEngagementHook(
|
||||
title: String(localized: "reengagement_neutral_title"),
|
||||
body: String(localized: "reengagement_neutral_body")))
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
/// Portfolio total at the end of each month that has any snapshot, ascending.
|
||||
func monthlyEvolution(from sources: [InvestmentSource]) -> [(date: Date, value: Decimal)] {
|
||||
let cal = Calendar.current
|
||||
var monthStarts = Set<Date>()
|
||||
for source in sources {
|
||||
for snap in source.sortedSnapshotsByDateAscending {
|
||||
if let m = cal.date(from: cal.dateComponents([.year, .month], from: snap.date)) {
|
||||
monthStarts.insert(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return monthStarts.sorted().map { monthStart in
|
||||
let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) ?? monthStart
|
||||
var total = Decimal.zero
|
||||
for source in sources {
|
||||
if let snap = source.sortedSnapshotsByDateAscending.last(where: { $0.date <= monthEnd }) {
|
||||
total += snap.decimalValue
|
||||
}
|
||||
}
|
||||
return (monthStart, total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects the first active goal reachable on the current 6-month trend and
|
||||
/// returns a hook naming its ETA. Mirrors GoalsViewModel.estimateCompletionDate.
|
||||
func goalEtaHook(ctx: NSManagedObjectContext, sources: [InvestmentSource]) -> ReEngagementHook? {
|
||||
let req: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "isActive == YES")
|
||||
let goals = (try? ctx.fetch(req)) ?? []
|
||||
guard !goals.isEmpty else { return nil }
|
||||
|
||||
let cal = Calendar.current
|
||||
for goal in goals {
|
||||
let goalSources: [InvestmentSource]
|
||||
if let accId = goal.account?.id {
|
||||
goalSources = sources.filter { $0.account?.id == accId }
|
||||
} else {
|
||||
goalSources = sources
|
||||
}
|
||||
|
||||
let evolution = monthlyEvolution(from: goalSources)
|
||||
guard evolution.count >= 3,
|
||||
let first = evolution.suffix(6).first,
|
||||
let last = evolution.suffix(6).last else { continue }
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else { continue }
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else { continue }
|
||||
|
||||
let remaining = goal.targetDecimal - last.value
|
||||
guard remaining > 0 else { continue } // already reached → skip
|
||||
let monthsToGo = Int(ceil(NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue))
|
||||
guard monthsToGo > 0, monthsToGo <= 600,
|
||||
let eta = cal.date(byAdding: .month, value: monthsToGo, to: last.date) else { continue }
|
||||
|
||||
let etaStr = Self.monthYearFormatter.string(from: eta)
|
||||
return ReEngagementHook(
|
||||
title: String(localized: "reengagement_goal_title"),
|
||||
body: String(format: String(localized: "reengagement_goal_body"), goal.name, etaStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Hook when the portfolio is within 10% below the next round milestone.
|
||||
func milestoneProximityHook(totalValue: Decimal) -> ReEngagementHook? {
|
||||
let current = NSDecimalNumber(decimal: totalValue).doubleValue
|
||||
guard current > 0 else { return nil }
|
||||
|
||||
for milestone in Self.milestones {
|
||||
let target = NSDecimalNumber(decimal: milestone).doubleValue
|
||||
if current < target {
|
||||
guard current >= target * 0.9 else { return nil } // next one up is too far
|
||||
let msStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
|
||||
return ReEngagementHook(
|
||||
title: String(localized: "reengagement_milestone_title"),
|
||||
body: String(format: String(localized: "reengagement_milestone_body"), msStr))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Number of consecutive months (ending at the most recent one with data) that
|
||||
/// have a snapshot. `evolution` already holds one entry per month.
|
||||
func monthlyStreak(evolution: [(date: Date, value: Decimal)]) -> Int? {
|
||||
guard !evolution.isEmpty else { return nil }
|
||||
let cal = Calendar.current
|
||||
let months = Set(evolution.map { $0.date })
|
||||
guard var cursor = months.max() else { return nil }
|
||||
|
||||
var count = 0
|
||||
while months.contains(cursor) {
|
||||
count += 1
|
||||
guard let prev = cal.date(byAdding: .month, value: -1, to: cursor) else { break }
|
||||
cursor = prev
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
@@ -3,370 +3,195 @@ import Foundation
|
||||
class PredictionEngine {
|
||||
static let shared = PredictionEngine()
|
||||
|
||||
private let context = CoreDataStack.shared.viewContext
|
||||
|
||||
// MARK: - Performance: Cached Calendar reference
|
||||
private static let calendar = Calendar.current
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Main Prediction Interface
|
||||
// MARK: - Public Interface
|
||||
|
||||
func predict(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
algorithm: PredictionAlgorithm? = nil
|
||||
) -> PredictionResult {
|
||||
guard snapshots.count >= 3 else {
|
||||
return PredictionResult(
|
||||
predictions: [],
|
||||
algorithm: .linear,
|
||||
accuracy: 0,
|
||||
volatility: 0
|
||||
)
|
||||
}
|
||||
func predict(snapshots: [Snapshot], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult {
|
||||
guard snapshots.count >= 3 else { return emptyResult() }
|
||||
let sorted = snapshots.sorted { $0.date < $1.date }
|
||||
return predictFromValues(
|
||||
sorted.map { $0.decimalValue.doubleValue },
|
||||
dates: sorted.map { $0.date },
|
||||
monthsAhead: monthsAhead,
|
||||
algorithm: algorithm
|
||||
)
|
||||
}
|
||||
|
||||
// Sort snapshots by date
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
func predict(series: [(date: Date, value: Decimal)], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult {
|
||||
guard series.count >= 3 else { return emptyResult() }
|
||||
let sorted = series.sorted { $0.date < $1.date }
|
||||
return predictFromValues(
|
||||
sorted.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||||
dates: sorted.map { $0.date },
|
||||
monthsAhead: monthsAhead,
|
||||
algorithm: algorithm
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate volatility for algorithm selection
|
||||
let volatility = calculateVolatility(snapshots: sortedSnapshots)
|
||||
// MARK: - Private Core
|
||||
|
||||
// Select algorithm if not specified
|
||||
private func emptyResult() -> PredictionResult {
|
||||
PredictionResult(predictions: [], algorithm: .linear, accuracy: 0, volatility: 0)
|
||||
}
|
||||
|
||||
private func predictFromValues(_ values: [Double], dates: [Date], monthsAhead: Int, algorithm: PredictionAlgorithm?) -> PredictionResult {
|
||||
let volatility = calculateVolatility(values: values)
|
||||
let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility)
|
||||
let lastDate = dates.last!
|
||||
let firstDate = dates.first!
|
||||
|
||||
// Generate predictions
|
||||
let predictions: [Prediction]
|
||||
let accuracy: Double
|
||||
|
||||
switch selectedAlgorithm {
|
||||
case .linear:
|
||||
predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = linearPredictions(values: values, firstDate: firstDate, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = linearAccuracy(values: values, firstDate: firstDate)
|
||||
case .exponentialSmoothing:
|
||||
predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateESAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = esPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = esAccuracy(values: values)
|
||||
case .movingAverage:
|
||||
predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateMAAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = maPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = maAccuracy(values: values)
|
||||
case .holtTrend:
|
||||
predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead)
|
||||
accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots)
|
||||
predictions = holtPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead)
|
||||
accuracy = holtAccuracy(values: values)
|
||||
}
|
||||
|
||||
return PredictionResult(
|
||||
predictions: predictions,
|
||||
algorithm: selectedAlgorithm,
|
||||
accuracy: accuracy,
|
||||
volatility: volatility
|
||||
)
|
||||
return PredictionResult(predictions: predictions, algorithm: selectedAlgorithm, accuracy: accuracy, volatility: volatility)
|
||||
}
|
||||
|
||||
// MARK: - Algorithm Selection
|
||||
|
||||
private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm {
|
||||
switch volatility {
|
||||
case 0..<8:
|
||||
return .holtTrend
|
||||
case 8..<20:
|
||||
return .exponentialSmoothing
|
||||
default:
|
||||
return .movingAverage
|
||||
case 0..<8: return .holtTrend
|
||||
case 8..<20: return .exponentialSmoothing
|
||||
default: return .movingAverage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Linear Regression
|
||||
|
||||
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
guard let firstDate = snapshots.first?.date else { return [] }
|
||||
|
||||
let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in
|
||||
let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400
|
||||
return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue)
|
||||
}
|
||||
|
||||
private func linearPredictions(values: [Double], firstDate: Date, lastDate: Date, monthsAhead: Int) -> [Prediction] {
|
||||
let dataPoints = values.enumerated().map { (x: Double($0.offset), y: $0.element) }
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints)
|
||||
let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept)
|
||||
let n = Double(values.count)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400
|
||||
let predictedValue = max(0, slope * daysFromStart + intercept)
|
||||
|
||||
// Widen confidence interval for further predictions
|
||||
let confidenceMultiplier = 1.0 + (Double(month) * 0.02)
|
||||
let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
return (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let x = n - 1 + Double(month)
|
||||
let predicted = max(0, slope * x + intercept)
|
||||
let width = residualStdDev * 1.96 * (1.0 + Double(month) * 0.02)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .linear,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateLinearRegression(
|
||||
dataPoints: [(x: Double, y: Double)]
|
||||
) -> (slope: Double, intercept: Double) {
|
||||
let n = Double(dataPoints.count)
|
||||
let sumX = dataPoints.reduce(0) { $0 + $1.x }
|
||||
let sumY = dataPoints.reduce(0) { $0 + $1.y }
|
||||
let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) }
|
||||
let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) }
|
||||
|
||||
let denominator = n * sumX2 - sumX * sumX
|
||||
guard denominator != 0 else { return (0, sumY / n) }
|
||||
|
||||
let slope = (n * sumXY - sumX * sumY) / denominator
|
||||
let intercept = (sumY - slope * sumX) / n
|
||||
|
||||
return (slope, intercept)
|
||||
}
|
||||
|
||||
private func calculateResidualStdDev(
|
||||
dataPoints: [(x: Double, y: Double)],
|
||||
slope: Double,
|
||||
intercept: Double
|
||||
) -> Double {
|
||||
guard dataPoints.count > 2 else { return 0 }
|
||||
|
||||
let residuals = dataPoints.map { point in
|
||||
let predicted = slope * point.x + intercept
|
||||
return pow(point.y - predicted, 2)
|
||||
}
|
||||
|
||||
let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2)
|
||||
return sqrt(meanSquaredError)
|
||||
}
|
||||
|
||||
private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
// Use last 20% of data for validation
|
||||
let splitIndex = Int(Double(snapshots.count) * 0.8)
|
||||
let trainingData = Array(snapshots.prefix(splitIndex))
|
||||
let validationData = Array(snapshots.suffix(from: splitIndex))
|
||||
|
||||
guard let firstDate = trainingData.first?.date else { return 0.5 }
|
||||
|
||||
let trainPoints = trainingData.map { snapshot in
|
||||
(x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue)
|
||||
}
|
||||
|
||||
private func linearAccuracy(values: [Double], firstDate: Date) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
let trainPoints = Array(values.prefix(splitIndex)).enumerated().map { (x: Double($0.offset), y: $0.element) }
|
||||
let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints)
|
||||
|
||||
// Calculate R-squared on validation data
|
||||
let validationValues = validationData.map { $0.decimalValue.doubleValue }
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for snapshot in validationData {
|
||||
let x = snapshot.date.timeIntervalSince(firstDate) / 86400
|
||||
let actual = snapshot.decimalValue.doubleValue
|
||||
let predicted = slope * x + intercept
|
||||
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for (i, actual) in validation.enumerated() {
|
||||
let x = Double(splitIndex + i)
|
||||
ssRes += pow(actual - (slope * x + intercept), 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
let rSquared = max(0, 1 - (ssRes / ssTot))
|
||||
|
||||
return min(1.0, rSquared)
|
||||
return min(1.0, max(0, 1 - ssRes / ssTot))
|
||||
}
|
||||
|
||||
// MARK: - Exponential Smoothing
|
||||
|
||||
func predictExponentialSmoothing(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
alpha: Double = 0.3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
|
||||
// Calculate smoothed values
|
||||
private func esPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.3) -> [Prediction] {
|
||||
var smoothed = values[0]
|
||||
for i in 1..<values.count {
|
||||
smoothed = alpha * values[i] + (1 - alpha) * smoothed
|
||||
}
|
||||
for i in 1..<values.count { smoothed = alpha * values[i] + (1 - alpha) * smoothed }
|
||||
|
||||
// Calculate trend
|
||||
var trend: Double = 0
|
||||
var trend = 0.0
|
||||
if values.count >= 2 {
|
||||
let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 -
|
||||
values.prefix(3).reduce(0) { $0 + $1 } / 3.0
|
||||
trend = recentChange / Double(values.count)
|
||||
let window = Double(min(3, values.count))
|
||||
let recentAvg: Double = values.suffix(3).reduce(0, +) / window
|
||||
let earlyAvg: Double = values.prefix(3).reduce(0, +) / window
|
||||
trend = (recentAvg - earlyAvg) / Double(values.count)
|
||||
}
|
||||
|
||||
// Calculate standard deviation for confidence interval
|
||||
let stdDev = calculateStdDev(values: values)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, smoothed + trend * Double(month))
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .exponentialSmoothing,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
return (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let predicted = max(0, smoothed + trend * Double(month))
|
||||
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.05)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .exponentialSmoothing,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateESAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func esAccuracy(values: [Double]) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
|
||||
var smoothed = values[0]
|
||||
for i in 1..<splitIndex {
|
||||
smoothed = 0.3 * values[i] + 0.7 * smoothed
|
||||
}
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for (i, actual) in validationValues.enumerated() {
|
||||
for i in 1..<splitIndex { smoothed = 0.3 * values[i] + 0.7 * smoothed }
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for (i, actual) in validation.enumerated() {
|
||||
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
return max(0, min(1.0, 1 - ssRes / ssTot))
|
||||
}
|
||||
|
||||
// MARK: - Moving Average
|
||||
|
||||
func predictMovingAverage(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
windowSize: Int = 3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= windowSize else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
|
||||
// Calculate moving average of last window
|
||||
let recentValues = Array(values.suffix(windowSize))
|
||||
let movingAverage = recentValues.reduce(0, +) / Double(windowSize)
|
||||
|
||||
// Calculate average monthly change
|
||||
private func maPredictions(values: [Double], lastDate: Date, monthsAhead: Int, windowSize: Int = 3) -> [Prediction] {
|
||||
guard values.count >= windowSize else { return [] }
|
||||
let recent = Array(values.suffix(windowSize))
|
||||
let movingAvg = recent.reduce(0, +) / Double(windowSize)
|
||||
var changes: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
changes.append(values[i] - values[i - 1])
|
||||
}
|
||||
for i in 1..<values.count { changes.append(values[i] - values[i - 1]) }
|
||||
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
|
||||
|
||||
let stdDev = calculateStdDev(values: values)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, movingAverage + avgChange * Double(month))
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .movingAverage,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
return (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let predicted = max(0, movingAvg + avgChange * Double(month))
|
||||
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .movingAverage,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateMAAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
let windowSize = 3
|
||||
private func maAccuracy(values: [Double], windowSize: Int = 3) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
|
||||
guard splitIndex > windowSize else { return 0.5 }
|
||||
|
||||
let recentWindow = Array(values[(splitIndex - windowSize)..<splitIndex])
|
||||
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for actual in validationValues {
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for actual in validation {
|
||||
ssRes += pow(actual - movingAvg, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
return max(0, min(1.0, 1 - ssRes / ssTot))
|
||||
}
|
||||
|
||||
// MARK: - Holt Trend (Double Exponential Smoothing)
|
||||
|
||||
func predictHoltTrend(
|
||||
snapshots: [Snapshot],
|
||||
monthsAhead: Int = 12,
|
||||
alpha: Double = 0.4,
|
||||
beta: Double = 0.3
|
||||
) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func holtPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.4, beta: Double = 0.3) -> [Prediction] {
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
var fitted: [Double] = []
|
||||
for value in values {
|
||||
let lastLevel = level
|
||||
@@ -374,92 +199,86 @@ class PredictionEngine {
|
||||
trend = beta * (level - lastLevel) + (1 - beta) * trend
|
||||
fitted.append(level + trend)
|
||||
}
|
||||
let stdDev = calculateStdDev(values: zip(values, fitted).map { $0 - $1 })
|
||||
|
||||
let residuals = zip(values, fitted).map { $0 - $1 }
|
||||
let stdDev = calculateStdDev(values: residuals)
|
||||
|
||||
var predictions: [Prediction] = []
|
||||
let lastDate = snapshots.last!.date
|
||||
|
||||
for month in 1...monthsAhead {
|
||||
guard let futureDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: month,
|
||||
to: lastDate
|
||||
) else { continue }
|
||||
|
||||
let predictedValue = max(0, level + Double(month) * trend)
|
||||
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
|
||||
|
||||
predictions.append(Prediction(
|
||||
date: futureDate,
|
||||
predictedValue: Decimal(predictedValue),
|
||||
algorithm: .holtTrend,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(
|
||||
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||
upper: Decimal(predictedValue + intervalWidth)
|
||||
)
|
||||
))
|
||||
return (1...monthsAhead).compactMap { month in
|
||||
guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil }
|
||||
let predicted = max(0, level + Double(month) * trend)
|
||||
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.04)
|
||||
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .holtTrend,
|
||||
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double {
|
||||
guard snapshots.count >= 5 else { return 0.5 }
|
||||
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func holtAccuracy(values: [Double]) -> Double {
|
||||
guard values.count >= 5 else { return 0.5 }
|
||||
let splitIndex = Int(Double(values.count) * 0.8)
|
||||
guard splitIndex >= 2 else { return 0.5 }
|
||||
|
||||
var level = values[0]
|
||||
var trend = values[1] - values[0]
|
||||
|
||||
for value in values.prefix(splitIndex) {
|
||||
let lastLevel = level
|
||||
level = 0.4 * value + 0.6 * (level + trend)
|
||||
trend = 0.3 * (level - lastLevel) + 0.7 * trend
|
||||
}
|
||||
|
||||
let validationValues = Array(values.suffix(from: splitIndex))
|
||||
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||
|
||||
var ssRes: Double = 0
|
||||
var ssTot: Double = 0
|
||||
|
||||
for (i, actual) in validationValues.enumerated() {
|
||||
let predicted = level + Double(i + 1) * trend
|
||||
ssRes += pow(actual - predicted, 2)
|
||||
ssTot += pow(actual - meanValidation, 2)
|
||||
let validation = Array(values.suffix(from: splitIndex))
|
||||
let mean = validation.reduce(0, +) / Double(validation.count)
|
||||
var ssRes = 0.0, ssTot = 0.0
|
||||
for (i, actual) in validation.enumerated() {
|
||||
ssRes += pow(actual - (level + Double(i + 1) * trend), 2)
|
||||
ssTot += pow(actual - mean, 2)
|
||||
}
|
||||
|
||||
guard ssTot != 0 else { return 0.5 }
|
||||
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||
return max(0, min(1.0, 1 - ssRes / ssTot))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
||||
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||
private func calculateVolatility(values: [Double]) -> Double {
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
var returns: [Double] = []
|
||||
for i in 1..<values.count {
|
||||
guard values[i - 1] != 0 else { continue }
|
||||
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
|
||||
returns.append(periodReturn)
|
||||
returns.append((values[i] - values[i - 1]) / values[i - 1] * 100)
|
||||
}
|
||||
|
||||
return calculateStdDev(values: returns)
|
||||
}
|
||||
|
||||
private func calculateLinearRegression(dataPoints: [(x: Double, y: Double)]) -> (slope: Double, intercept: Double) {
|
||||
let n = Double(dataPoints.count)
|
||||
let sumX = dataPoints.reduce(0) { $0 + $1.x }
|
||||
let sumY = dataPoints.reduce(0) { $0 + $1.y }
|
||||
let sumXY = dataPoints.reduce(0) { $0 + $1.x * $1.y }
|
||||
let sumX2 = dataPoints.reduce(0) { $0 + $1.x * $1.x }
|
||||
let denom = n * sumX2 - sumX * sumX
|
||||
guard denom != 0 else { return (0, sumY / n) }
|
||||
let slope = (n * sumXY - sumX * sumY) / denom
|
||||
return (slope, (sumY - slope * sumX) / n)
|
||||
}
|
||||
|
||||
private func calculateResidualStdDev(dataPoints: [(x: Double, y: Double)], slope: Double, intercept: Double) -> Double {
|
||||
guard dataPoints.count > 2 else { return 0 }
|
||||
let mse = dataPoints.map { pow($0.y - (slope * $0.x + intercept), 2) }.reduce(0, +) / Double(dataPoints.count - 2)
|
||||
return sqrt(mse)
|
||||
}
|
||||
|
||||
private func calculateStdDev(values: [Double]) -> Double {
|
||||
guard values.count >= 2 else { return 0 }
|
||||
|
||||
let mean = values.reduce(0, +) / Double(values.count)
|
||||
let squaredDifferences = values.map { pow($0 - mean, 2) }
|
||||
let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1)
|
||||
|
||||
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count - 1)
|
||||
return sqrt(variance)
|
||||
}
|
||||
|
||||
// MARK: - Public compatibility (kept for external callers)
|
||||
|
||||
func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] {
|
||||
guard snapshots.count >= 3 else { return [] }
|
||||
let sorted = snapshots.sorted { $0.date < $1.date }
|
||||
return linearPredictions(
|
||||
values: sorted.map { $0.decimalValue.doubleValue },
|
||||
firstDate: sorted.first!.date,
|
||||
lastDate: sorted.last!.date,
|
||||
monthsAhead: monthsAhead
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import StoreKit
|
||||
import UIKit
|
||||
|
||||
final class ReviewPromptService {
|
||||
static let shared = ReviewPromptService()
|
||||
|
||||
private let lastPromptKey = "reviewPromptLastDate"
|
||||
private let checkInCountKey = "reviewPromptCheckinCount"
|
||||
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
|
||||
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
|
||||
// Rating velocity: with a monthly-cadence app, 3 check-ins + 90 days meant 3+ months
|
||||
// before the first review could even be requested. 2 check-ins + 30 days keeps the
|
||||
// prompt tied to a good moment (a completed check-in) while building ratings sooner.
|
||||
private let minCheckInsBetweenPrompts = 2
|
||||
private let minDaysBetweenPrompts = 30
|
||||
private let userDefaults: UserDefaults
|
||||
private let dateProvider: () -> Date
|
||||
private let reviewRequestHandler: () -> Void
|
||||
|
||||
private init() {
|
||||
userDefaults = .standard
|
||||
dateProvider = Date.init
|
||||
reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler
|
||||
}
|
||||
|
||||
init(
|
||||
userDefaults: UserDefaults,
|
||||
dateProvider: @escaping () -> Date,
|
||||
reviewRequestHandler: @escaping () -> Void
|
||||
) {
|
||||
self.userDefaults = userDefaults
|
||||
self.dateProvider = dateProvider
|
||||
self.reviewRequestHandler = reviewRequestHandler
|
||||
}
|
||||
|
||||
func recordMonthlyCheckInCompleted() {
|
||||
let currentCount = userDefaults.integer(forKey: checkInCountKey)
|
||||
userDefaults.set(currentCount + 1, forKey: checkInCountKey)
|
||||
requestReviewIfEligible()
|
||||
}
|
||||
|
||||
func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set<String>) -> Bool {
|
||||
guard !newlyUnlockedAchievementKeys.isEmpty else { return false }
|
||||
guard !hasCompletedStoreReview else { return false }
|
||||
|
||||
let promptedKeys = Set(userDefaults.stringArray(forKey: promptedAchievementKeysKey) ?? [])
|
||||
let unpromptedKeys = newlyUnlockedAchievementKeys.subtracting(promptedKeys)
|
||||
guard !unpromptedKeys.isEmpty else { return false }
|
||||
|
||||
let mergedKeys = promptedKeys.union(unpromptedKeys).sorted()
|
||||
userDefaults.set(mergedKeys, forKey: promptedAchievementKeysKey)
|
||||
return true
|
||||
}
|
||||
|
||||
var hasCompletedStoreReview: Bool {
|
||||
userDefaults.bool(forKey: hasCompletedStoreReviewKey)
|
||||
}
|
||||
|
||||
func markStoreReviewCompleted() {
|
||||
userDefaults.set(true, forKey: hasCompletedStoreReviewKey)
|
||||
}
|
||||
|
||||
static func appStoreWriteReviewURL() -> URL {
|
||||
guard var components = URLComponents(url: GoalShareService.appStoreURL, resolvingAgainstBaseURL: false) else {
|
||||
return GoalShareService.appStoreURL
|
||||
}
|
||||
var queryItems = components.queryItems ?? []
|
||||
queryItems.removeAll { $0.name == "action" }
|
||||
queryItems.append(URLQueryItem(name: "action", value: "write-review"))
|
||||
components.queryItems = queryItems
|
||||
return components.url ?? GoalShareService.appStoreURL
|
||||
}
|
||||
|
||||
private func requestReviewIfEligible() {
|
||||
guard !hasCompletedStoreReview else { return }
|
||||
let now = dateProvider()
|
||||
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
|
||||
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
|
||||
if daysSince < Double(minDaysBetweenPrompts) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let count = userDefaults.integer(forKey: checkInCountKey)
|
||||
guard count >= minCheckInsBetweenPrompts else { return }
|
||||
|
||||
requestReview()
|
||||
}
|
||||
|
||||
private func requestReview() {
|
||||
reviewRequestHandler()
|
||||
userDefaults.set(dateProvider(), forKey: lastPromptKey)
|
||||
userDefaults.set(0, forKey: checkInCountKey)
|
||||
}
|
||||
|
||||
private static func defaultReviewRequestHandler() {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||
if #available(iOS 18.0, *) {
|
||||
AppStore.requestReview(in: scene)
|
||||
} else {
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ class SampleDataService {
|
||||
|
||||
let snapshotRepository = SnapshotRepository(context: context)
|
||||
let goalRepository = GoalRepository(context: context)
|
||||
let transactionRepository = TransactionRepository(context: context)
|
||||
|
||||
let categories = fetchCategories(in: context)
|
||||
guard let fallbackCategory = categories.first else { return }
|
||||
@@ -70,16 +69,6 @@ class SampleDataService {
|
||||
|
||||
seedMonthlyNotes()
|
||||
|
||||
transactionRepository.createTransaction(
|
||||
source: stocks,
|
||||
type: .buy,
|
||||
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
|
||||
shares: 10,
|
||||
price: 400,
|
||||
amount: nil,
|
||||
notes: "Sample buy"
|
||||
)
|
||||
|
||||
_ = goalRepository.createGoal(
|
||||
name: "1M Goal",
|
||||
targetAmount: 1_000_000,
|
||||
|
||||
@@ -1,11 +1,278 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import LinkPresentation
|
||||
|
||||
class ShareService {
|
||||
static let shared = ShareService()
|
||||
|
||||
/// Attributed App Store link for shared cards (ct= shows up in App
|
||||
/// Analytics as a campaign, so referral installs become measurable).
|
||||
static let snapshotShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=snapshot_share")!
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Extracts the "(±X.XX%)" part of a combined "±1.234 € (±X.XX%)" string.
|
||||
static func percentPart(_ s: String?) -> String? {
|
||||
guard let s else { return nil }
|
||||
guard let open = s.lastIndex(of: "("), let close = s.lastIndex(of: ")"), open < close else {
|
||||
return s
|
||||
}
|
||||
return String(s[s.index(after: open)..<close])
|
||||
}
|
||||
|
||||
struct PortfolioCardStrings {
|
||||
/// The big number (an all-time/return %) — the hero of the card.
|
||||
let heroPercent: String?
|
||||
/// Supporting absolute value (empty in percent-only mode).
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
let yearChange: String?
|
||||
let sinceInceptionChange: String?
|
||||
}
|
||||
|
||||
/// The all-time return is always the hero (branding + shareable). With
|
||||
/// `percentOnly` every absolute amount is dropped — nobody posts their net
|
||||
/// worth in euros; everybody posts +42%.
|
||||
static func portfolioCardStrings(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?,
|
||||
percentOnly: Bool
|
||||
) -> PortfolioCardStrings {
|
||||
let hero = percentPart(sinceInceptionChange) ?? percentPart(changeText)
|
||||
if percentOnly {
|
||||
return PortfolioCardStrings(
|
||||
heroPercent: hero ?? totalValue,
|
||||
totalValue: "",
|
||||
changeText: percentPart(changeText) ?? changeText,
|
||||
yearChange: percentPart(yearChange),
|
||||
sinceInceptionChange: percentPart(sinceInceptionChange)
|
||||
)
|
||||
}
|
||||
return PortfolioCardStrings(
|
||||
heroPercent: hero,
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange
|
||||
)
|
||||
}
|
||||
|
||||
static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String {
|
||||
"""
|
||||
\(summary.periodLabel) Check-in
|
||||
Starting: \(summary.formattedStartingValue)
|
||||
Ending: \(summary.formattedEndingValue)
|
||||
Contributions: \(summary.formattedContributions)
|
||||
Net performance: \(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))
|
||||
|
||||
Shared from \(appName)
|
||||
"""
|
||||
}
|
||||
|
||||
static func buildPortfolioValueShareText(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
changeLabel: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?,
|
||||
appName: String
|
||||
) -> String {
|
||||
var lines = [
|
||||
"Total Portfolio Value",
|
||||
totalValue,
|
||||
"\(changeText) \(changeLabel)"
|
||||
]
|
||||
|
||||
if let yearChange {
|
||||
lines.append("YoY: \(yearChange)")
|
||||
}
|
||||
if let sinceInceptionChange {
|
||||
lines.append("Since inception: \(sinceInceptionChange)")
|
||||
}
|
||||
|
||||
lines.append("")
|
||||
lines.append("Shared from \(appName)")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
|
||||
FirebaseService.shared.logShare(type: "monthly_checkin")
|
||||
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
|
||||
shareCard(
|
||||
cardTitle: "\(summary.periodLabel) Check-in",
|
||||
fallbackText: text
|
||||
) {
|
||||
MonthlyCheckInShareCardView(
|
||||
summary: summary,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlySummaryShareData {
|
||||
let monthLabel: String
|
||||
let totalValue: String
|
||||
let changeAmount: String
|
||||
let changePercentage: String
|
||||
let isPositive: Bool
|
||||
let streak: Int
|
||||
let mood: MonthlyCheckInMood?
|
||||
let rating: Int?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
|
||||
FirebaseService.shared.logShare(type: "monthly_summary")
|
||||
let appName = Self.appDisplayName
|
||||
|
||||
var lines = [
|
||||
"\(data.monthLabel) — Monthly Summary",
|
||||
"Portfolio Value: \(data.totalValue)",
|
||||
"\(data.changeAmount) (\(data.changePercentage)) since last check-in"
|
||||
]
|
||||
if data.streak >= 2 {
|
||||
lines.append("Streak: \(data.streak) months")
|
||||
}
|
||||
if let mood = data.mood {
|
||||
lines.append("Mood: \(mood.title)")
|
||||
}
|
||||
lines.append("")
|
||||
lines.append("Tracked with \(appName)")
|
||||
let fallbackText = lines.joined(separator: "\n")
|
||||
|
||||
shareCard(
|
||||
cardTitle: "\(data.monthLabel) — Monthly Summary",
|
||||
fallbackText: fallbackText
|
||||
) {
|
||||
MonthlySummaryShareView(
|
||||
monthLabel: data.monthLabel,
|
||||
totalValue: data.totalValue,
|
||||
changeAmount: data.changeAmount,
|
||||
changePercentage: data.changePercentage,
|
||||
isPositive: data.isPositive,
|
||||
streak: data.streak,
|
||||
mood: data.mood,
|
||||
rating: data.rating,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sharePortfolioValue(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
changeLabel: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?,
|
||||
sparkline: [Double] = [],
|
||||
isPositive: Bool = true,
|
||||
asOf: String? = nil,
|
||||
theme: ShareCardTheme = .dark,
|
||||
percentOnly: Bool = false,
|
||||
storyFormat: Bool = false
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
|
||||
let appName = Self.appDisplayName
|
||||
let display = Self.portfolioCardStrings(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
percentOnly: percentOnly
|
||||
)
|
||||
// Fallback text honors the privacy choice too.
|
||||
let text = Self.buildPortfolioValueShareText(
|
||||
totalValue: display.totalValue,
|
||||
changeText: display.changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: display.yearChange,
|
||||
sinceInceptionChange: display.sinceInceptionChange,
|
||||
appName: appName
|
||||
)
|
||||
|
||||
shareCard(
|
||||
cardTitle: "Portfolio Snapshot",
|
||||
fallbackText: text,
|
||||
shareURL: Self.snapshotShareURL
|
||||
) {
|
||||
PortfolioValueShareCardView(
|
||||
totalValue: display.totalValue,
|
||||
changeText: display.changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: display.yearChange,
|
||||
sinceInceptionChange: display.sinceInceptionChange,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200),
|
||||
sparkline: sparkline,
|
||||
isPositive: isPositive,
|
||||
heroPercent: display.heroPercent,
|
||||
asOf: asOf,
|
||||
theme: theme,
|
||||
storyFormat: storyFormat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func shareText(_ content: String) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [content],
|
||||
applicationActivities: nil
|
||||
)
|
||||
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func shareCard<Content: View>(
|
||||
cardTitle: String,
|
||||
fallbackText: String,
|
||||
shareURL: URL = GoalShareService.appStoreURL,
|
||||
@ViewBuilder card: () -> Content
|
||||
) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
let renderer = ImageRenderer(content: card())
|
||||
let scale = viewController.view.window?.windowScene?.screen.scale
|
||||
?? viewController.traitCollection.displayScale
|
||||
renderer.scale = scale
|
||||
|
||||
if let image = renderer.uiImage {
|
||||
let item = CardShareItem(
|
||||
image: image,
|
||||
title: cardTitle,
|
||||
text: fallbackText,
|
||||
url: shareURL
|
||||
)
|
||||
presentShareSheet(items: [item], from: viewController)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
presentShareSheet(items: [fallbackText], from: viewController)
|
||||
}
|
||||
|
||||
func shareTextFile(content: String, fileName: String) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
@@ -109,4 +376,67 @@ class ShareService {
|
||||
.replacingOccurrences(of: ";", with: "\\;")
|
||||
.replacingOccurrences(of: ",", with: "\\,")
|
||||
}
|
||||
|
||||
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
|
||||
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
popover.permittedArrowDirections = []
|
||||
}
|
||||
viewController.present(activityVC, animated: true)
|
||||
}
|
||||
|
||||
private static var appDisplayName: String {
|
||||
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
|
||||
return name
|
||||
}
|
||||
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
|
||||
return name
|
||||
}
|
||||
return "Portfolio Journal"
|
||||
}
|
||||
}
|
||||
|
||||
private class CardShareItem: NSObject, UIActivityItemSource {
|
||||
let image: UIImage
|
||||
let title: String
|
||||
let text: String
|
||||
let url: URL
|
||||
|
||||
init(image: UIImage, title: String, text: String, url: URL) {
|
||||
self.image = image
|
||||
self.title = title
|
||||
self.text = text
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||
title
|
||||
}
|
||||
|
||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||
let metadata = LPLinkMetadata()
|
||||
metadata.title = title
|
||||
metadata.originalURL = url
|
||||
metadata.url = url
|
||||
metadata.imageProvider = NSItemProvider(object: image)
|
||||
if let appIcon = UIImage(named: "BrandMark") {
|
||||
metadata.iconProvider = NSItemProvider(object: appIcon)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,65 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Per-category allocation target (percentage).
|
||||
///
|
||||
/// Backed by the `Category.allocationTarget` Core Data attribute so targets sync across
|
||||
/// devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is device-local
|
||||
/// and did NOT sync — `migrateIfNeeded(context:)` moves any legacy values over.
|
||||
enum AllocationTargetStore {
|
||||
private static let targetsKey = "allocationTargets"
|
||||
/// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
|
||||
private static let legacyKey = "allocationTargets"
|
||||
|
||||
private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
|
||||
|
||||
static func target(for categoryId: UUID) -> Double? {
|
||||
loadTargets()[categoryId.uuidString]
|
||||
guard let category = fetchCategory(categoryId),
|
||||
let target = category.allocationTarget?.doubleValue,
|
||||
target > 0 else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
static func setTarget(_ value: Double?, for categoryId: UUID) {
|
||||
var targets = loadTargets()
|
||||
let key = categoryId.uuidString
|
||||
guard let category = fetchCategory(categoryId) else { return }
|
||||
if let value, value > 0 {
|
||||
targets[key] = value
|
||||
category.allocationTarget = NSNumber(value: value)
|
||||
} else {
|
||||
targets.removeValue(forKey: key)
|
||||
category.allocationTarget = nil
|
||||
}
|
||||
saveTargets(targets)
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
|
||||
let targets = loadTargets()
|
||||
return categoryIds.reduce(0) { total, id in
|
||||
total + (targets[id.uuidString] ?? 0)
|
||||
categoryIds.reduce(0) { total, id in
|
||||
total + (target(for: id) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadTargets() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: targetsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
private static func fetchCategory(_ id: UUID) -> Category? {
|
||||
let request = Category.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
private static func saveTargets(_ targets: [String: Double]) {
|
||||
if let data = try? JSONEncoder().encode(targets) {
|
||||
UserDefaults.standard.set(data, forKey: targetsKey)
|
||||
// MARK: - Migration
|
||||
|
||||
/// One-time migration of legacy UserDefaults targets into Core Data so they sync via
|
||||
/// iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
|
||||
static func migrateIfNeeded(context: NSManagedObjectContext) {
|
||||
guard let data = UserDefaults.standard.data(forKey: legacyKey),
|
||||
let dict = try? JSONDecoder().decode([String: Double].self, from: data),
|
||||
!dict.isEmpty else { return }
|
||||
|
||||
let categories = (try? context.fetch(Category.fetchRequest())) ?? []
|
||||
var changed = false
|
||||
for category in categories where category.allocationTarget == nil {
|
||||
if let value = dict[category.id.uuidString], value > 0 {
|
||||
category.allocationTarget = NSNumber(value: value)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed { try? context.save() }
|
||||
UserDefaults.standard.removeObject(forKey: legacyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Central store for the "Hide balances" privacy feature.
|
||||
///
|
||||
/// When `balancesHidden` is true, every monetary amount in the app renders
|
||||
/// redacted (a blurred bullet mask) instead of the real value. This lets the
|
||||
/// user open the app in public or take screenshots without exposing net worth.
|
||||
///
|
||||
/// If `requireBiometricToReveal` is enabled, un-hiding balances must first pass
|
||||
/// Face ID / biometric auth (reusing `AppLockService`). On failure, balances
|
||||
/// stay hidden.
|
||||
///
|
||||
/// State is persisted in `UserDefaults` so it survives relaunches, and mirrored
|
||||
/// into `@Published` properties so SwiftUI views observing this object update
|
||||
/// instantly when the user taps the quick toggle.
|
||||
@MainActor
|
||||
final class BalancePrivacyManager: ObservableObject {
|
||||
static let shared = BalancePrivacyManager()
|
||||
|
||||
private enum Keys {
|
||||
static let balancesHidden = "balancesHidden"
|
||||
static let requireBiometricToReveal = "requireBiometricToReveal"
|
||||
}
|
||||
|
||||
/// When true, all monetary amounts render redacted.
|
||||
@Published var balancesHidden: Bool {
|
||||
didSet { UserDefaults.standard.set(balancesHidden, forKey: Keys.balancesHidden) }
|
||||
}
|
||||
|
||||
/// When true, revealing hidden balances requires biometric auth.
|
||||
@Published var requireBiometricToReveal: Bool {
|
||||
didSet { UserDefaults.standard.set(requireBiometricToReveal, forKey: Keys.requireBiometricToReveal) }
|
||||
}
|
||||
|
||||
init() {
|
||||
self.balancesHidden = UserDefaults.standard.bool(forKey: Keys.balancesHidden)
|
||||
self.requireBiometricToReveal = UserDefaults.standard.bool(forKey: Keys.requireBiometricToReveal)
|
||||
}
|
||||
|
||||
/// Toggle balance visibility. Hiding is always immediate. Revealing, when
|
||||
/// `requireBiometricToReveal` is on and biometrics are available, runs a
|
||||
/// Face ID prompt first and only reveals on success.
|
||||
func toggleHidden() {
|
||||
if balancesHidden {
|
||||
reveal()
|
||||
} else {
|
||||
balancesHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to reveal balances, honoring the biometric requirement.
|
||||
func reveal() {
|
||||
guard balancesHidden else { return }
|
||||
if requireBiometricToReveal && AppLockService.canUseBiometrics() {
|
||||
AppLockService.authenticate(reason: "Reveal your balances") { [weak self] success in
|
||||
if success {
|
||||
self?.balancesHidden = false
|
||||
}
|
||||
// On failure, stay hidden — do nothing.
|
||||
}
|
||||
} else {
|
||||
balancesHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Fills gaps between snapshots for chart display only — it never creates or
|
||||
/// mutates Core Data objects. A source that has a snapshot in February and the
|
||||
/// next in June leaves March/April/May empty; without filling, per-source and
|
||||
/// category lines dip or break. This computes a value for every month in the
|
||||
/// grid so the graphs read continuously.
|
||||
///
|
||||
/// - Internal gaps (between two known snapshots) are linearly interpolated when
|
||||
/// `interpolate` is true, or carried forward (stepped) when false.
|
||||
/// - After a source's last snapshot the value is carried forward (there is no
|
||||
/// future point to interpolate towards) — matches the portfolio total.
|
||||
/// - Before a source's first snapshot the value is `nil` (the source did not
|
||||
/// exist yet, so it must not contribute 0 and drag the total down).
|
||||
enum ChartGapFill {
|
||||
|
||||
/// The user-facing toggle (Settings). Defaults to ON so charts look right out
|
||||
/// of the box; reading via `object(forKey:)` so an unset key means enabled.
|
||||
static let defaultsKey = "smoothChartGaps"
|
||||
|
||||
static var isEnabled: Bool {
|
||||
(UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true
|
||||
}
|
||||
|
||||
/// For a single source: `known` maps a month index (position in the ordered
|
||||
/// month grid) to that month's value. Returns a value per month index, with
|
||||
/// internal gaps filled and a trailing carry-forward. `nil` means "absent".
|
||||
static func denseValues(
|
||||
known: [Int: Decimal],
|
||||
monthCount: Int,
|
||||
interpolate: Bool
|
||||
) -> [Decimal?] {
|
||||
var result = [Decimal?](repeating: nil, count: monthCount)
|
||||
let indices = known.keys.sorted()
|
||||
guard let first = indices.first, let last = indices.last else { return result }
|
||||
|
||||
for i in indices { result[i] = known[i] }
|
||||
|
||||
if interpolate {
|
||||
// Linear interpolation across each internal gap.
|
||||
for k in 0..<(indices.count - 1) {
|
||||
let a = indices[k]
|
||||
let b = indices[k + 1]
|
||||
guard b - a > 1, let va = known[a], let vb = known[b] else { continue }
|
||||
for i in (a + 1)..<b {
|
||||
let frac = Decimal(i - a) / Decimal(b - a)
|
||||
result[i] = va + (vb - va) * frac
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Stepped carry-forward within internal gaps.
|
||||
var lastVal = known[first] ?? .zero
|
||||
for i in first...last {
|
||||
if let v = result[i] { lastVal = v } else { result[i] = lastVal }
|
||||
}
|
||||
}
|
||||
|
||||
// Carry the last known value forward past the final snapshot.
|
||||
if let lastVal = known[last], last + 1 < monthCount {
|
||||
for i in (last + 1)..<monthCount { result[i] = lastVal }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,16 @@ enum CurrencyFormatter {
|
||||
return AppSettings.getOrCreate(in: context).currency
|
||||
}
|
||||
|
||||
static func locale(for currencyCode: String?) -> Locale {
|
||||
guard let currencyCode, !currencyCode.isEmpty else { return Locale.current }
|
||||
if let match = Locale.availableIdentifiers.first(where: {
|
||||
Locale(identifier: $0).currency?.identifier == currencyCode
|
||||
}) {
|
||||
return Locale(identifier: match)
|
||||
}
|
||||
return Locale.current
|
||||
}
|
||||
|
||||
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = style
|
||||
@@ -14,10 +24,116 @@ enum CurrencyFormatter {
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
||||
}
|
||||
|
||||
static func format(
|
||||
_ decimal: Decimal,
|
||||
currencyCode: String?,
|
||||
style: NumberFormatter.Style = .currency,
|
||||
maximumFractionDigits: Int = 2,
|
||||
preferredLocale: Locale? = nil
|
||||
) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = style
|
||||
formatter.currencyCode = currencyCode ?? currentCurrencyCode()
|
||||
formatter.maximumFractionDigits = maximumFractionDigits
|
||||
formatter.locale = preferredLocale ?? locale(for: currencyCode)
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
|
||||
}
|
||||
|
||||
static func symbol(for code: String) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = code
|
||||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
|
||||
/// Parses a user-typed numeric string accepting both `.` and `,` as decimal/grouping separators.
|
||||
/// The parser is intentionally permissive to avoid turning decimal input into huge integers.
|
||||
static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? {
|
||||
let stripped = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" }
|
||||
|
||||
guard !stripped.isEmpty else { return nil }
|
||||
|
||||
let isNegative = stripped.hasPrefix("-")
|
||||
let unsigned = stripped.replacingOccurrences(of: "-", with: "")
|
||||
guard !unsigned.isEmpty else { return nil }
|
||||
|
||||
let normalizedUnsigned = normalizeNumericInput(unsigned)
|
||||
let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
private static func normalizeNumericInput(_ input: String) -> String {
|
||||
let hasDot = input.contains(".")
|
||||
let hasComma = input.contains(",")
|
||||
|
||||
if hasDot && hasComma {
|
||||
// Both separators present: the last one is considered decimal.
|
||||
let lastDot = input.lastIndex(of: ".")!
|
||||
let lastComma = input.lastIndex(of: ",")!
|
||||
if lastComma > lastDot {
|
||||
return input
|
||||
.replacingOccurrences(of: ".", with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
}
|
||||
return input.replacingOccurrences(of: ",", with: "")
|
||||
}
|
||||
|
||||
if hasDot {
|
||||
return normalizeSingleSeparator(input, separator: ".")
|
||||
}
|
||||
|
||||
if hasComma {
|
||||
return normalizeSingleSeparator(input, separator: ",")
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
private static func normalizeSingleSeparator(_ input: String, separator: Character) -> String {
|
||||
let parts = input.split(separator: separator, omittingEmptySubsequences: false)
|
||||
guard parts.count > 1 else { return input }
|
||||
|
||||
if parts.count == 2 {
|
||||
// A single separator is treated as decimal (e.g. 533.595).
|
||||
return "\(parts[0]).\(parts[1])"
|
||||
}
|
||||
|
||||
let integerParts = Array(parts.dropLast())
|
||||
let fractionPart = String(parts.last ?? "")
|
||||
|
||||
// If all groups follow a strict thousands pattern and the last one has 3 digits,
|
||||
// prefer grouping-only interpretation (e.g. 1.234.567).
|
||||
if looksLikeGroupedThousands(integerParts), fractionPart.count == 3 {
|
||||
return input.replacingOccurrences(of: String(separator), with: "")
|
||||
}
|
||||
|
||||
// Otherwise, treat the last separator as decimal and previous ones as grouping.
|
||||
let integer = integerParts.joined()
|
||||
return "\(integer).\(fractionPart)"
|
||||
}
|
||||
|
||||
private static func looksLikeGroupedThousands(_ groups: [Substring]) -> Bool {
|
||||
guard let first = groups.first, !first.isEmpty, first.count <= 3 else { return false }
|
||||
guard groups.count >= 2 else { return false }
|
||||
return groups.dropFirst().allSatisfy { $0.count == 3 }
|
||||
}
|
||||
|
||||
/// Formats a decimal for display in an input field (no grouping separator).
|
||||
static func formatForInput(_ decimal: Decimal, currencyCode: String) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = locale(for: currencyCode)
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,22 @@ struct DashboardSectionConfig: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
var isVisible: Bool
|
||||
var isCollapsed: Bool
|
||||
var columnSpan: Int
|
||||
|
||||
init(id: String, isVisible: Bool, isCollapsed: Bool, columnSpan: Int = 1) {
|
||||
self.id = id
|
||||
self.isVisible = isVisible
|
||||
self.isCollapsed = isCollapsed
|
||||
self.columnSpan = columnSpan
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
isVisible = try container.decode(Bool.self, forKey: .isVisible)
|
||||
isCollapsed = try container.decode(Bool.self, forKey: .isCollapsed)
|
||||
columnSpan = try container.decodeIfPresent(Int.self, forKey: .columnSpan) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
enum DashboardSection: String, CaseIterable, Identifiable {
|
||||
@@ -16,6 +32,7 @@ enum DashboardSection: String, CaseIterable, Identifiable {
|
||||
case goals
|
||||
case pendingUpdates
|
||||
case periodReturns
|
||||
case contributionsVsReturns
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -39,6 +56,8 @@ enum DashboardSection: String, CaseIterable, Identifiable {
|
||||
return "Pending Updates"
|
||||
case .periodReturns:
|
||||
return "Returns"
|
||||
case .contributionsVsReturns:
|
||||
return "Invested vs. Returns"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,12 +103,23 @@ enum DashboardLayoutStore {
|
||||
UserDefaults.standard.removeObject(forKey: storageKey)
|
||||
}
|
||||
|
||||
/// Calm 2.0 defaults: three zones — state (total value), action (check-in),
|
||||
/// context. Redundant cards start hidden: pending updates and streaks now
|
||||
/// live inside the check-in card, period returns inside the hero. All remain
|
||||
/// available via Customize.
|
||||
private static let hiddenByDefault: Set<DashboardSection> = [
|
||||
.momentumStreaks, .pendingUpdates, .periodReturns, .monthlySummary
|
||||
]
|
||||
|
||||
private static func defaultConfigs() -> [DashboardSectionConfig] {
|
||||
DashboardSection.allCases.map { section in
|
||||
DashboardSectionConfig(
|
||||
id: section.id,
|
||||
isVisible: true,
|
||||
isCollapsed: false
|
||||
isVisible: !hiddenByDefault.contains(section),
|
||||
isCollapsed: false,
|
||||
// The check-in row is slim — full width on the iPad grid so it
|
||||
// reads as the "action" zone instead of leaving a gap cell.
|
||||
columnSpan: section == .monthlyCheckIn ? 2 : 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,28 @@ extension Color {
|
||||
return Color(hex: hex) ?? .blue
|
||||
}
|
||||
|
||||
// MARK: - Source Colors (distinct per-source palette, different order from categories)
|
||||
|
||||
static let sourceColors: [String] = [
|
||||
"#6366F1", // Indigo
|
||||
"#F97316", // Orange
|
||||
"#06B6D4", // Cyan
|
||||
"#EF4444", // Red
|
||||
"#84CC16", // Lime
|
||||
"#EC4899", // Pink
|
||||
"#14B8A6", // Teal
|
||||
"#F59E0B", // Amber
|
||||
"#8B5CF6", // Purple
|
||||
"#3B82F6", // Blue
|
||||
"#A855F7", // Violet
|
||||
"#10B981", // Green
|
||||
]
|
||||
|
||||
static func sourceColor(at index: Int) -> Color {
|
||||
let hex = sourceColors[index % sourceColors.count]
|
||||
return Color(hex: hex) ?? .blue
|
||||
}
|
||||
|
||||
// MARK: - Chart Colors
|
||||
|
||||
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
|
||||
|
||||
@@ -136,6 +136,18 @@ extension Date {
|
||||
return formatter.localizedString(for: self, relativeTo: Date())
|
||||
}
|
||||
|
||||
var relativeDayDescription: String {
|
||||
let calendar = Calendar.current
|
||||
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: self), to: calendar.startOfDay(for: Date())).day ?? 0
|
||||
if days == 0 { return String(localized: "date_today") }
|
||||
if days == 1 { return "1d ago" }
|
||||
if days < 31 { return "\(days)d ago" }
|
||||
let months = calendar.dateComponents([.month], from: self, to: Date()).month ?? 0
|
||||
if months < 12 { return "\(max(1, months))mo ago" }
|
||||
let years = calendar.dateComponents([.year], from: self, to: Date()).year ?? 0
|
||||
return "\(max(1, years))y ago"
|
||||
}
|
||||
|
||||
var friendlyDescription: String {
|
||||
if isToday {
|
||||
return String(localized: "date_today")
|
||||
|
||||
@@ -54,6 +54,27 @@ class FreemiumValidator: ObservableObject {
|
||||
return snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
/// Number of distinct months of data that exist BEYOND the free history window.
|
||||
/// Used to make the locked value concrete ("8 more months with Premium") instead
|
||||
/// of silently truncating — users can't want what they can't see.
|
||||
func hiddenHistoryMonths(in snapshots: [Snapshot]) -> Int {
|
||||
if iapService.isPremium { return 0 }
|
||||
|
||||
let cutoffDate = Calendar.current.date(
|
||||
byAdding: .month,
|
||||
value: -FreemiumLimits.maxHistoricalMonths,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
let calendar = Calendar.current
|
||||
let hiddenMonths = Set(
|
||||
snapshots
|
||||
.filter { $0.date < cutoffDate }
|
||||
.map { calendar.dateComponents([.year, .month], from: $0.date) }
|
||||
)
|
||||
return hiddenMonths.count
|
||||
}
|
||||
|
||||
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
|
||||
if iapService.isPremium { return true }
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure, dependency-free projection helper for goals.
|
||||
///
|
||||
/// Given the current portfolio value relevant to a goal (its account's sources,
|
||||
/// or the whole portfolio when the goal has no account), a monthly growth rate
|
||||
/// derived from evolution history, and the total monthly contributions of the
|
||||
/// relevant sources, it projects value forward month by month and estimates the
|
||||
/// month the goal amount is reached — plus an on-track status vs the target date.
|
||||
///
|
||||
/// Kept free of Core Data / SwiftUI so it is trivially unit-testable and reusable.
|
||||
enum GoalProjection {
|
||||
|
||||
/// On-track status of a goal relative to its target date.
|
||||
enum Status: Equatable {
|
||||
/// Reached before the target date.
|
||||
case aheadOfSchedule
|
||||
/// Reached at (or within a small window of) the target date.
|
||||
case onTrack
|
||||
/// Reached after the target date.
|
||||
case behind
|
||||
/// Never reached at the current trajectory (no growth, no contributions).
|
||||
case unreachable
|
||||
/// No target date set — only a projected date is meaningful.
|
||||
case noTargetDate
|
||||
}
|
||||
|
||||
struct Result {
|
||||
/// Projected month the goal amount is reached, or nil if unreachable.
|
||||
let projectedDate: Date?
|
||||
/// Number of months from `asOf` until the goal is reached, or nil if unreachable.
|
||||
let monthsToReach: Int?
|
||||
/// On-track status vs the target date.
|
||||
let status: Status
|
||||
/// Monthly growth rate used (fraction, e.g. 0.008 == +0.8%/mo). Zero if flat/negative.
|
||||
let monthlyGrowthRate: Double
|
||||
/// A short projection curve (value per month) for a sparkline, starting at `currentValue`.
|
||||
/// Empty when the goal is already reached or unreachable.
|
||||
let sparkline: [Decimal]
|
||||
|
||||
var isReachable: Bool { projectedDate != nil }
|
||||
}
|
||||
|
||||
/// Hard cap on how many months we project before declaring a goal unreachable
|
||||
/// at the current pace (50 years).
|
||||
private static let maxProjectionMonths = 600
|
||||
|
||||
/// Derives a robust monthly growth rate (as a fraction) from evolution history.
|
||||
///
|
||||
/// Uses the trailing window (default 6 points) and computes a monthly CAGR from
|
||||
/// first to last value. Contributions inflate raw value growth, so this rate is a
|
||||
/// blended "how fast the pot grows" figure rather than pure market return — which
|
||||
/// is the honest thing to extrapolate for an ETA. Returns 0 for flat/negative
|
||||
/// trends or insufficient data.
|
||||
static func monthlyGrowthRate(
|
||||
from evolution: [(date: Date, value: Decimal)],
|
||||
window: Int = 6
|
||||
) -> Double {
|
||||
guard evolution.count >= 3 else { return 0 }
|
||||
let recent = Array(evolution.suffix(max(2, window)))
|
||||
guard let first = recent.first, let last = recent.last else { return 0 }
|
||||
|
||||
let firstValue = NSDecimalNumber(decimal: first.value).doubleValue
|
||||
let lastValue = NSDecimalNumber(decimal: last.value).doubleValue
|
||||
guard firstValue > 0, lastValue > firstValue else { return 0 }
|
||||
|
||||
let months = max(1, first.date.monthsBetween(last.date))
|
||||
// Monthly CAGR: (last/first)^(1/months) - 1
|
||||
let ratio = lastValue / firstValue
|
||||
let rate = pow(ratio, 1.0 / Double(months)) - 1.0
|
||||
guard rate.isFinite, rate > 0 else { return 0 }
|
||||
// Clamp to a sane band to avoid absurd extrapolations from noisy data.
|
||||
return min(rate, 0.5)
|
||||
}
|
||||
|
||||
/// Projects when `targetAmount` is reached.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - currentValue: current portfolio value relevant to the goal.
|
||||
/// - targetAmount: the goal amount.
|
||||
/// - monthlyGrowthRate: fraction per month (from `monthlyGrowthRate(from:)`).
|
||||
/// - monthlyContribution: total monthly contributions of relevant sources.
|
||||
/// - targetDate: optional deadline to compare against.
|
||||
/// - asOf: base date to project from (defaults to now).
|
||||
static func project(
|
||||
currentValue: Decimal,
|
||||
targetAmount: Decimal,
|
||||
monthlyGrowthRate: Double,
|
||||
monthlyContribution: Decimal,
|
||||
targetDate: Date?,
|
||||
asOf: Date = Date()
|
||||
) -> Result {
|
||||
// Already reached.
|
||||
if currentValue >= targetAmount, targetAmount > 0 {
|
||||
return Result(
|
||||
projectedDate: asOf,
|
||||
monthsToReach: 0,
|
||||
status: targetDate == nil ? .noTargetDate : .onTrack,
|
||||
monthlyGrowthRate: max(0, monthlyGrowthRate),
|
||||
sparkline: []
|
||||
)
|
||||
}
|
||||
|
||||
let rate = max(0, monthlyGrowthRate)
|
||||
let contribution = NSDecimalNumber(decimal: max(0, monthlyContribution)).doubleValue
|
||||
var value = NSDecimalNumber(decimal: currentValue).doubleValue
|
||||
let target = NSDecimalNumber(decimal: targetAmount).doubleValue
|
||||
|
||||
// No trajectory at all → unreachable.
|
||||
if rate <= 0 && contribution <= 0 {
|
||||
return Result(
|
||||
projectedDate: nil,
|
||||
monthsToReach: nil,
|
||||
status: .unreachable,
|
||||
monthlyGrowthRate: 0,
|
||||
sparkline: []
|
||||
)
|
||||
}
|
||||
|
||||
var sparkline: [Decimal] = [Decimal(value)]
|
||||
var months = 0
|
||||
while value < target && months < maxProjectionMonths {
|
||||
value = value * (1 + rate) + contribution
|
||||
months += 1
|
||||
// Sample the sparkline sparsely (at most ~12 points).
|
||||
if months % max(1, months / 12) == 0 || value >= target {
|
||||
sparkline.append(Decimal(value))
|
||||
}
|
||||
}
|
||||
|
||||
guard value >= target else {
|
||||
return Result(
|
||||
projectedDate: nil,
|
||||
monthsToReach: nil,
|
||||
status: .unreachable,
|
||||
monthlyGrowthRate: rate,
|
||||
sparkline: []
|
||||
)
|
||||
}
|
||||
|
||||
let projectedDate = Calendar.current.date(byAdding: .month, value: months, to: asOf)
|
||||
|
||||
let status: Status
|
||||
if let targetDate {
|
||||
let projectedDay = (projectedDate ?? asOf).startOfDay
|
||||
let targetDay = targetDate.startOfDay
|
||||
let deltaDays = targetDay.daysBetween(projectedDay)
|
||||
if deltaDays <= -15 {
|
||||
status = .aheadOfSchedule
|
||||
} else if deltaDays >= 15 {
|
||||
status = .behind
|
||||
} else {
|
||||
status = .onTrack
|
||||
}
|
||||
} else {
|
||||
status = .noTargetDate
|
||||
}
|
||||
|
||||
return Result(
|
||||
projectedDate: projectedDate,
|
||||
monthsToReach: months,
|
||||
status: status,
|
||||
monthlyGrowthRate: rate,
|
||||
sparkline: sparkline
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Vision
|
||||
|
||||
// MARK: - On-device amount OCR (app target)
|
||||
//
|
||||
// Recognizes the monetary amounts in a shared/picked image entirely on-device
|
||||
// (Vision), ranked by visual prominence — the balance is almost always the
|
||||
// biggest number on a bank screen. Mirrors the Share Extension's scanner so the
|
||||
// two entry points behave identically.
|
||||
|
||||
struct ScannedAmount: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let display: String
|
||||
let value: Decimal
|
||||
let prominence: CGFloat
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
|
||||
enum ImageAmountScanner {
|
||||
static let maxCandidates = 6
|
||||
|
||||
static func scan(_ image: UIImage, completion: @escaping ([ScannedAmount]) -> Void) {
|
||||
guard let cgImage = image.cgImage else { completion([]); return }
|
||||
let request = VNRecognizeTextRequest { request, _ in
|
||||
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
|
||||
completion(candidates(from: obs))
|
||||
}
|
||||
request.recognitionLevel = .accurate
|
||||
request.usesLanguageCorrection = false
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation))
|
||||
try? handler.perform([request])
|
||||
}
|
||||
}
|
||||
|
||||
private static let numberPattern: NSRegularExpression = {
|
||||
let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+"
|
||||
return try! NSRegularExpression(pattern: pattern)
|
||||
}()
|
||||
|
||||
private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ScannedAmount] {
|
||||
var found: [ScannedAmount] = []
|
||||
for observation in observations {
|
||||
guard let recognized = observation.topCandidates(1).first else { continue }
|
||||
let line = recognized.string
|
||||
let range = NSRange(line.startIndex..., in: line)
|
||||
for match in numberPattern.matches(in: line, range: range) {
|
||||
guard let r = Range(match.range, in: line) else { continue }
|
||||
let display = String(line[r])
|
||||
guard !isPercentage(display, in: line, at: r),
|
||||
!looksLikeYear(display),
|
||||
let value = CurrencyFormatter.parseUserInput(display), value > 0 else { continue }
|
||||
found.append(ScannedAmount(
|
||||
display: display.replacingOccurrences(of: "\u{00A0}", with: " "),
|
||||
value: value,
|
||||
prominence: observation.boundingBox.height))
|
||||
}
|
||||
}
|
||||
var byValue: [Decimal: ScannedAmount] = [:]
|
||||
for c in found {
|
||||
if let e = byValue[c.value], e.prominence >= c.prominence { continue }
|
||||
byValue[c.value] = c
|
||||
}
|
||||
return byValue.values.sorted { $0.prominence > $1.prominence }.prefix(maxCandidates).map { $0 }
|
||||
}
|
||||
|
||||
private static func isPercentage(_ display: String, in line: String, at range: Range<String.Index>) -> Bool {
|
||||
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||
if after.first == "%" { return true }
|
||||
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||
return before.first == "%"
|
||||
}
|
||||
|
||||
private static func looksLikeYear(_ display: String) -> Bool {
|
||||
guard display.count == 4, display.allSatisfy(\.isNumber), let v = Int(display) else { return false }
|
||||
return (1990...2099).contains(v)
|
||||
}
|
||||
|
||||
private static func cgOrientation(from o: UIImage.Orientation) -> CGImagePropertyOrientation {
|
||||
switch o {
|
||||
case .up: return .up
|
||||
case .down: return .down
|
||||
case .left: return .left
|
||||
case .right: return .right
|
||||
case .upMirrored: return .upMirrored
|
||||
case .downMirrored: return .downMirrored
|
||||
case .leftMirrored: return .leftMirrored
|
||||
case .rightMirrored: return .rightMirrored
|
||||
@unknown default: return .up
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MonthlyCheckInStore — primary storage is CoreData (syncs via iCloud).
|
||||
// UserDefaults is kept for one-time migration from older builds.
|
||||
enum MonthlyCheckInStore {
|
||||
// Legacy UserDefaults keys (read-only after migration)
|
||||
private static let notesKey = "monthlyCheckInNotes"
|
||||
private static let completionsKey = "monthlyCheckInCompletions"
|
||||
private static let legacyLastCheckInKey = "lastCheckInDate"
|
||||
private static let entriesKey = "monthlyCheckInEntries"
|
||||
private static let migrationDoneKey = "journalMigratedToCoreData"
|
||||
static let graceDays = 20
|
||||
|
||||
// MARK: - CoreData Context
|
||||
|
||||
private static var context: NSManagedObjectContext {
|
||||
CoreDataStack.shared.viewContext
|
||||
}
|
||||
|
||||
// MARK: - Public Accessors
|
||||
|
||||
static func note(for date: Date) -> String {
|
||||
entry(for: date)?.note ?? ""
|
||||
fetchEntry(for: monthKey(for: date))?.note ?? ""
|
||||
}
|
||||
|
||||
static func setNote(_ note: String, for date: Date) {
|
||||
@@ -20,21 +32,17 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
static func rating(for date: Date) -> Int? {
|
||||
entry(for: date)?.rating
|
||||
fetchEntry(for: monthKey(for: date))?.ratingValue
|
||||
}
|
||||
|
||||
static func setRating(_ rating: Int?, for date: Date) {
|
||||
updateEntry(for: date) { entry in
|
||||
if let rating, rating > 0 {
|
||||
entry.rating = min(max(1, rating), 5)
|
||||
} else {
|
||||
entry.rating = nil
|
||||
}
|
||||
entry.ratingValue = rating
|
||||
}
|
||||
}
|
||||
|
||||
static func mood(for date: Date) -> MonthlyCheckInMood? {
|
||||
entry(for: date)?.mood
|
||||
fetchEntry(for: monthKey(for: date))?.mood
|
||||
}
|
||||
|
||||
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
|
||||
@@ -44,53 +52,113 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
static func monthKey(for date: Date) -> String {
|
||||
Self.monthFormatter.string(from: date)
|
||||
monthFormatter.string(from: effectiveMonth(for: date))
|
||||
}
|
||||
|
||||
static func allNotes() -> [(date: Date, note: String)] {
|
||||
loadEntries()
|
||||
.compactMap { key, entry in
|
||||
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||
fetchAllEntries()
|
||||
.compactMap { entry in
|
||||
guard let key = entry.monthKey,
|
||||
let date = monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, note: entry.note ?? "")
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
static func entry(for date: Date) -> MonthlyCheckInEntry? {
|
||||
loadEntries()[monthKey(for: date)]
|
||||
fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry)
|
||||
}
|
||||
|
||||
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
|
||||
loadEntries()
|
||||
.compactMap { key, entry in
|
||||
guard let date = Self.monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, entry: entry)
|
||||
fetchAllEntries()
|
||||
.compactMap { entry in
|
||||
guard let key = entry.monthKey,
|
||||
let date = monthFormatter.date(from: key) else { return nil }
|
||||
return (date: date, entry: makeCheckInEntry(entry))
|
||||
}
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
static func completionDate(for date: Date) -> Date? {
|
||||
entry(for: date)?.completionDate
|
||||
fetchEntry(for: monthKey(for: date))?.completionTime
|
||||
}
|
||||
|
||||
static func setCompletionDate(_ completionDate: Date, for month: Date) {
|
||||
updateEntry(for: month) { entry in
|
||||
entry.completionTime = completionDate.timeIntervalSince1970
|
||||
let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays)
|
||||
let targetKey = monthFormatter.string(from: targetMonth)
|
||||
|
||||
let calendar = Calendar.current
|
||||
if calendar.isDate(month, inSameDayAs: completionDate),
|
||||
calendar.component(.day, from: completionDate) > graceDays {
|
||||
|
||||
let allExisting = fetchAllEntries()
|
||||
let completedPrevious = allExisting.compactMap { entry -> (month: Date, entry: JournalEntry)? in
|
||||
guard let key = entry.monthKey,
|
||||
let entryMonth = monthFormatter.date(from: key)?.startOfMonth,
|
||||
entry.completionTime != nil,
|
||||
entryMonth < targetMonth else { return nil }
|
||||
return (month: entryMonth, entry: entry)
|
||||
}
|
||||
|
||||
if let lastCompleted = completedPrevious.max(by: { $0.month < $1.month }) {
|
||||
var cursor = lastCompleted.month.adding(months: 1).startOfMonth
|
||||
while cursor < targetMonth {
|
||||
let key = monthFormatter.string(from: cursor)
|
||||
if fetchEntry(for: key) == nil {
|
||||
let fallbackDate = min(cursor.endOfMonth, completionDate)
|
||||
let new = JournalEntry(context: context)
|
||||
new.monthKey = key
|
||||
new.note = lastCompleted.entry.note
|
||||
new.ratingValue = lastCompleted.entry.ratingValue
|
||||
new.mood = lastCompleted.entry.mood
|
||||
new.completionTime = fallbackDate
|
||||
}
|
||||
cursor = cursor.adding(months: 1).startOfMonth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure target month entry exists with the completion date.
|
||||
let targetEntry = fetchEntry(for: targetKey) ?? {
|
||||
let e = JournalEntry(context: context)
|
||||
e.monthKey = targetKey
|
||||
return e
|
||||
}()
|
||||
targetEntry.completionTime = completionDate
|
||||
|
||||
// Backfill any previous entries that have no completion date.
|
||||
for entry in fetchAllEntries() {
|
||||
guard let key = entry.monthKey,
|
||||
let entryMonth = monthFormatter.date(from: key)?.startOfMonth,
|
||||
entryMonth < targetMonth,
|
||||
entry.completionTime == nil else { continue }
|
||||
entry.completionTime = min(entryMonth.endOfMonth, completionDate)
|
||||
}
|
||||
|
||||
saveContext()
|
||||
}
|
||||
|
||||
static func latestCompletionDate() -> Date? {
|
||||
let latestEntryDate = loadEntries().values
|
||||
.compactMap { $0.completionDate }
|
||||
.max()
|
||||
let request = JournalEntry.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "completionTime != nil")
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)]
|
||||
request.fetchLimit = 1
|
||||
return (try? context.fetch(request))?.first?.completionTime
|
||||
}
|
||||
|
||||
if let latestEntryDate {
|
||||
return latestEntryDate
|
||||
static func effectiveMonth(
|
||||
for date: Date,
|
||||
relativeTo referenceDate: Date = Date(),
|
||||
graceDays: Int = 20
|
||||
) -> Date {
|
||||
let calendar = Calendar.current
|
||||
if calendar.isDate(date, inSameDayAs: referenceDate) {
|
||||
let day = calendar.component(.day, from: referenceDate)
|
||||
if day <= graceDays {
|
||||
return referenceDate.adding(months: -1).startOfMonth
|
||||
}
|
||||
}
|
||||
|
||||
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
|
||||
guard legacy > 0 else { return nil }
|
||||
return Date(timeIntervalSince1970: legacy)
|
||||
return date.startOfMonth
|
||||
}
|
||||
|
||||
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
||||
@@ -115,7 +183,6 @@ enum MonthlyCheckInStore {
|
||||
let totalCheckIns = completions.count
|
||||
let onTimeCount = onTimeMonths.count
|
||||
|
||||
// Current streak counts consecutive on-time months up to the reference month.
|
||||
var currentStreak = 0
|
||||
var cursor = referenceDate.startOfMonth
|
||||
while onTimeMonths.contains(cursor) {
|
||||
@@ -123,7 +190,6 @@ enum MonthlyCheckInStore {
|
||||
cursor = cursor.adding(months: -1).startOfMonth
|
||||
}
|
||||
|
||||
// Best streak across history.
|
||||
let sortedMonths = onTimeMonths.sorted()
|
||||
var bestStreak = 0
|
||||
var running = 0
|
||||
@@ -139,9 +205,7 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
let averageDaysBeforeDeadline = onTimeCount > 0
|
||||
? deadlineDiffs
|
||||
.filter { $0 >= 0 }
|
||||
.average()
|
||||
? deadlineDiffs.filter { $0 >= 0 }.average()
|
||||
: nil
|
||||
let closestCutoffDays = onTimeCount > 0
|
||||
? deadlineDiffs.filter { $0 >= 0 }.min()
|
||||
@@ -175,6 +239,10 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
static func clearAll() {
|
||||
for entry in fetchAllEntries() {
|
||||
context.delete(entry)
|
||||
}
|
||||
saveContext()
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: notesKey)
|
||||
defaults.removeObject(forKey: completionsKey)
|
||||
@@ -182,168 +250,129 @@ enum MonthlyCheckInStore {
|
||||
defaults.removeObject(forKey: legacyLastCheckInKey)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
// MARK: - One-time Migration from UserDefaults
|
||||
|
||||
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
|
||||
static func migrateIfNeeded() {
|
||||
guard !UserDefaults.standard.bool(forKey: migrationDoneKey) else { return }
|
||||
|
||||
let legacyEntries = loadLegacyEntries()
|
||||
for (key, legacy) in legacyEntries {
|
||||
guard fetchEntry(for: key) == nil else { continue }
|
||||
let entry = JournalEntry(context: context)
|
||||
entry.monthKey = key
|
||||
entry.note = legacy.note
|
||||
entry.ratingValue = legacy.rating
|
||||
entry.mood = legacy.mood
|
||||
if let t = legacy.completionTime {
|
||||
entry.completionTime = Date(timeIntervalSince1970: t)
|
||||
}
|
||||
entry.createdAt = Date(timeIntervalSince1970: legacy.createdAt)
|
||||
}
|
||||
|
||||
saveContext()
|
||||
UserDefaults.standard.set(true, forKey: migrationDoneKey)
|
||||
}
|
||||
|
||||
// MARK: - Private CoreData Helpers
|
||||
|
||||
private static func fetchEntry(for key: String) -> JournalEntry? {
|
||||
let request = JournalEntry.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "monthKey == %@", key)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
private static func fetchAllEntries() -> [JournalEntry] {
|
||||
let request = JournalEntry.fetchRequest()
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func updateEntry(for date: Date, mutate: (JournalEntry) -> Void) {
|
||||
let key = monthKey(for: date)
|
||||
var entries = loadEntries()
|
||||
var entry = entries[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: legacyCompletion(for: key),
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
let entry = fetchEntry(for: key) ?? {
|
||||
let e = JournalEntry(context: context)
|
||||
e.monthKey = key
|
||||
return e
|
||||
}()
|
||||
|
||||
mutate(&entry)
|
||||
mutate(entry)
|
||||
|
||||
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
|
||||
entry.note = nil
|
||||
}
|
||||
|
||||
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
|
||||
let isEmpty = entry.note == nil
|
||||
&& entry.ratingValue == nil
|
||||
&& entry.mood == nil
|
||||
&& entry.completionTime == nil
|
||||
if isEmpty {
|
||||
entries.removeValue(forKey: key)
|
||||
} else {
|
||||
entries[key] = entry
|
||||
context.delete(entry)
|
||||
}
|
||||
|
||||
saveEntries(entries)
|
||||
persistLegacyMirrors(entries)
|
||||
saveContext()
|
||||
}
|
||||
|
||||
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
|
||||
guard let data = UserDefaults.standard.data(forKey: entriesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
|
||||
return migrateLegacyData()
|
||||
private static func saveContext() {
|
||||
guard context.hasChanges else { return }
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private static func makeCheckInEntry(_ entry: JournalEntry) -> MonthlyCheckInEntry {
|
||||
MonthlyCheckInEntry(
|
||||
note: entry.note,
|
||||
rating: entry.ratingValue,
|
||||
mood: entry.mood,
|
||||
completionTime: entry.completionTime?.timeIntervalSince1970,
|
||||
createdAt: entry.createdAt?.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Legacy UserDefaults Reader (for migration only)
|
||||
|
||||
private static func loadLegacyEntries() -> [String: MonthlyCheckInEntry] {
|
||||
if let data = UserDefaults.standard.data(forKey: entriesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data),
|
||||
!decoded.isEmpty {
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Ensure legacy data is merged if it existed before this release.
|
||||
return mergeLegacy(into: decoded)
|
||||
return loadAndMergeLegacyKeys()
|
||||
}
|
||||
|
||||
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
UserDefaults.standard.set(data, forKey: entriesKey)
|
||||
}
|
||||
|
||||
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
|
||||
let notes = loadNotes()
|
||||
let completions = loadCompletions()
|
||||
private static func loadAndMergeLegacyKeys() -> [String: MonthlyCheckInEntry] {
|
||||
let notes = loadLegacyNotes()
|
||||
let completions = loadLegacyCompletions()
|
||||
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
|
||||
|
||||
var entries: [String: MonthlyCheckInEntry] = [:]
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
for (key, note) in notes {
|
||||
entries[key] = MonthlyCheckInEntry(
|
||||
note: note,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completions[key],
|
||||
createdAt: now
|
||||
note: note, rating: nil, mood: nil,
|
||||
completionTime: completions[key], createdAt: now
|
||||
)
|
||||
}
|
||||
|
||||
for (key, completion) in completions where entries[key] == nil {
|
||||
entries[key] = MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completion,
|
||||
createdAt: completion
|
||||
note: nil, rating: nil, mood: nil,
|
||||
completionTime: completion, createdAt: completion
|
||||
)
|
||||
}
|
||||
|
||||
saveEntries(entries)
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
|
||||
var merged = entries
|
||||
let notes = loadNotes()
|
||||
let completions = loadCompletions()
|
||||
var shouldSave = false
|
||||
|
||||
for (key, note) in notes where merged[key]?.note == nil {
|
||||
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: completions[key],
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
entry.note = note
|
||||
merged[key] = entry
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
for (key, completion) in completions where merged[key]?.completionTime == nil {
|
||||
var entry = merged[key] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: nil,
|
||||
createdAt: completion
|
||||
)
|
||||
entry.completionTime = completion
|
||||
merged[key] = entry
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
if shouldSave {
|
||||
saveEntries(merged)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
|
||||
var notes: [String: String] = [:]
|
||||
var completions: [String: Double] = [:]
|
||||
|
||||
for (key, entry) in entries {
|
||||
if let note = entry.note {
|
||||
notes[key] = note
|
||||
}
|
||||
if let completion = entry.completionTime {
|
||||
completions[key] = completion
|
||||
}
|
||||
}
|
||||
|
||||
saveNotes(notes)
|
||||
saveCompletions(completions)
|
||||
}
|
||||
|
||||
private static func loadNotes() -> [String: String] {
|
||||
private static func loadLegacyNotes() -> [String: String] {
|
||||
guard let data = UserDefaults.standard.data(forKey: notesKey),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveNotes(_ notes: [String: String]) {
|
||||
guard let data = try? JSONEncoder().encode(notes) else { return }
|
||||
UserDefaults.standard.set(data, forKey: notesKey)
|
||||
}
|
||||
|
||||
private static func loadCompletions() -> [String: Double] {
|
||||
private static func loadLegacyCompletions() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: completionsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func saveCompletions(_ completions: [String: Double]) {
|
||||
guard let data = try? JSONEncoder().encode(completions) else { return }
|
||||
UserDefaults.standard.set(data, forKey: completionsKey)
|
||||
}
|
||||
|
||||
private static func legacyCompletion(for key: String) -> Double? {
|
||||
loadCompletions()[key]
|
||||
}
|
||||
// MARK: - Private Achievement Helpers
|
||||
|
||||
private struct MonthlyCheckInAchievementRule {
|
||||
let achievement: MonthlyCheckInAchievement
|
||||
@@ -397,9 +426,7 @@ enum MonthlyCheckInStore {
|
||||
icon: "hourglass"
|
||||
),
|
||||
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
|
||||
if let closestCutoffDays {
|
||||
return closestCutoffDays <= 2
|
||||
}
|
||||
if let closestCutoffDays { return closestCutoffDays <= 2 }
|
||||
return false
|
||||
}
|
||||
),
|
||||
@@ -445,12 +472,8 @@ enum MonthlyCheckInStore {
|
||||
) -> [MonthlyCheckInAchievement] {
|
||||
achievementRules.compactMap { rule in
|
||||
rule.isUnlocked(
|
||||
currentStreak,
|
||||
bestStreak,
|
||||
onTimeCount,
|
||||
totalCheckIns,
|
||||
closestCutoffDays,
|
||||
averageDaysBeforeDeadline
|
||||
currentStreak, bestStreak, onTimeCount, totalCheckIns,
|
||||
closestCutoffDays, averageDaysBeforeDeadline
|
||||
) ? rule.achievement : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Per-source recurring monthly contribution amount.
|
||||
///
|
||||
/// Backed by the `InvestmentSource.monthlyContribution` Core Data attribute so the value
|
||||
/// syncs across devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is
|
||||
/// device-local and did NOT sync — `migrateIfNeeded(context:)` moves any legacy value over.
|
||||
enum MonthlyContributionStore {
|
||||
/// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
|
||||
private static let legacyKey = "monthlyContributions"
|
||||
|
||||
private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
|
||||
|
||||
static func contribution(for sourceId: UUID) -> Decimal? {
|
||||
guard let source = fetchSource(sourceId),
|
||||
let amount = source.monthlyContribution?.decimalValue,
|
||||
amount > 0 else { return nil }
|
||||
return amount
|
||||
}
|
||||
|
||||
static func setContribution(_ amount: Decimal?, for sourceId: UUID) {
|
||||
guard let source = fetchSource(sourceId) else { return }
|
||||
if let amount, amount > 0 {
|
||||
source.monthlyContribution = NSDecimalNumber(decimal: amount)
|
||||
} else {
|
||||
source.monthlyContribution = nil
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
private static func fetchSource(_ id: UUID) -> InvestmentSource? {
|
||||
let request = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
/// One-time migration of legacy UserDefaults contributions into Core Data so they sync
|
||||
/// via iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
|
||||
static func migrateIfNeeded(context: NSManagedObjectContext) {
|
||||
guard let data = UserDefaults.standard.data(forKey: legacyKey),
|
||||
let dict = try? JSONDecoder().decode([String: Double].self, from: data),
|
||||
!dict.isEmpty else { return }
|
||||
|
||||
let sources = (try? context.fetch(InvestmentSource.fetchRequest())) ?? []
|
||||
var changed = false
|
||||
for source in sources where source.monthlyContribution == nil {
|
||||
if let raw = dict[source.id.uuidString], raw > 0 {
|
||||
source.monthlyContribution = NSDecimalNumber(value: raw)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed { try? context.save() }
|
||||
UserDefaults.standard.removeObject(forKey: legacyKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Decides when to ask for an App Store rating: after a "positive moment"
|
||||
/// (a saved check-in), only once the habit is established (≥2 distinct months
|
||||
/// of snapshots) and at most once per app version. Apple additionally caps
|
||||
/// the system prompt at 3 displays per year, so over-calling is safe.
|
||||
///
|
||||
/// Call sites own the actual prompt via SwiftUI's `\.requestReview` — this
|
||||
/// service only answers "should we ask now?" and records that we did.
|
||||
@MainActor
|
||||
enum ReviewRequestService {
|
||||
private static let lastVersionKey = "reviewRequest.lastVersion"
|
||||
|
||||
/// Returns true at most once per app version, and only when the user has
|
||||
/// snapshots in 2+ distinct months (they've seen the app deliver value).
|
||||
/// Marks the version as asked when returning true.
|
||||
static func shouldRequestAfterCheckIn() -> Bool {
|
||||
guard distinctSnapshotMonths() >= 2 else { return false }
|
||||
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.string(forKey: lastVersionKey) != version else { return false }
|
||||
|
||||
defaults.set(version, forKey: lastVersionKey)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func distinctSnapshotMonths() -> Int {
|
||||
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
|
||||
guard let snapshots = try? CoreDataStack.shared.viewContext.fetch(request) else { return 0 }
|
||||
let calendar = Calendar.current
|
||||
let months = Set(snapshots.map { snapshot in
|
||||
calendar.dateComponents([.year, .month], from: snapshot.date)
|
||||
})
|
||||
return months.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MARK: - App Group bridge for the Quick Update share extension
|
||||
//
|
||||
// The share extension can't open the CloudKit-backed Core Data store, so the app
|
||||
// maintains a lightweight mirror of active sources in the App Group defaults and
|
||||
// the extension appends "pending updates" to a queue there. The app ingests the
|
||||
// queue on every activation, creating real snapshots.
|
||||
|
||||
/// Snapshot of an active source, enough for the extension's picker.
|
||||
struct SharedSourceInfo: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let latestValue: Double
|
||||
let currencyCode: String
|
||||
/// True when a value was already recorded this month (by app or extension).
|
||||
var updatedThisMonth: Bool
|
||||
}
|
||||
|
||||
/// A value captured from the share extension, waiting to become a Snapshot.
|
||||
struct PendingQuickUpdate: Codable {
|
||||
let sourceId: UUID
|
||||
let amount: Double
|
||||
let capturedAt: Date
|
||||
}
|
||||
|
||||
enum SharedQuickUpdateStore {
|
||||
private static let mirrorKey = "sharedSourceMirror"
|
||||
private static let queueKey = "pendingQuickUpdates"
|
||||
|
||||
static var defaults: UserDefaults? {
|
||||
UserDefaults(suiteName: AppConstants.appGroupIdentifier)
|
||||
}
|
||||
|
||||
// MARK: Mirror (app writes, extension reads)
|
||||
|
||||
static func writeMirror(_ sources: [SharedSourceInfo]) {
|
||||
guard let data = try? JSONEncoder().encode(sources) else { return }
|
||||
defaults?.set(data, forKey: mirrorKey)
|
||||
}
|
||||
|
||||
static func readMirror() -> [SharedSourceInfo] {
|
||||
guard let data = defaults?.data(forKey: mirrorKey),
|
||||
let decoded = try? JSONDecoder().decode([SharedSourceInfo].self, from: data) else { return [] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
// MARK: Pending queue (extension writes, app drains)
|
||||
|
||||
static func appendPending(_ update: PendingQuickUpdate) {
|
||||
var queue = readPending()
|
||||
queue.append(update)
|
||||
if let data = try? JSONEncoder().encode(queue) {
|
||||
defaults?.set(data, forKey: queueKey)
|
||||
}
|
||||
// Optimistically mark the source as updated in the mirror so the extension
|
||||
// suggests the next pending source on the following invocation.
|
||||
var mirror = readMirror()
|
||||
if let idx = mirror.firstIndex(where: { $0.id == update.sourceId }) {
|
||||
mirror[idx].updatedThisMonth = true
|
||||
writeMirror(mirror)
|
||||
}
|
||||
}
|
||||
|
||||
static func readPending() -> [PendingQuickUpdate] {
|
||||
guard let data = defaults?.data(forKey: queueKey),
|
||||
let decoded = try? JSONDecoder().decode([PendingQuickUpdate].self, from: data) else { return [] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
static func clearPending() {
|
||||
defaults?.removeObject(forKey: queueKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-side sync & ingestion
|
||||
|
||||
enum SharedQuickUpdateSync {
|
||||
/// Refreshes the source mirror for the extension. Call on app activation and
|
||||
/// after snapshot saves.
|
||||
@MainActor
|
||||
static func refreshMirror() {
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let request = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "isActive == YES")
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||
guard let sources = try? context.fetch(request) else { return }
|
||||
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
let calendar = Calendar.current
|
||||
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date()
|
||||
|
||||
let mirror = sources.map { source in
|
||||
SharedSourceInfo(
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
latestValue: NSDecimalNumber(decimal: source.latestValue).doubleValue,
|
||||
currencyCode: source.account?.currency ?? settings.currency,
|
||||
updatedThisMonth: (source.latestSnapshot?.date ?? .distantPast) >= monthStart
|
||||
)
|
||||
}
|
||||
SharedQuickUpdateStore.writeMirror(mirror)
|
||||
}
|
||||
|
||||
/// Drains the extension's pending queue into real snapshots. Returns how many
|
||||
/// snapshots were created. Call on app activation.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
static func ingestPending() -> Int {
|
||||
let pending = SharedQuickUpdateStore.readPending()
|
||||
guard !pending.isEmpty else { return 0 }
|
||||
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let request = InvestmentSource.fetchRequest()
|
||||
guard let sources = try? context.fetch(request) else { return 0 }
|
||||
let byId = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
|
||||
|
||||
var created = 0
|
||||
for update in pending {
|
||||
guard let source = byId[update.sourceId] else { continue }
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.id = UUID()
|
||||
snapshot.value = NSDecimalNumber(value: update.amount)
|
||||
snapshot.date = update.capturedAt
|
||||
snapshot.source = source
|
||||
created += 1
|
||||
}
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to ingest pending quick updates: \(error)")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
SharedQuickUpdateStore.clearPending()
|
||||
refreshMirror()
|
||||
return created
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Detects INTERNAL gaps in a source's snapshot history: two consecutive
|
||||
/// snapshots more than one calendar month apart, i.e. one or more calendar
|
||||
/// months between them have no data at all.
|
||||
///
|
||||
/// This complements `ChartGapFill`, which only smooths the *display* by
|
||||
/// interpolating/carrying values across those empty months. The chart looks
|
||||
/// continuous, but real data is missing — this surfaces that so the user can
|
||||
/// fill it in. Read-only: it never mutates Core Data.
|
||||
enum SnapshotGapDetector {
|
||||
|
||||
/// A single internal gap for one source.
|
||||
struct Gap: Identifiable, Equatable {
|
||||
let sourceId: UUID
|
||||
let sourceName: String
|
||||
/// The month of the snapshot before the gap (start of that month).
|
||||
let fromMonth: Date
|
||||
/// The month of the snapshot after the gap (start of that month).
|
||||
let toMonth: Date
|
||||
/// Number of calendar months with no data between `fromMonth` and `toMonth`.
|
||||
let missingMonths: Int
|
||||
/// The individual missing months (start-of-month dates), oldest first.
|
||||
let missingMonthDates: [Date]
|
||||
|
||||
var id: String { "\(sourceId.uuidString)-\(fromMonth.timeIntervalSince1970)" }
|
||||
}
|
||||
|
||||
/// Computes all internal gaps across the provided sources' snapshots.
|
||||
/// Only months strictly between two known snapshots count — a source's
|
||||
/// trailing "not updated in a while" is handled elsewhere (pending updates).
|
||||
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
|
||||
let calendar = Calendar.current
|
||||
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
||||
var gaps: [Gap] = []
|
||||
|
||||
for source in sources {
|
||||
let sourceId = source.id
|
||||
guard let sourceSnapshots = snapshotsBySource[sourceId], sourceSnapshots.count >= 2 else {
|
||||
continue
|
||||
}
|
||||
|
||||
// One value per calendar month (keep the latest in each), sorted ascending.
|
||||
var monthStarts: [Date] = sourceSnapshots
|
||||
.map { $0.date.startOfMonth }
|
||||
monthStarts = Array(Set(monthStarts)).sorted()
|
||||
|
||||
guard monthStarts.count >= 2 else { continue }
|
||||
|
||||
for i in 0..<(monthStarts.count - 1) {
|
||||
let from = monthStarts[i]
|
||||
let to = monthStarts[i + 1]
|
||||
let monthsApart = from.monthsBetween(to)
|
||||
guard monthsApart > 1 else { continue }
|
||||
|
||||
var missingDates: [Date] = []
|
||||
var cursor = from.adding(months: 1).startOfMonth
|
||||
while cursor < to {
|
||||
missingDates.append(cursor)
|
||||
cursor = cursor.adding(months: 1).startOfMonth
|
||||
}
|
||||
guard !missingDates.isEmpty else { continue }
|
||||
|
||||
gaps.append(Gap(
|
||||
sourceId: sourceId,
|
||||
sourceName: source.name,
|
||||
fromMonth: from,
|
||||
toMonth: to,
|
||||
missingMonths: missingDates.count,
|
||||
missingMonthDates: missingDates
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Most recent gaps first (by the month after the gap), then by source name.
|
||||
return gaps.sorted {
|
||||
if $0.toMonth != $1.toMonth { return $0.toMonth > $1.toMonth }
|
||||
return $0.sourceName < $1.sourceName
|
||||
}
|
||||
}
|
||||
|
||||
/// Total count of missing months across all gaps.
|
||||
static func totalMissingMonths(_ gaps: [Gap]) -> Int {
|
||||
gaps.reduce(0) { $0 + $1.missingMonths }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Deterministic UUIDs (RFC 4122 v5) for records that every device/install must
|
||||
/// create identically — the default Account and the default Categories.
|
||||
///
|
||||
/// Why: `awakeFromInsert()` assigns a random UUID, so each device seeded its own
|
||||
/// "Default" account + default categories with *different* UUIDs before the first
|
||||
/// CloudKit import. CloudKit keys on the UUID (record name), so those identical-in-
|
||||
/// meaning records piled up as distinct records (8 accounts, 23 categories in the
|
||||
/// wild). Giving the defaults a STABLE UUID derived from a fixed namespace + name
|
||||
/// means every device produces the SAME record name → CloudKit natively merges
|
||||
/// them into one. No dedup, no deletes, no cascade — the root-cause fix.
|
||||
enum StableID {
|
||||
/// Fixed app namespace (generated once, constant forever — never change it).
|
||||
static let namespace = UUID(uuidString: "F3A1C2D4-5E6F-4A7B-8C9D-0E1F2A3B4C5D")!
|
||||
|
||||
/// RFC 4122 v5 UUID: SHA-1(namespace bytes ‖ name) with version/variant bits set.
|
||||
static func v5(_ name: String) -> UUID {
|
||||
var hasher = Insecure.SHA1()
|
||||
withUnsafeBytes(of: namespace.uuid) { hasher.update(bufferPointer: $0) }
|
||||
hasher.update(data: Data(name.utf8))
|
||||
let digest = Array(hasher.finalize()) // 20 bytes; take first 16
|
||||
|
||||
var b = Array(digest.prefix(16))
|
||||
b[6] = (b[6] & 0x0F) | 0x50 // version 5
|
||||
b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 variant
|
||||
|
||||
return UUID(uuid: (b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class DashboardViewModel: ObservableObject {
|
||||
@@ -22,6 +23,14 @@ class DashboardViewModel: ObservableObject {
|
||||
// MARK: - Chart Data
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
@Published var updateStreak: Int = 0
|
||||
|
||||
// MARK: - Data Gaps
|
||||
|
||||
/// Internal gaps in per-source snapshot history (missing months between two
|
||||
/// known snapshots). The chart smooths these over; this exposes them so the
|
||||
/// user knows real data is missing and can fill it. See SnapshotGapDetector.
|
||||
@Published var dataGaps: [SnapshotGapDetector.Gap] = []
|
||||
|
||||
// MARK: - Portfolio Forecast
|
||||
|
||||
@@ -195,11 +204,21 @@ class DashboardViewModel: ObservableObject {
|
||||
snapshots: allSnapshots
|
||||
)
|
||||
updateEvolutionData(from: completedSnapshots, categories: categories)
|
||||
latestPortfolioChange = calculateLatestChange(from: evolutionData)
|
||||
latestPortfolioChange = calculateLatestCheckInChange(
|
||||
sources: sources,
|
||||
snapshots: allSnapshots,
|
||||
fallback: evolutionData
|
||||
)
|
||||
|
||||
// Calculate portfolio forecast
|
||||
updatePortfolioForecast()
|
||||
|
||||
// Compute update streak
|
||||
updateStreak = computeStreak(from: evolutionData)
|
||||
|
||||
// Detect internal data gaps (missing months between known snapshots)
|
||||
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
@@ -274,51 +293,75 @@ class DashboardViewModel: ObservableObject {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
let calendar = Calendar.current
|
||||
func monthKey(_ date: Date) -> DateComponents {
|
||||
let c = calendar.dateComponents([.year, .month], from: date)
|
||||
return DateComponents(year: c.year, month: c.month)
|
||||
}
|
||||
|
||||
// Ordered grid of every month that has data, and each month's index in it.
|
||||
let sortedMonthKeys = Set(snapshots.map { monthKey($0.date) }).sorted {
|
||||
(calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast)
|
||||
}
|
||||
let monthCount = sortedMonthKeys.count
|
||||
guard monthCount > 0 else {
|
||||
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
|
||||
}
|
||||
var indexByKey: [DateComponents: Int] = [:]
|
||||
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
|
||||
|
||||
// Per source: the latest snapshot value at each month index + its category.
|
||||
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
|
||||
var categoryBySource: [UUID: UUID] = [:]
|
||||
for snapshot in snapshots {
|
||||
guard let sourceId = snapshot.source?.id,
|
||||
let idx = indexByKey[monthKey(snapshot.date)] else { continue }
|
||||
let existing = knownBySource[sourceId]?[idx]
|
||||
if existing == nil || snapshot.date > existing!.date {
|
||||
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
|
||||
}
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
categoryBySource[sourceId] = categoryId
|
||||
}
|
||||
}
|
||||
|
||||
// Fill each source's gaps (linear interpolation when enabled, else stepped
|
||||
// carry-forward), then sum per month. Interpolating per source means a
|
||||
// source last seen in Feb and next in Jun contributes a smooth ramp for
|
||||
// Mar/Apr/May instead of a flat line + jump. Chart display only — no data
|
||||
// is created (see ChartGapFill).
|
||||
let interpolate = ChartGapFill.isEnabled
|
||||
var monthTotals = [Decimal](repeating: 0, count: monthCount)
|
||||
var monthCategory = [[UUID: Decimal]](repeating: [:], count: monthCount)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for (sourceId, known) in knownBySource {
|
||||
let dense = ChartGapFill.denseValues(
|
||||
known: known.mapValues { $0.value },
|
||||
monthCount: monthCount,
|
||||
interpolate: interpolate
|
||||
)
|
||||
let categoryId = categoryBySource[sourceId]
|
||||
for i in 0..<monthCount {
|
||||
guard let v = dense[i] else { continue }
|
||||
monthTotals[i] += v
|
||||
if let categoryId {
|
||||
monthCategory[i][categoryId, default: 0] += v
|
||||
categoryTotals[categoryId, default: 0] += v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var evolution: [(date: Date, value: Decimal)] = []
|
||||
evolution.reserveCapacity(groupedByMonth.count)
|
||||
evolution.reserveCapacity(monthCount)
|
||||
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
|
||||
series.reserveCapacity(groupedByMonth.count)
|
||||
var categoryTotals: [UUID: Decimal] = [:]
|
||||
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snapshot in monthSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
var total: Decimal = 0
|
||||
var valuesByCategory: [UUID: Decimal] = [:]
|
||||
|
||||
for snapshot in latestBySource.values {
|
||||
let value = snapshot.decimalValue
|
||||
total += value
|
||||
if let categoryId = snapshot.source?.category?.id {
|
||||
valuesByCategory[categoryId, default: 0] += value
|
||||
categoryTotals[categoryId, default: 0] += value
|
||||
}
|
||||
}
|
||||
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
evolution.append((date: date, value: total))
|
||||
series.append((date: date, valuesByCategory: valuesByCategory))
|
||||
series.reserveCapacity(monthCount)
|
||||
for i in 0..<monthCount {
|
||||
let date = calendar.date(from: sortedMonthKeys[i]) ?? Date()
|
||||
evolution.append((date: date, value: monthTotals[i]))
|
||||
series.append((date: date, valuesByCategory: monthCategory[i]))
|
||||
}
|
||||
|
||||
evolution.sort { $0.date < $1.date }
|
||||
series.sort { $0.date < $1.date }
|
||||
|
||||
return EvolutionSummary(
|
||||
evolutionData: evolution,
|
||||
categorySeries: series,
|
||||
@@ -345,7 +388,10 @@ class DashboardViewModel: ObservableObject {
|
||||
for (key, monthSnapshots) in groupedByMonth {
|
||||
guard let monthDate = Calendar.current.date(from: key) else { continue }
|
||||
guard monthDate > cutoff else { continue }
|
||||
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
|
||||
// A month is "complete" if all active sources have snapshot data for it.
|
||||
// We intentionally do NOT require a MonthlyCheckInStore entry here because
|
||||
// that store lives in UserDefaults (device-local) and is not synced via iCloud.
|
||||
// Snapshot data from CoreData is the authoritative cross-device source of truth.
|
||||
let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
|
||||
if sourceIds.isSubset(of: monthSourceIds) {
|
||||
completed.insert(key)
|
||||
@@ -360,7 +406,7 @@ class DashboardViewModel: ObservableObject {
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
return snapshots
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
@@ -371,6 +417,10 @@ class DashboardViewModel: ObservableObject {
|
||||
|
||||
return snapshots.filter { snapshot in
|
||||
let monthDate = snapshot.date.startOfMonth
|
||||
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
|
||||
snapshot.date > completionDate {
|
||||
return false
|
||||
}
|
||||
if monthDate <= lastCompleted {
|
||||
return true
|
||||
}
|
||||
@@ -382,7 +432,7 @@ class DashboardViewModel: ObservableObject {
|
||||
|
||||
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
|
||||
guard data.count >= 2 else {
|
||||
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
|
||||
return PortfolioChange(absolute: 0, percentage: 0, label: "since last check-in")
|
||||
}
|
||||
let last = data[data.count - 1]
|
||||
let previous = data[data.count - 2]
|
||||
@@ -390,7 +440,58 @@ class DashboardViewModel: ObservableObject {
|
||||
let percentage = previous.value > 0
|
||||
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
|
||||
: 0
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
|
||||
}
|
||||
|
||||
private func calculateLatestCheckInChange(
|
||||
sources: [InvestmentSource],
|
||||
snapshots: [Snapshot],
|
||||
fallback: [(date: Date, value: Decimal)]
|
||||
) -> PortfolioChange {
|
||||
let completedMonths = MonthlyCheckInStore.allEntries()
|
||||
.compactMap { entry -> Date? in
|
||||
entry.entry.completionDate != nil ? entry.date.startOfMonth : nil
|
||||
}
|
||||
.sorted()
|
||||
|
||||
guard completedMonths.count >= 2 else {
|
||||
return calculateLatestChange(from: fallback)
|
||||
}
|
||||
|
||||
let lastMonth = completedMonths[completedMonths.count - 1]
|
||||
let previousMonth = completedMonths[completedMonths.count - 2]
|
||||
let lastCompletion = MonthlyCheckInStore.completionDate(for: lastMonth) ?? lastMonth.endOfMonth
|
||||
let previousCompletion = MonthlyCheckInStore.completionDate(for: previousMonth) ?? previousMonth.endOfMonth
|
||||
|
||||
let lastValue = totalPortfolioValue(asOf: lastCompletion, sources: sources, snapshots: snapshots)
|
||||
let previousValue = totalPortfolioValue(asOf: previousCompletion, sources: sources, snapshots: snapshots)
|
||||
let absolute = lastValue - previousValue
|
||||
let percentage = previousValue > 0
|
||||
? NSDecimalNumber(decimal: absolute / previousValue).doubleValue * 100
|
||||
: 0
|
||||
|
||||
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
|
||||
}
|
||||
|
||||
private func totalPortfolioValue(
|
||||
asOf date: Date,
|
||||
sources: [InvestmentSource],
|
||||
snapshots: [Snapshot]
|
||||
) -> Decimal {
|
||||
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
|
||||
var total = Decimal.zero
|
||||
|
||||
for source in sources {
|
||||
let sourceId = source.id
|
||||
guard let sourceSnapshots = snapshotsBySource[sourceId] else { continue }
|
||||
if let latest = sourceSnapshots
|
||||
.filter({ $0.date <= date })
|
||||
.max(by: { $0.date < $1.date }) {
|
||||
total += latest.decimalValue
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
private func updatePortfolioForecast() {
|
||||
@@ -427,8 +528,19 @@ class DashboardViewModel: ObservableObject {
|
||||
let totalGrowth = lastPoint.value - firstPoint.value
|
||||
let monthlyGrowth = totalGrowth / Decimal(monthsBetween)
|
||||
|
||||
// Project 12 months ahead
|
||||
let forecastValue = currentValue + (monthlyGrowth * 12)
|
||||
// Project 12 months ahead using compound growth (matches CAGR semantics);
|
||||
// fall back to the linear projection if the ratio is degenerate.
|
||||
let firstD = NSDecimalNumber(decimal: firstPoint.value).doubleValue
|
||||
let lastD = NSDecimalNumber(decimal: lastPoint.value).doubleValue
|
||||
let currentD = NSDecimalNumber(decimal: currentValue).doubleValue
|
||||
let forecastValue: Decimal
|
||||
if firstD > 0, lastD > 0 {
|
||||
let monthlyRate = pow(lastD / firstD, 1.0 / Double(monthsBetween))
|
||||
let projected = currentD * pow(monthlyRate, 12)
|
||||
forecastValue = projected.isFinite ? Decimal(projected) : currentValue + (monthlyGrowth * 12)
|
||||
} else {
|
||||
forecastValue = currentValue + (monthlyGrowth * 12)
|
||||
}
|
||||
|
||||
// Calculate confidence interval based on volatility
|
||||
let volatility = calculatePortfolioVolatility(from: values)
|
||||
@@ -470,10 +582,49 @@ class DashboardViewModel: ObservableObject {
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let totalReturn = (last.value - first.value) / first.value
|
||||
|
||||
// Annualize: (1 + total_return)^(12/months) - 1
|
||||
let monthlyReturn = totalReturn / Decimal(monthsBetween)
|
||||
let annualized = monthlyReturn * 12
|
||||
return annualized
|
||||
// Annualize compounding: (1 + total_return)^(12/months) - 1.
|
||||
// The previous linear approximation (monthly*12) overstated short histories.
|
||||
let totalReturnD = NSDecimalNumber(decimal: totalReturn).doubleValue
|
||||
guard totalReturnD > -1 else { return 0 }
|
||||
let annualizedD = pow(1 + totalReturnD, 12.0 / Double(monthsBetween)) - 1
|
||||
guard annualizedD.isFinite else { return 0 }
|
||||
return Decimal(annualizedD)
|
||||
}
|
||||
|
||||
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
|
||||
guard !evolutionData.isEmpty else { return 0 }
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Group into (year, month) set
|
||||
let monthSet: Set<DateComponents> = Set(evolutionData.map { point in
|
||||
let comps = calendar.dateComponents([.year, .month], from: point.date)
|
||||
return DateComponents(year: comps.year, month: comps.month)
|
||||
})
|
||||
|
||||
// Get sorted unique months descending
|
||||
let sortedMonths = monthSet
|
||||
.compactMap { comps -> Date? in calendar.date(from: comps) }
|
||||
.sorted(by: >)
|
||||
|
||||
guard let mostRecent = sortedMonths.first else { return 0 }
|
||||
|
||||
var streak = 1
|
||||
var current = mostRecent
|
||||
|
||||
for i in 1..<sortedMonths.count {
|
||||
guard let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
|
||||
let prev = sortedMonths[i]
|
||||
let prevComps = calendar.dateComponents([.year, .month], from: prev)
|
||||
let expectedComps = calendar.dateComponents([.year, .month], from: expected)
|
||||
if prevComps.year == expectedComps.year && prevComps.month == expectedComps.month {
|
||||
streak += 1
|
||||
current = prev
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
// Virtual snapshot for forecast calculations
|
||||
@@ -544,6 +695,83 @@ class DashboardViewModel: ObservableObject {
|
||||
sourcesNeedingUpdate.count
|
||||
}
|
||||
|
||||
/// Total number of missing months across all detected internal gaps.
|
||||
var totalMissingMonths: Int {
|
||||
SnapshotGapDetector.totalMissingMonths(dataGaps)
|
||||
}
|
||||
|
||||
/// Resolves an InvestmentSource for a detected gap so the fill flow can open
|
||||
/// the add-snapshot sheet for it.
|
||||
func source(withId id: UUID) -> InvestmentSource? {
|
||||
sourceRepository.sources.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Builds the data for the shareable monthly summary card from already-computed
|
||||
/// dashboard state. Mood/rating come from the latest completed check-in entry.
|
||||
func monthlySummaryShareData() -> ShareService.MonthlySummaryShareData {
|
||||
let monthLabel: String = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
let date = MonthlyCheckInStore.latestCompletionDate() ?? Date()
|
||||
return formatter.string(from: date)
|
||||
}()
|
||||
|
||||
let latestEntry = MonthlyCheckInStore.latestCompletionDate()
|
||||
.flatMap { MonthlyCheckInStore.entry(for: $0) }
|
||||
|
||||
return ShareService.MonthlySummaryShareData(
|
||||
monthLabel: monthLabel,
|
||||
totalValue: portfolioSummary.formattedTotalValue,
|
||||
changeAmount: latestPortfolioChange.formattedAbsolute,
|
||||
changePercentage: latestPortfolioChange.formattedPercentage,
|
||||
isPositive: latestPortfolioChange.absolute >= 0,
|
||||
streak: updateStreak,
|
||||
mood: latestEntry?.mood,
|
||||
rating: latestEntry?.rating
|
||||
)
|
||||
}
|
||||
|
||||
/// Prioritized, on-device insights. Gathers local inputs and delegates the
|
||||
/// rules to `InsightsEngine` (no network / no AI).
|
||||
var insights: [PortfolioInsight] {
|
||||
let total = portfolioSummary.totalValue
|
||||
guard total > 0 else { return [] }
|
||||
|
||||
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
|
||||
InsightsEngine.CategorySlice(
|
||||
name: cat.categoryName,
|
||||
percentageOfPortfolio: cat.percentageOfPortfolio,
|
||||
targetPercentage: AllocationTargetStore.target(for: cat.id),
|
||||
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
let sources = filteredSources()
|
||||
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
|
||||
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
|
||||
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
|
||||
guard value > 0, totalDouble > 0 else { return nil }
|
||||
return InsightsEngine.SourceSlice(
|
||||
name: source.name,
|
||||
percentageOfPortfolio: value / totalDouble * 100
|
||||
)
|
||||
}
|
||||
|
||||
let input = InsightsEngine.Input(
|
||||
totalValue: total,
|
||||
categories: categorySlices,
|
||||
sources: sourceSlices,
|
||||
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
|
||||
fundedSourceCount: sourceSlices.count,
|
||||
yearChangePercentage: portfolioSummary.yearChangePercentage,
|
||||
allTimeReturn: portfolioSummary.allTimeReturn,
|
||||
updateStreak: updateStreak,
|
||||
forecast: portfolioForecast
|
||||
)
|
||||
|
||||
return InsightsEngine.makeInsights(input)
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
Array(categoryMetrics.prefix(5))
|
||||
}
|
||||
@@ -581,4 +809,15 @@ class DashboardViewModel: ObservableObject {
|
||||
var formattedLastUpdate: String {
|
||||
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
|
||||
}
|
||||
|
||||
/// Absolute last-check-in date (e.g. "7 Aug 2026") for the share card — an
|
||||
/// outsider needs a concrete date, not "2 days ago".
|
||||
var shareAsOfDate: String? {
|
||||
guard let date = portfolioSummary.lastUpdated else { return nil }
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,31 @@ class GoalsViewModel: ObservableObject {
|
||||
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
|
||||
}
|
||||
|
||||
func isAchieved(_ goal: Goal) -> Bool {
|
||||
Self.isAchieved(progress: progress(for: goal))
|
||||
}
|
||||
|
||||
static func isAchieved(progress: Double) -> Bool {
|
||||
progress >= 0.999
|
||||
}
|
||||
|
||||
static func urgencyLevel(
|
||||
targetDate: Date?,
|
||||
isBehind: Bool,
|
||||
isAchieved: Bool,
|
||||
referenceDate: Date = Date()
|
||||
) -> GoalUrgencyLevel {
|
||||
guard let targetDate else { return .normal }
|
||||
guard !isAchieved else { return .normal }
|
||||
guard isBehind else { return .normal }
|
||||
|
||||
let daysUntilTarget = referenceDate.startOfDay.daysBetween(targetDate.startOfDay)
|
||||
if daysUntilTarget < 0 {
|
||||
return .critical
|
||||
}
|
||||
return .warning
|
||||
}
|
||||
|
||||
func totalValue(for goal: Goal) -> Decimal {
|
||||
if let accountId = goal.account?.safeId {
|
||||
return sourceRepository.sources
|
||||
@@ -136,71 +161,67 @@ class GoalsViewModel: ObservableObject {
|
||||
goalRepository.deleteGoal(goal)
|
||||
}
|
||||
|
||||
private func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
// Performance: Use cached completion date if available
|
||||
if let cached = cachedCompletionDates[goal.id] {
|
||||
func archiveGoal(_ goal: Goal) {
|
||||
goalRepository.updateGoal(goal, isActive: !goal.isActive)
|
||||
}
|
||||
|
||||
func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
projection(for: goal).projectedDate
|
||||
}
|
||||
|
||||
/// Full forward projection for a goal, blending recent growth trend with the
|
||||
/// monthly contributions of the relevant sources. Cached per goal.
|
||||
func projection(for goal: Goal) -> GoalProjection.Result {
|
||||
let sources = relevantSources(for: goal)
|
||||
let currentValue = totalValue(for: goal)
|
||||
|
||||
guard !sources.isEmpty else {
|
||||
let cached = GoalProjection.Result(
|
||||
projectedDate: nil, monthsToReach: nil,
|
||||
status: goal.targetDate == nil ? .noTargetDate : .unreachable,
|
||||
monthlyGrowthRate: 0, sparkline: []
|
||||
)
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return cached
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
let evolutionData = evolutionData(for: goal, sources: sources)
|
||||
let rate = GoalProjection.monthlyGrowthRate(from: evolutionData)
|
||||
let contribution = sources.reduce(Decimal.zero) {
|
||||
$0 + ($1.monthlyContribution?.decimalValue ?? .zero)
|
||||
}
|
||||
|
||||
let result = GoalProjection.project(
|
||||
currentValue: currentValue,
|
||||
targetAmount: goal.targetDecimal,
|
||||
monthlyGrowthRate: rate,
|
||||
monthlyContribution: contribution,
|
||||
targetDate: goal.targetDate
|
||||
)
|
||||
cachedCompletionDates[goal.id] = result.projectedDate
|
||||
return result
|
||||
}
|
||||
|
||||
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
|
||||
if let accountId = goal.account?.safeId {
|
||||
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
} else {
|
||||
sources = sourceRepository.sources
|
||||
return sourceRepository.sources.filter { $0.account?.id == accountId }
|
||||
}
|
||||
return sourceRepository.sources
|
||||
}
|
||||
|
||||
private func evolutionData(
|
||||
for goal: Goal,
|
||||
sources: [InvestmentSource]
|
||||
) -> [(date: Date, value: Decimal)] {
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
let sourceIds = sources.compactMap { $0.id }
|
||||
guard !sourceIds.isEmpty else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Performance: Use cached evolution data if available
|
||||
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
let evolutionData: [(date: Date, value: Decimal)]
|
||||
if let cached = cachedEvolutionData[cacheKey] {
|
||||
evolutionData = cached
|
||||
} else {
|
||||
let snapshots = snapshotRepository.fetchSnapshots(
|
||||
for: sourceIds,
|
||||
months: maxHistoryMonths
|
||||
)
|
||||
evolutionData = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = evolutionData
|
||||
}
|
||||
|
||||
guard evolutionData.count >= 3,
|
||||
let first = evolutionData.suffix(6).first,
|
||||
let last = evolutionData.suffix(6).last else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthsBetween = max(1, first.date.monthsBetween(last.date))
|
||||
let delta = last.value - first.value
|
||||
guard delta > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let monthlyGain = delta / Decimal(monthsBetween)
|
||||
guard monthlyGain > 0 else {
|
||||
cachedCompletionDates[goal.id] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentValue = totalValue(for: goal)
|
||||
let remaining = goal.targetDecimal - currentValue
|
||||
guard remaining > 0 else {
|
||||
let result = Date()
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
}
|
||||
|
||||
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
|
||||
let monthsRounded = Int(ceil(months))
|
||||
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
|
||||
cachedCompletionDates[goal.id] = result
|
||||
return result
|
||||
let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths)
|
||||
let data = calculateEvolutionData(from: snapshots)
|
||||
cachedEvolutionData[cacheKey] = data
|
||||
return data
|
||||
}
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
|
||||
@@ -288,3 +309,9 @@ struct GoalPaceStatus {
|
||||
let isBehind: Bool
|
||||
let statusText: String
|
||||
}
|
||||
|
||||
enum GoalUrgencyLevel: Equatable {
|
||||
case normal
|
||||
case warning
|
||||
case critical
|
||||
}
|
||||
|
||||
@@ -89,8 +89,7 @@ class MonthlyCheckInViewModel: ObservableObject {
|
||||
)
|
||||
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
|
||||
|
||||
let now = Date()
|
||||
let targetDate = targetRange.contains(now) ? now : targetRange.end
|
||||
let targetDate = Date()
|
||||
|
||||
for source in sources {
|
||||
let sourceId = source.id
|
||||
|
||||
@@ -20,6 +20,8 @@ class SettingsViewModel: ObservableObject {
|
||||
@Published var showingExportOptions = false
|
||||
@Published var showingImportSheet = false
|
||||
@Published var showingResetConfirmation = false
|
||||
@Published var isBackupInProgress = false
|
||||
@Published var isRestoreInProgress = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var successMessage: String?
|
||||
|
||||
@@ -29,6 +31,14 @@ class SettingsViewModel: ObservableObject {
|
||||
@Published var totalSnapshots = 0
|
||||
@Published var totalCategories = 0
|
||||
|
||||
// MARK: - Backups
|
||||
|
||||
@Published var backupRetentionCount = 10
|
||||
@Published var backups: [BackupRecord] = []
|
||||
@Published var backupsEnabled = false
|
||||
/// Auto-backup cadence in days: 0 = off, 1 = daily, 7 = weekly, 30 = monthly.
|
||||
@Published var autoBackupFrequencyDays = 0
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let iapService: IAPService
|
||||
@@ -37,6 +47,7 @@ class SettingsViewModel: ObservableObject {
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let freemiumValidator: FreemiumValidator
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let backupsEnabledKey = "backupsEnabled"
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -61,7 +72,10 @@ class SettingsViewModel: ObservableObject {
|
||||
private func setupObservers() {
|
||||
iapService.$isPremium
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isPremium)
|
||||
.sink { [weak self] isPremium in
|
||||
self?.handlePremiumChange(isPremium)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
iapService.$isFamilyShared
|
||||
.receive(on: DispatchQueue.main)
|
||||
@@ -82,6 +96,14 @@ class SettingsViewModel: ObservableObject {
|
||||
analyticsEnabled = settings.enableAnalytics
|
||||
currencyCode = settings.currency
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
backupRetentionCount = loadBackupRetention()
|
||||
backupsEnabled = loadBackupsEnabled()
|
||||
autoBackupFrequencyDays = UserDefaults.standard.integer(forKey: "autoBackupFrequencyDays")
|
||||
if backupsEnabled {
|
||||
refreshBackups()
|
||||
} else {
|
||||
backups = []
|
||||
}
|
||||
|
||||
// Load statistics directly from database to avoid async race conditions
|
||||
loadStatistics()
|
||||
@@ -297,7 +319,6 @@ class SettingsViewModel: ObservableObject {
|
||||
"Asset",
|
||||
"Account",
|
||||
"Goal",
|
||||
"Transaction",
|
||||
"PredictionCache"
|
||||
]
|
||||
|
||||
@@ -335,6 +356,7 @@ class SettingsViewModel: ObservableObject {
|
||||
if accountCount == 0 {
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = Account(context: context)
|
||||
account.id = Account.defaultAccountStableID
|
||||
account.name = Account.defaultAccountName
|
||||
account.currency = defaultCurrency
|
||||
account.inputMode = InputMode.simple.rawValue
|
||||
@@ -361,6 +383,98 @@ class SettingsViewModel: ObservableObject {
|
||||
NotificationCenter.default.post(name: .didResetData, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Backups
|
||||
|
||||
func updateBackupRetention(_ count: Int) {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
backupRetentionCount = count
|
||||
UserDefaults.standard.set(count, forKey: "backupRetentionCount")
|
||||
refreshBackups()
|
||||
}
|
||||
|
||||
func refreshBackups() {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||
backups = BackupService.shared.listAllBackups(includeICloud: includeICloud)
|
||||
}
|
||||
|
||||
/// Auto-backup cadence (0 = off). Running one immediately when enabled gives
|
||||
/// instant feedback that it works.
|
||||
func updateAutoBackupFrequency(_ days: Int) {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
autoBackupFrequencyDays = days
|
||||
UserDefaults.standard.set(days, forKey: "autoBackupFrequencyDays")
|
||||
if days > 0 { createBackupNow() }
|
||||
}
|
||||
|
||||
var lastAutoBackupDate: Date? {
|
||||
UserDefaults.standard.object(forKey: "lastAutoBackupDate") as? Date
|
||||
}
|
||||
|
||||
func createBackupNow() {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
guard !isBackupInProgress else { return }
|
||||
isBackupInProgress = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
|
||||
let records = BackupService.shared.createBackup(
|
||||
retentionCount: backupRetentionCount,
|
||||
includeICloud: includeICloud
|
||||
)
|
||||
await MainActor.run {
|
||||
backups = records
|
||||
isBackupInProgress = false
|
||||
successMessage = "Backup saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBackup(_ backup: BackupRecord) {
|
||||
guard backupsEnabled, isPremium else { return }
|
||||
guard !isRestoreInProgress else { return }
|
||||
isRestoreInProgress = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
let content: String
|
||||
do {
|
||||
content = try String(contentsOf: backup.url, encoding: .utf8)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isRestoreInProgress = false
|
||||
errorMessage = "Failed to read backup file."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
resetAllData()
|
||||
}
|
||||
|
||||
let allowMultiple = iapService.isPremium
|
||||
let result = await ImportService.shared.importDataAsync(
|
||||
content: content,
|
||||
format: .json,
|
||||
allowMultipleAccounts: allowMultiple,
|
||||
defaultAccountName: Account.defaultAccountName,
|
||||
progress: { _ in }
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
isRestoreInProgress = false
|
||||
if result.errors.isEmpty {
|
||||
successMessage = "Backup restored"
|
||||
} else {
|
||||
errorMessage = "Backup restored with warnings."
|
||||
}
|
||||
loadSettings()
|
||||
refreshBackups()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var appVersion: String {
|
||||
@@ -405,4 +519,53 @@ class SettingsViewModel: ObservableObject {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBackupRetention() -> Int {
|
||||
let value = UserDefaults.standard.integer(forKey: "backupRetentionCount")
|
||||
let options = [5, 10, 20]
|
||||
return options.contains(value) ? value : 10
|
||||
}
|
||||
|
||||
private func loadBackupsEnabled() -> Bool {
|
||||
let enabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
|
||||
if !isPremium && enabled {
|
||||
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||
return false
|
||||
}
|
||||
return isPremium && enabled
|
||||
}
|
||||
|
||||
private func handlePremiumChange(_ isPremium: Bool) {
|
||||
self.isPremium = isPremium
|
||||
if isPremium {
|
||||
backupsEnabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
|
||||
if backupsEnabled {
|
||||
refreshBackups()
|
||||
}
|
||||
} else {
|
||||
if backupsEnabled {
|
||||
backupsEnabled = false
|
||||
}
|
||||
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||
backups = []
|
||||
}
|
||||
}
|
||||
|
||||
func setBackupsEnabled(_ enabled: Bool) {
|
||||
guard isPremium else {
|
||||
backupsEnabled = false
|
||||
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
|
||||
FirebaseService.shared.logPaywallShown(trigger: "backups")
|
||||
showingPaywall = true
|
||||
return
|
||||
}
|
||||
|
||||
backupsEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: backupsEnabledKey)
|
||||
if enabled {
|
||||
refreshBackups()
|
||||
} else {
|
||||
backups = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
class SnapshotFormViewModel: ObservableObject {
|
||||
@@ -12,9 +13,12 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
@Published var includeContribution = false
|
||||
@Published var inputMode: InputMode = .simple
|
||||
@Published var currencySymbol = "€"
|
||||
private let currencyCode: String
|
||||
|
||||
@Published var isValid = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var clipboardValue: String?
|
||||
private var rawClipboardString: String?
|
||||
|
||||
// MARK: - Mode
|
||||
|
||||
@@ -26,19 +30,31 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
let mode: Mode
|
||||
let source: InvestmentSource
|
||||
|
||||
/// Contribution stored on the snapshot when editing started (nil in add mode or
|
||||
/// when the snapshot had no contribution). Used to detect whether the user changed
|
||||
/// the contribution and should be offered to propagate it to other snapshots.
|
||||
private(set) var originalContribution: Decimal?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(source: InvestmentSource, mode: Mode = .add) {
|
||||
init(source: InvestmentSource, mode: Mode = .add, initialDate: Date? = nil) {
|
||||
self.source = source
|
||||
self.mode = mode
|
||||
if case .add = mode, let initialDate {
|
||||
// Prefill for filling a specific missing month (gap fill flow).
|
||||
// Clamp to today so the date picker's `...Date()` bound holds.
|
||||
date = min(initialDate, Date())
|
||||
}
|
||||
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
|
||||
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
|
||||
currencyCode = accountCurrency
|
||||
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
|
||||
} else {
|
||||
currencyCode = settings.currency
|
||||
currencySymbol = settings.currencySymbol
|
||||
}
|
||||
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
|
||||
@@ -57,6 +73,7 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
if let contribution = snapshot.contribution {
|
||||
includeContribution = true
|
||||
contributionString = formatDecimalForInput(contribution.decimalValue)
|
||||
originalContribution = contribution.decimalValue
|
||||
}
|
||||
notes = snapshot.notes ?? ""
|
||||
}
|
||||
@@ -88,8 +105,11 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
|
||||
// Contribution is optional but must be valid if included
|
||||
if includeContribution {
|
||||
guard let contribution = parseDecimal(contributionString), contribution >= 0 else {
|
||||
return false
|
||||
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
guard let contribution = parseDecimal(trimmed), contribution >= 0 else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,27 +119,11 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
// MARK: - Parsing
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
let cleaned = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
|
||||
return formatter.number(from: cleaned)?.decimalValue
|
||||
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
|
||||
}
|
||||
|
||||
private func formatDecimalForInput(_ decimal: Decimal) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
|
||||
CurrencyFormatter.formatForInput(decimal, currencyCode: currencyCode)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
@@ -130,7 +134,15 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
|
||||
var contribution: Decimal? {
|
||||
guard includeContribution else { return nil }
|
||||
return parseDecimal(contributionString)
|
||||
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return parseDecimal(trimmed)
|
||||
}
|
||||
|
||||
/// True when the current contribution differs from the value the snapshot had when
|
||||
/// editing started. Drives the "propagate to other snapshots" prompt.
|
||||
var contributionChanged: Bool {
|
||||
contribution != originalContribution
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
@@ -207,6 +219,41 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
date = Date()
|
||||
}
|
||||
|
||||
// MARK: - Clipboard
|
||||
|
||||
func checkClipboard() {
|
||||
guard let raw = UIPasteboard.general.string else {
|
||||
clipboardValue = nil
|
||||
rawClipboardString = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let parsed = parseDecimal(raw), parsed > 0 else {
|
||||
clipboardValue = nil
|
||||
rawClipboardString = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Don't suggest if it matches what's already typed
|
||||
let formatted = formatDecimalForInput(parsed)
|
||||
if formatted == valueString {
|
||||
clipboardValue = nil
|
||||
rawClipboardString = nil
|
||||
return
|
||||
}
|
||||
|
||||
rawClipboardString = raw
|
||||
clipboardValue = CurrencyFormatter.format(parsed, style: .currency, maximumFractionDigits: 2)
|
||||
}
|
||||
|
||||
func applyClipboardValue() {
|
||||
guard let raw = rawClipboardString,
|
||||
let parsed = parseDecimal(raw) else { return }
|
||||
valueString = formatDecimalForInput(parsed)
|
||||
clipboardValue = nil
|
||||
rawClipboardString = nil
|
||||
}
|
||||
|
||||
// MARK: - Date Validation
|
||||
|
||||
var isDateInFuture: Bool {
|
||||
|
||||
@@ -11,13 +11,12 @@ class SourceDetailViewModel: ObservableObject {
|
||||
@Published var metrics: InvestmentMetrics = .empty
|
||||
@Published var predictions: [Prediction] = []
|
||||
@Published var predictionResult: PredictionResult?
|
||||
@Published var transactions: [Transaction] = []
|
||||
@Published var isDeleted = false
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingAddSnapshot = false
|
||||
@Published var showingEditSource = false
|
||||
@Published var showingPaywall = false
|
||||
@Published var showingAddTransaction = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
// MARK: - Chart Data
|
||||
@@ -28,7 +27,6 @@ class SourceDetailViewModel: ObservableObject {
|
||||
|
||||
private let snapshotRepository: SnapshotRepository
|
||||
private let sourceRepository: InvestmentSourceRepository
|
||||
private let transactionRepository: TransactionRepository
|
||||
private let calculationService: CalculationService
|
||||
private let predictionEngine: PredictionEngine
|
||||
private let freemiumValidator: FreemiumValidator
|
||||
@@ -37,6 +35,7 @@ class SourceDetailViewModel: ObservableObject {
|
||||
private var isRefreshing = false
|
||||
private var refreshQueued = false
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private let sourceName: String
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -44,15 +43,14 @@ class SourceDetailViewModel: ObservableObject {
|
||||
source: InvestmentSource,
|
||||
snapshotRepository: SnapshotRepository? = nil,
|
||||
sourceRepository: InvestmentSourceRepository? = nil,
|
||||
transactionRepository: TransactionRepository? = nil,
|
||||
calculationService: CalculationService? = nil,
|
||||
predictionEngine: PredictionEngine? = nil,
|
||||
iapService: IAPService
|
||||
) {
|
||||
self.source = source
|
||||
self.sourceName = source.name
|
||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||
self.transactionRepository = transactionRepository ?? TransactionRepository()
|
||||
self.calculationService = calculationService ?? .shared
|
||||
self.predictionEngine = predictionEngine ?? .shared
|
||||
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||
@@ -103,6 +101,11 @@ class SourceDetailViewModel: ObservableObject {
|
||||
|
||||
refreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.isDeleted, !self.source.isDeleted, self.source.managedObjectContext != nil else {
|
||||
self.isDeleted = true
|
||||
self.isRefreshing = false
|
||||
return
|
||||
}
|
||||
while self.refreshQueued && !Task.isCancelled {
|
||||
self.refreshQueued = false
|
||||
|
||||
@@ -139,8 +142,6 @@ class SourceDetailViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Transactions update independently
|
||||
self.transactions = transactionRepository.fetchTransactions(for: source)
|
||||
}
|
||||
self.isRefreshing = false
|
||||
}
|
||||
@@ -183,33 +184,6 @@ class SourceDetailViewModel: ObservableObject {
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// MARK: - Transaction Actions
|
||||
|
||||
func addTransaction(
|
||||
type: TransactionType,
|
||||
date: Date,
|
||||
shares: Decimal?,
|
||||
price: Decimal?,
|
||||
amount: Decimal?,
|
||||
notes: String?
|
||||
) {
|
||||
transactionRepository.createTransaction(
|
||||
source: source,
|
||||
type: type,
|
||||
date: date,
|
||||
shares: shares,
|
||||
price: price,
|
||||
amount: amount,
|
||||
notes: notes
|
||||
)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
func deleteTransaction(_ transaction: Transaction) {
|
||||
transactionRepository.deleteTransaction(transaction)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// MARK: - Source Actions
|
||||
|
||||
func updateSource(
|
||||
@@ -232,6 +206,23 @@ class SourceDetailViewModel: ObservableObject {
|
||||
showingEditSource = false
|
||||
}
|
||||
|
||||
func deleteSource() {
|
||||
guard !isDeleted else { return }
|
||||
// Mark deleted first so the view can dismiss before any further access.
|
||||
isDeleted = true
|
||||
snapshots = []
|
||||
chartData = []
|
||||
predictionResult = nil
|
||||
// Cancel any pending notifications for this source
|
||||
NotificationService.shared.cancelReminder(for: source)
|
||||
|
||||
// Log analytics before deletion
|
||||
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
|
||||
|
||||
// Delete the source using the existing repository
|
||||
sourceRepository.deleteSource(source)
|
||||
}
|
||||
|
||||
// MARK: - Predictions
|
||||
|
||||
func showPredictions() {
|
||||
@@ -256,6 +247,13 @@ class SourceDetailViewModel: ObservableObject {
|
||||
currentValue.currencyString
|
||||
}
|
||||
|
||||
var safeSourceName: String {
|
||||
if source.isDeleted || source.managedObjectContext == nil {
|
||||
return sourceName
|
||||
}
|
||||
return source.name
|
||||
}
|
||||
|
||||
var totalReturn: Decimal {
|
||||
metrics.absoluteReturn
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class SourceListViewModel: ObservableObject {
|
||||
|
||||
@Published var sources: [InvestmentSource] = []
|
||||
@Published var categories: [Category] = []
|
||||
@Published var selectedCategory: Category?
|
||||
@Published var selectedCategoryIds: Set<UUID> = []
|
||||
@Published var searchText = ""
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@@ -55,7 +55,7 @@ class SourceListViewModel: ObservableObject {
|
||||
Publishers.CombineLatest4(
|
||||
sourceRepository.$sources,
|
||||
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
|
||||
$selectedCategory,
|
||||
$selectedCategoryIds,
|
||||
$selectedAccount
|
||||
)
|
||||
.combineLatest($showAllAccounts)
|
||||
@@ -88,9 +88,12 @@ class SourceListViewModel: ObservableObject {
|
||||
filtered = filtered.filter { $0.account?.id == selectedAccountId }
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if let category = selectedCategory {
|
||||
filtered = filtered.filter { $0.category?.id == category.id }
|
||||
// Filter by category (multi-select)
|
||||
if !selectedCategoryIds.isEmpty {
|
||||
filtered = filtered.filter {
|
||||
guard let id = $0.category?.id else { return false }
|
||||
return selectedCategoryIds.contains(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
@@ -195,26 +198,30 @@ class SourceListViewModel: ObservableObject {
|
||||
totalValue.currencyString
|
||||
}
|
||||
|
||||
var sourcesByCategory: [Category: [InvestmentSource]] {
|
||||
Dictionary(grouping: sources) { $0.category ?? categories.first! }
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
|
||||
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
|
||||
}
|
||||
|
||||
var isFiltered: Bool {
|
||||
!searchText.isEmpty || selectedCategory != nil
|
||||
!searchText.isEmpty || !selectedCategoryIds.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
func selectCategory(_ category: Category?) {
|
||||
selectedCategory = category
|
||||
guard let category else {
|
||||
selectedCategoryIds = []
|
||||
return
|
||||
}
|
||||
if selectedCategoryIds.contains(category.id) {
|
||||
selectedCategoryIds.remove(category.id)
|
||||
} else {
|
||||
selectedCategoryIds.insert(category.id)
|
||||
}
|
||||
}
|
||||
|
||||
func clearFilters() {
|
||||
searchText = ""
|
||||
selectedCategory = nil
|
||||
selectedCategoryIds = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,18 @@ import Charts
|
||||
|
||||
struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
var title: String = "Asset Allocation"
|
||||
var showsTargetsComparison: Bool = true
|
||||
/// Drill-down: tapping the chevron on a legend row jumps to Evolution
|
||||
/// filtered to that category/source.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private var chartHeight: CGFloat {
|
||||
horizontalSizeClass == .regular ? 190 : 130
|
||||
}
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
@@ -12,93 +22,74 @@ struct AllocationPieChart: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Asset Allocation")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// Pie Chart
|
||||
Chart(data, id: \.category) { item in
|
||||
SectorMark(
|
||||
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
innerRadius: .ratio(0.6),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(width: 180, height: 180)
|
||||
.overlay {
|
||||
// Center content
|
||||
VStack(spacing: 2) {
|
||||
if let selected = selectedSlice,
|
||||
let item = data.first(where: { $0.category == selected }) {
|
||||
Text(selected)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.headline)
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(total.compactCurrencyString)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
// Simple tap detection
|
||||
}
|
||||
HStack(alignment: .center, spacing: 24) {
|
||||
// Hemicycle (parliament-style) allocation gauge — fills its
|
||||
// half of the row so it scales up on iPad instead of sitting
|
||||
// tiny in a corner.
|
||||
SemicircleAllocation(
|
||||
data: data,
|
||||
total: total,
|
||||
selectedSlice: $selectedSlice
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: chartHeight)
|
||||
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(data, id: \.category) { item in
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 11, height: 11)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
if showsTargetsComparison {
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
}
|
||||
} else {
|
||||
Text("No allocation data available")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -113,6 +104,116 @@ struct AllocationPieChart: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Semicircle (hemicycle) allocation gauge
|
||||
|
||||
/// Parliament-style half-donut: segments sweep the top 180°, sized by share.
|
||||
struct SemicircleAllocation: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
let total: Decimal
|
||||
@Binding var selectedSlice: String?
|
||||
|
||||
/// Cumulative start/end fractions [0,1] for each segment across the 180° arc.
|
||||
private var segments: [(category: String, start: Double, end: Double, color: Color)] {
|
||||
guard total > 0 else { return [] }
|
||||
var acc = 0.0
|
||||
return data.map { item in
|
||||
let frac = NSDecimalNumber(decimal: item.value / total).doubleValue
|
||||
let start = acc
|
||||
acc += frac
|
||||
return (item.category, start, min(acc, 1), Color(hex: item.color) ?? .gray)
|
||||
}
|
||||
}
|
||||
|
||||
private var centerItem: (label: String, value: String, sub: String?)? {
|
||||
if let selected = selectedSlice, let item = data.first(where: { $0.category == selected }) {
|
||||
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
|
||||
return (selected, item.value.compactCurrencyString, String(format: "%.1f%%", pct))
|
||||
}
|
||||
return (String(localized: "allocation_total"), total.compactCurrencyString, nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
// The semicircle is 2:1 (width:height). Fit it to whichever
|
||||
// dimension binds so it scales up on iPad without clipping.
|
||||
let w = min(geo.size.width, geo.size.height * 2)
|
||||
let lineWidth = w * 0.17
|
||||
let radius = (w - lineWidth) / 2
|
||||
let center = CGPoint(x: geo.size.width / 2, y: (geo.size.height + w / 2) / 2)
|
||||
|
||||
ZStack {
|
||||
// Track
|
||||
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius, center: center)
|
||||
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
|
||||
ForEach(segments, id: \.category) { seg in
|
||||
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center, inset: 0.006)
|
||||
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center).stroke(style: StrokeStyle(lineWidth: lineWidth)))
|
||||
.onTapGesture {
|
||||
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
|
||||
}
|
||||
}
|
||||
|
||||
// Percentage labels on the arc band (only where the slice is wide
|
||||
// enough to fit the text).
|
||||
ForEach(segments, id: \.category) { seg in
|
||||
let frac = seg.end - seg.start
|
||||
if frac >= 0.07 {
|
||||
let mid = (seg.start + seg.end) / 2
|
||||
let angle = (180 + mid * 180) * .pi / 180
|
||||
let pos = CGPoint(x: center.x + radius * CoreGraphics.cos(angle),
|
||||
y: center.y + radius * CoreGraphics.sin(angle))
|
||||
Text("\(Int((frac * 100).rounded()))%")
|
||||
.font(.system(size: lineWidth * 0.42, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.position(pos)
|
||||
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
// Center readout, sitting just under the arc
|
||||
if let c = centerItem {
|
||||
VStack(spacing: 1) {
|
||||
Text(c.label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(c.value)
|
||||
.font(.headline)
|
||||
if let sub = c.sub {
|
||||
Text(sub)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.position(x: center.x, y: center.y - lineWidth * 0.25)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc along the TOP semicircle, parameterized by fraction [0,1] of the 180°
|
||||
/// sweep (0 = left/9 o'clock, 1 = right/3 o'clock, passing through the top).
|
||||
struct SemicircleArc: Shape {
|
||||
let startFraction: Double
|
||||
let endFraction: Double
|
||||
let radius: CGFloat
|
||||
var center: CGPoint? = nil
|
||||
var inset: Double = 0
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let c = center ?? CGPoint(x: rect.width / 2, y: rect.width / 2)
|
||||
let start = Angle.degrees(180 + (startFraction + inset) * 180)
|
||||
let end = Angle.degrees(180 + (endFraction - inset) * 180)
|
||||
var p = Path()
|
||||
p.addArc(center: c, radius: radius, startAngle: start, endAngle: end, clockwise: false)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation Targets Comparison
|
||||
|
||||
struct AllocationTargetsComparisonChart: View {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct AllocationSimulatorView: View {
|
||||
@Environment(\.chartImageExport) private var chartImageExport
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Allocation Simulator")
|
||||
.font(.headline)
|
||||
|
||||
if viewModel.simulatorSources.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
allocationCard
|
||||
chartSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No sources available")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 300)
|
||||
}
|
||||
|
||||
// MARK: - Allocation Card
|
||||
|
||||
private var allocationCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Adjust Allocation")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
totalAllocationBadge
|
||||
resetButton
|
||||
}
|
||||
|
||||
ForEach(Array(viewModel.simulatorSources.enumerated()), id: \.element.id) { index, source in
|
||||
sourceSliderRow(index: index, source: source)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private var totalAllocationBadge: some View {
|
||||
let total = viewModel.simulatorSources.reduce(0) { $0 + $1.simulatedPct }
|
||||
let isNear100 = abs(total - 100) < 5
|
||||
return Text(String(format: "%.0f%%", total))
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
|
||||
.foregroundColor(isNear100 ? .green : .orange)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var resetButton: some View {
|
||||
Button {
|
||||
var updated = viewModel.simulatorSources
|
||||
for i in updated.indices {
|
||||
updated[i] = ChartsViewModel.SimulatorSource(
|
||||
id: updated[i].id,
|
||||
name: updated[i].name,
|
||||
currentPct: updated[i].currentPct,
|
||||
simulatedPct: updated[i].currentPct,
|
||||
colorHex: updated[i].colorHex
|
||||
)
|
||||
}
|
||||
viewModel.simulatorSources = updated
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func sourceSliderRow(index: Int, source: ChartsViewModel.SimulatorSource) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: source.colorHex) ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(source.name)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", source.simulatedPct))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
Text(String(format: "(%.0f%%)", source.currentPct))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Slider(
|
||||
value: Binding<Double>(
|
||||
get: { source.simulatedPct },
|
||||
set: { newValue in
|
||||
var updated = viewModel.simulatorSources
|
||||
if index < updated.count {
|
||||
updated[index] = ChartsViewModel.SimulatorSource(
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
currentPct: source.currentPct,
|
||||
simulatedPct: newValue,
|
||||
colorHex: source.colorHex
|
||||
)
|
||||
viewModel.simulatorSources = updated
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0...100,
|
||||
step: 1
|
||||
)
|
||||
.tint(Color(hex: source.colorHex) ?? .appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Section
|
||||
|
||||
private var chartSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
|
||||
Text("Not enough data to simulate.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 220)
|
||||
} else {
|
||||
simulationChart
|
||||
chartLegend
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var simulationChart: some View {
|
||||
Chart {
|
||||
ForEach(viewModel.simulatorActualData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", item.value),
|
||||
series: .value("Series", "Actual")
|
||||
)
|
||||
.foregroundStyle(Color.blue)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
|
||||
ForEach(viewModel.simulatorData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", item.value),
|
||||
series: .value("Series", "Simulated")
|
||||
)
|
||||
.foregroundStyle(Color.orange)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 3]))
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let d = value.as(Double.self) {
|
||||
Text(String(format: "%.0f", d))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
}
|
||||
|
||||
private var chartLegend: some View {
|
||||
HStack(spacing: 20) {
|
||||
legendItem(color: .blue, label: "Actual", dashed: false)
|
||||
legendItem(color: .orange, label: "Simulated", dashed: true)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func legendItem(color: Color, label: String, dashed: Bool) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
if dashed {
|
||||
HStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 3)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 3)
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 20, height: 3)
|
||||
}
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var xAxisStride: Int {
|
||||
let count = max(viewModel.simulatorActualData.count, viewModel.simulatorData.count)
|
||||
switch count {
|
||||
case ...6: return 1
|
||||
case ...12: return 2
|
||||
case ...24: return 3
|
||||
default: return 6
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Stat definition
|
||||
struct ChartStat {
|
||||
let label: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - Stats summary row (horizontal scroll of chips)
|
||||
struct ChartStatsRow: View {
|
||||
let stats: [ChartStat]
|
||||
/// On regular width the charts container surfaces these stats in the KPI
|
||||
/// header above the chart — hide the in-card duplicate row. Charts without a
|
||||
/// header equivalent (e.g. Year vs Year) opt back in.
|
||||
var showsOnRegularWidth = false
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
if horizontalSizeClass != .regular || showsOnRegularWidth {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(stats.indices, id: \.self) { i in
|
||||
VStack(alignment: .center, spacing: 3) {
|
||||
Text(stats[i].label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(stats[i].value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(stats[i].color)
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data table row model
|
||||
struct ChartDataTableRow: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let value: String
|
||||
let deltaPrev: String?
|
||||
let deltaFirst: String?
|
||||
let isPrevPositive: Bool
|
||||
let isFirstPositive: Bool
|
||||
}
|
||||
|
||||
// MARK: - Expandable data table
|
||||
struct ChartDataTable: View {
|
||||
let rows: [ChartDataTableRow]
|
||||
let valueHeader: String
|
||||
let deltaPrevHeader: String?
|
||||
let deltaFirstHeader: String?
|
||||
|
||||
@State private var isExpanded = false
|
||||
private let previewCount = 5
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tableHeader
|
||||
Divider()
|
||||
let displayed = isExpanded ? rows : Array(rows.suffix(previewCount))
|
||||
ForEach(displayed) { row in
|
||||
tableDataRow(row)
|
||||
if row.id != displayed.last?.id {
|
||||
Divider().opacity(0.35)
|
||||
}
|
||||
}
|
||||
if rows.count > previewCount {
|
||||
Divider().opacity(0.35)
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(isExpanded ? "Show less" : "Show all \(rows.count)")
|
||||
.font(.caption)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.appPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private var tableHeader: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
if let h = deltaPrevHeader {
|
||||
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
if let h = deltaFirstHeader {
|
||||
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func tableDataRow(_ row: ChartDataTableRow) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(row.label).font(.caption2).foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(row.value).font(.caption2.weight(.medium))
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
if let dp = row.deltaPrev, deltaPrevHeader != nil {
|
||||
Text(dp).font(.caption2.weight(.medium))
|
||||
.foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
if let df = row.deltaFirst, deltaFirstHeader != nil {
|
||||
Text(df).font(.caption2.weight(.medium))
|
||||
.foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed)
|
||||
.frame(width: 62, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact date formatter
|
||||
private let tableMonthFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMMy")
|
||||
return f
|
||||
}()
|
||||
|
||||
func chartTableDateLabel(_ date: Date) -> String {
|
||||
tableMonthFormatter.string(from: date)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
// MARK: - Point value labels for line charts
|
||||
//
|
||||
// Shared pieces for the "points + value labels" behavior across every line chart
|
||||
// in the Charts tab: permanent labels when the series is short (they carry a lot
|
||||
// of value at a glance), and a tap/drag selection bubble when it's dense.
|
||||
|
||||
enum ChartLabels {
|
||||
/// A series with at most this many points shows its value labels permanently.
|
||||
static let alwaysShowThreshold = 12
|
||||
|
||||
/// Max permanent labels to render before thinning — fewer on the narrow
|
||||
/// iPhone screen where crammed labels overlap and become unreadable.
|
||||
static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 }
|
||||
|
||||
/// Stride between labelled points so at most `maxLabels` show.
|
||||
static func stride(count: Int, compact: Bool) -> Int {
|
||||
let maxN = maxLabels(compact: compact)
|
||||
guard count > maxN else { return 1 }
|
||||
return Int(ceil(Double(count) / Double(maxN)))
|
||||
}
|
||||
|
||||
/// Whether point `index` should carry a permanent label: always the first
|
||||
/// and last (the endpoints matter most), plus every `stride`-th in between.
|
||||
/// On iPhone (compact) permanent labels collide with the axis on the narrow
|
||||
/// screen, so only the endpoint keeps its label — exact values live in the
|
||||
/// scrub header instead.
|
||||
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
|
||||
guard count > 0 else { return false }
|
||||
if compact {
|
||||
return count <= 6 ? true : index == count - 1
|
||||
}
|
||||
if index == 0 || index == count - 1 { return true }
|
||||
let s = stride(count: count, compact: compact)
|
||||
// Avoid a label landing right next to the forced last one.
|
||||
if index >= count - 1 - (s / 2) { return false }
|
||||
return index % s == 0
|
||||
}
|
||||
|
||||
static func compactCurrency(_ value: Decimal) -> String {
|
||||
value.compactCurrencyString
|
||||
}
|
||||
|
||||
static func percent(_ value: Double, decimals: Int = 1) -> String {
|
||||
String(format: "%+.\(decimals)f%%", value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Small capsule bubble used both for permanent point labels and the selection popover.
|
||||
struct ChartValueBubble: View {
|
||||
let text: String
|
||||
var color: Color = .appPrimary
|
||||
var prominent: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold))
|
||||
.foregroundColor(prominent ? .white : color)
|
||||
.padding(.horizontal, prominent ? 8 : 5)
|
||||
.padding(.vertical, prominent ? 4 : 2)
|
||||
.background(
|
||||
Capsule().fill(prominent ? color : color.opacity(0.12))
|
||||
)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-line selection card (one row per series) used by multi-series charts.
|
||||
struct ChartSelectionCard: View {
|
||||
let title: String
|
||||
let rows: [(label: String, value: String, color: Color)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(row.color).frame(width: 6, height: 6)
|
||||
Text(row.label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(row.value)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import UIKit
|
||||
|
||||
// MARK: - Time-series chart zoom (window model)
|
||||
//
|
||||
// Shared VISUAL zoom for every chart with a Date X axis: pinch anchored on the
|
||||
// window center, native horizontal pan once zoomed, double-tap to reset.
|
||||
//
|
||||
// The DATA range is a separate, global concern: the range brush
|
||||
// (`ChartRangeBrush`) lives in the period bar and drives
|
||||
// `ChartsViewModel.customRange`, filtering every chart at once. Keeping it out
|
||||
// of the chart content also keeps it out of the chart-swipe gesture's reach.
|
||||
|
||||
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
|
||||
struct ChartZoomModel {
|
||||
/// Visible X-domain length in seconds; nil = show everything.
|
||||
var spanSeconds: TimeInterval?
|
||||
/// Start of the visible window (chart scroll position).
|
||||
var startX: Date = .distantPast
|
||||
/// (span, center) captured when a pinch begins.
|
||||
var pinchAnchor: (span: TimeInterval, center: Date)?
|
||||
|
||||
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
|
||||
/// Padding after the last point so it isn't clipped at the window edge.
|
||||
static let endPadding: TimeInterval = 60 * 60 * 24 * 15 // half month
|
||||
|
||||
init() {}
|
||||
|
||||
var isZoomed: Bool { spanSeconds != nil }
|
||||
|
||||
/// The currently visible window, given the full data domain.
|
||||
func visibleWindow(dates: [Date]) -> ClosedRange<Date>? {
|
||||
guard let first = dates.first, let last = dates.last else { return nil }
|
||||
guard let span = spanSeconds else { return first...last }
|
||||
let start = max(first, min(startX, last))
|
||||
let end = min(last, start.addingTimeInterval(span))
|
||||
return start <= end ? start...end : first...last
|
||||
}
|
||||
|
||||
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
|
||||
let upper = max(fullSpan + Self.endPadding, Self.minSpan)
|
||||
let clamped = min(max(span, Self.minSpan), upper)
|
||||
// Snap back to fit-all when zoomed (almost) fully out.
|
||||
return clamped >= upper * 0.98 ? nil : clamped
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
spanSeconds = nil
|
||||
pinchAnchor = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
enum ChartHaptics {
|
||||
private static let selection = UISelectionFeedbackGenerator()
|
||||
private static let impact = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
static func tick() { selection.selectionChanged() }
|
||||
static func bump() { impact.impactOccurred() }
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Applies zoomable window behavior to a Chart with a Date X axis.
|
||||
@ViewBuilder
|
||||
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
|
||||
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ZoomableTimeSeriesModifier: ViewModifier {
|
||||
let dates: [Date]
|
||||
@Binding var zoom: ChartZoomModel
|
||||
|
||||
private var fullSpan: TimeInterval {
|
||||
guard let min = dates.min(), let max = dates.max(), max > min else {
|
||||
return ChartZoomModel.minSpan
|
||||
}
|
||||
return max.timeIntervalSince(min)
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Group {
|
||||
if let span = zoom.spanSeconds {
|
||||
content
|
||||
.chartScrollableAxes(.horizontal)
|
||||
.chartXVisibleDomain(length: span)
|
||||
.chartScrollPosition(x: $zoom.startX)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(pinchGesture)
|
||||
.simultaneousGesture(doubleTapGesture)
|
||||
}
|
||||
|
||||
// Pinch anchored on the window center: the point you're looking at stays put.
|
||||
private var pinchGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
var model = zoom
|
||||
if model.pinchAnchor == nil {
|
||||
let window = model.visibleWindow(dates: dates)
|
||||
let span = model.spanSeconds ?? (fullSpan + ChartZoomModel.endPadding)
|
||||
let center = window.map {
|
||||
$0.lowerBound.addingTimeInterval($0.upperBound.timeIntervalSince($0.lowerBound) / 2)
|
||||
} ?? Date()
|
||||
model.pinchAnchor = (span, center)
|
||||
}
|
||||
guard let anchor = model.pinchAnchor, value.magnification > 0 else { return }
|
||||
let newSpan = model.clamped(anchor.span / value.magnification, fullSpan: fullSpan)
|
||||
model.spanSeconds = newSpan
|
||||
if let newSpan, let first = dates.first, let last = dates.last {
|
||||
let start = anchor.center.addingTimeInterval(-newSpan / 2)
|
||||
let maxStart = last.addingTimeInterval(-newSpan + ChartZoomModel.endPadding)
|
||||
model.startX = max(first, min(start, maxStart))
|
||||
}
|
||||
zoom = model
|
||||
}
|
||||
.onEnded { _ in
|
||||
zoom.pinchAnchor = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var doubleTapGesture: some Gesture {
|
||||
TapGesture(count: 2)
|
||||
.onEnded {
|
||||
guard zoom.isZoomed else { return }
|
||||
withAnimation(.snappy) { zoom.reset() }
|
||||
ChartHaptics.bump()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Range brush (full-history sparkline + month-snapping handles)
|
||||
//
|
||||
// Index-based over the months that actually have data. Bound to a
|
||||
// ClosedRange<Int> selection owned by the caller (the charts container maps it
|
||||
// to ChartsViewModel.customRange). Selecting everything = "no filter".
|
||||
|
||||
struct ChartRangeBrush: View {
|
||||
let dates: [Date]
|
||||
let values: [Double]
|
||||
@Binding var selection: ClosedRange<Int>
|
||||
|
||||
@State private var dragStartIndices: (lo: Int, hi: Int)?
|
||||
@State private var dragTarget: DragTarget?
|
||||
@State private var lastTickedIndex: Int = -1
|
||||
@State private var showingPickers = false
|
||||
|
||||
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
|
||||
/// One unified gesture instead of per-element gestures: with a narrow
|
||||
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
|
||||
/// window and moving it was impossible.
|
||||
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
|
||||
|
||||
private static let labelFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
private var clampedSelection: (lo: Int, hi: Int) {
|
||||
guard !dates.isEmpty else { return (0, 0) }
|
||||
let lo = max(0, min(selection.lowerBound, dates.count - 1))
|
||||
let hi = max(lo, min(selection.upperBound, dates.count - 1))
|
||||
return (lo, hi)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
rangeLabel
|
||||
GeometryReader { geo in
|
||||
brushBody(width: geo.size.width)
|
||||
}
|
||||
.frame(height: 44)
|
||||
}
|
||||
.sheet(isPresented: $showingPickers) {
|
||||
ChartRangePickerSheet(dates: dates, selection: $selection)
|
||||
.presentationDetents([.height(320)])
|
||||
}
|
||||
}
|
||||
|
||||
private var rangeLabel: some View {
|
||||
let (lo, hi) = clampedSelection
|
||||
return Button {
|
||||
showingPickers = true
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Text("\(Self.labelFormatter.string(from: dates[lo])) – \(Self.labelFormatter.string(from: dates[hi]))")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func brushBody(width: CGFloat) -> some View {
|
||||
let (lo, hi) = clampedSelection
|
||||
let xLo = xPosition(index: lo, width: width)
|
||||
let xHi = xPosition(index: hi, width: width)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
// Full-history sparkline (widget-style: gradient line, no axes)
|
||||
SparklinePath(values: values)
|
||||
.stroke(
|
||||
LinearGradient(colors: [.appPrimary, .cyan],
|
||||
startPoint: .leading, endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
.opacity(0.85)
|
||||
|
||||
// Dimming outside the window
|
||||
Rectangle()
|
||||
.fill(Color(.systemBackground).opacity(0.72))
|
||||
.frame(width: max(0, xLo))
|
||||
Rectangle()
|
||||
.fill(Color(.systemBackground).opacity(0.72))
|
||||
.frame(width: max(0, width - xHi))
|
||||
.offset(x: xHi)
|
||||
|
||||
// Window frame
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6).fill(Color.appPrimary.opacity(0.06))
|
||||
)
|
||||
.frame(width: max(10, xHi - xLo))
|
||||
.offset(x: xLo)
|
||||
|
||||
// Handles (visual only — hit-testing lives in the unified gesture)
|
||||
handle(at: xLo)
|
||||
handle(at: xHi)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(unifiedGesture(width: width))
|
||||
}
|
||||
|
||||
private func handle(at x: CGFloat) -> some View {
|
||||
Capsule()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 5, height: 26)
|
||||
.overlay(
|
||||
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
|
||||
)
|
||||
.frame(height: 44)
|
||||
.offset(x: x - 2.5)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// MARK: Geometry helpers
|
||||
|
||||
private func xPosition(index: Int, width: CGFloat) -> CGFloat {
|
||||
guard dates.count > 1 else { return 0 }
|
||||
return width * CGFloat(index) / CGFloat(dates.count - 1)
|
||||
}
|
||||
|
||||
private func index(atX x: CGFloat, width: CGFloat) -> Int {
|
||||
guard dates.count > 1, width > 0 else { return 0 }
|
||||
let frac = max(0, min(1, x / width))
|
||||
return Int((frac * CGFloat(dates.count - 1)).rounded())
|
||||
}
|
||||
|
||||
// MARK: Unified gesture
|
||||
//
|
||||
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
|
||||
// anywhere else (inside the window OR on the dimmed history) slides the
|
||||
// whole window keeping its width — select Jan–Mar, then swipe to land on
|
||||
// Jun–Aug. Narrow windows stay movable because the edge grip only wins in
|
||||
// its 18pt zone (from outside when the window is too narrow to share).
|
||||
|
||||
private func unifiedGesture(width: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let (lo, hi) = dragStartIndices ?? clampedSelection
|
||||
if dragStartIndices == nil {
|
||||
dragStartIndices = (lo, hi)
|
||||
dragTarget = target(
|
||||
forX: value.startLocation.x,
|
||||
xLo: xPosition(index: lo, width: width),
|
||||
xHi: xPosition(index: hi, width: width)
|
||||
)
|
||||
}
|
||||
guard let target = dragTarget else { return }
|
||||
var newLo = lo
|
||||
var newHi = hi
|
||||
switch target {
|
||||
case .lowerHandle:
|
||||
newLo = min(index(atX: value.location.x, width: width), hi)
|
||||
case .upperHandle:
|
||||
newHi = max(index(atX: value.location.x, width: width), lo)
|
||||
case .moveWindow:
|
||||
let span = hi - lo
|
||||
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
|
||||
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
|
||||
newHi = newLo + span
|
||||
}
|
||||
let ticked = (target == .upperHandle) ? newHi : newLo
|
||||
if ticked != lastTickedIndex {
|
||||
lastTickedIndex = ticked
|
||||
ChartHaptics.tick()
|
||||
}
|
||||
selection = newLo...newHi
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragStartIndices = nil
|
||||
dragTarget = nil
|
||||
lastTickedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
|
||||
let grip: CGFloat = 18
|
||||
let windowWidth = xHi - xLo
|
||||
if windowWidth >= grip * 3 {
|
||||
// Wide window: edges grab from either side of the handle.
|
||||
if abs(x - xLo) <= grip { return .lowerHandle }
|
||||
if abs(x - xHi) <= grip { return .upperHandle }
|
||||
} else {
|
||||
// Narrow window: the inside belongs to MOVE; edges only from outside.
|
||||
if x < xLo && xLo - x <= grip { return .lowerHandle }
|
||||
if x > xHi && x - xHi <= grip { return .upperHandle }
|
||||
}
|
||||
return .moveWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal normalized line path for the brush background.
|
||||
private struct SparklinePath: Shape {
|
||||
let values: [Double]
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
guard values.count > 1 else { return path }
|
||||
let minV = values.min() ?? 0
|
||||
let maxV = values.max() ?? 1
|
||||
let range = maxV - minV
|
||||
let inset: CGFloat = 4
|
||||
func point(_ i: Int) -> CGPoint {
|
||||
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
|
||||
let norm = range > 0 ? (values[i] - minV) / range : 0.5
|
||||
let y = inset + (rect.height - inset * 2) * (1 - CGFloat(norm))
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
path.move(to: point(0))
|
||||
for i in 1..<values.count {
|
||||
path.addLine(to: point(i))
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Precise month/year pickers
|
||||
|
||||
/// Two wheel pickers over the months that actually have data — the precise,
|
||||
/// non-gesture way to land exactly on e.g. Feb–Mar 2025.
|
||||
struct ChartRangePickerSheet: View {
|
||||
let dates: [Date]
|
||||
@Binding var selection: ClosedRange<Int>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var fromIndex = 0
|
||||
@State private var toIndex = 0
|
||||
|
||||
private static let optionFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = .autoupdatingCurrent
|
||||
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
HStack(spacing: 0) {
|
||||
picker(title: String(localized: "chart_range_from"), selection: $fromIndex)
|
||||
picker(title: String(localized: "chart_range_to"), selection: $toIndex)
|
||||
}
|
||||
.navigationTitle(String(localized: "chart_range_button"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("OK") {
|
||||
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
|
||||
ChartHaptics.bump()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
fromIndex = max(0, min(selection.lowerBound, dates.count - 1))
|
||||
toIndex = max(fromIndex, min(selection.upperBound, dates.count - 1))
|
||||
}
|
||||
.onChange(of: fromIndex) { _, new in
|
||||
if new > toIndex { toIndex = new }
|
||||
}
|
||||
.onChange(of: toIndex) { _, new in
|
||||
if new < fromIndex { fromIndex = new }
|
||||
}
|
||||
}
|
||||
|
||||
private func picker(title: String, selection: Binding<Int>) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(dates.indices, id: \.self) { i in
|
||||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct ComparisonChartView: View {
|
||||
@Environment(\.chartImageExport) private var chartImageExport
|
||||
@State private var selectedDate: Date?
|
||||
|
||||
private static let selectionDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM yyyy"
|
||||
return f
|
||||
}()
|
||||
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Compare Sources")
|
||||
.font(.headline)
|
||||
|
||||
sourceSelector
|
||||
|
||||
Picker("", selection: $viewModel.comparisonDisplayMode) {
|
||||
ForEach(ChartsViewModel.ComparisonDisplayMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
chartContent
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Source Selector
|
||||
|
||||
private var sourceSelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in
|
||||
let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id)
|
||||
let chipColor = Color(hex: ChartsViewModel.sourceColorHexesPublic[index % ChartsViewModel.sourceColorHexesPublic.count]) ?? .appPrimary
|
||||
Button {
|
||||
if isSelected {
|
||||
viewModel.comparisonSelectedSourceIds.remove(source.id)
|
||||
} else {
|
||||
viewModel.comparisonSelectedSourceIds.insert(source.id)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(chipColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(source.name)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(isSelected ? chipColor.opacity(0.15) : Color.gray.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Content
|
||||
|
||||
@ViewBuilder
|
||||
private var chartContent: some View {
|
||||
if viewModel.comparisonSelectedSourceIds.isEmpty {
|
||||
emptySelectionView
|
||||
} else if viewModel.comparisonData.isEmpty {
|
||||
noDataView
|
||||
} else {
|
||||
lineChart
|
||||
}
|
||||
}
|
||||
|
||||
private var emptySelectionView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "chart.xyaxis.line")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Select 2+ sources to compare")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
}
|
||||
|
||||
private var noDataView: some View {
|
||||
Text("No data available for selected sources in this period.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
}
|
||||
|
||||
/// Multi-series comparison: labelling every point of every series overlaps
|
||||
/// badly. Instead we label only each series' END point (where it landed),
|
||||
/// which is the value the comparison is actually about.
|
||||
|
||||
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
|
||||
viewModel.comparisonData.compactMap { series in
|
||||
guard let point = series.points.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) else { return nil }
|
||||
return (series.name, yAxisLabel(for: point.value), Color(hex: series.colorHex) ?? .appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private var lineChart: some View {
|
||||
VStack(spacing: 12) {
|
||||
Chart {
|
||||
ForEach(viewModel.comparisonData, id: \.id) { series in
|
||||
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
|
||||
let lastDate = series.points.last?.date
|
||||
ForEach(series.points, id: \.date) { point in
|
||||
LineMark(
|
||||
x: .value("Date", point.date),
|
||||
y: .value("Value", point.value),
|
||||
series: .value("Source", series.name)
|
||||
)
|
||||
.foregroundStyle(seriesColor)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", point.date),
|
||||
y: .value("Value", point.value)
|
||||
)
|
||||
.foregroundStyle(seriesColor)
|
||||
.symbolSize(point.date == lastDate ? 30 : 14)
|
||||
.annotation(position: .trailing, spacing: 2) {
|
||||
if point.date == lastDate {
|
||||
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let selectedDate {
|
||||
RuleMark(x: .value("Selected", selectedDate))
|
||||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
.annotation(position: .top, spacing: 4) {
|
||||
ChartSelectionCard(
|
||||
title: Self.selectionDateFormatter.string(from: selectedDate),
|
||||
rows: selectionRows(for: selectedDate)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXSelection(value: $selectedDate)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let d = value.as(Double.self) {
|
||||
Text(yAxisLabel(for: d))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
|
||||
legend
|
||||
}
|
||||
}
|
||||
|
||||
private var legend: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(viewModel.comparisonData, id: \.id) { series in
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(hex: series.colorHex) ?? .appPrimary)
|
||||
.frame(width: 20, height: 3)
|
||||
Text(series.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var xAxisStride: Int {
|
||||
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
|
||||
switch maxPoints {
|
||||
case ...6: return 1
|
||||
case ...12: return 2
|
||||
case ...24: return 3
|
||||
default: return 6
|
||||
}
|
||||
}
|
||||
|
||||
private func yAxisLabel(for value: Double) -> String {
|
||||
switch viewModel.comparisonDisplayMode {
|
||||
case .indexed:
|
||||
return String(format: "%.0f", value)
|
||||
case .returnPct:
|
||||
return String(format: "%.1f%%", value)
|
||||
case .absolute:
|
||||
return Decimal(value).shortCurrencyString
|
||||
case .monthlyReturn:
|
||||
return String(format: "%.1f%%", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import Charts
|
||||
|
||||
struct DrawdownChart: View {
|
||||
let data: [(date: Date, drawdown: Double)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
private var selectedPoint: (date: Date, drawdown: Double)? {
|
||||
guard let selectedDate else { return nil }
|
||||
return data.min(by: {
|
||||
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
|
||||
})
|
||||
}
|
||||
|
||||
var maxDrawdown: Double {
|
||||
abs(data.map { $0.drawdown }.min() ?? 0)
|
||||
@@ -15,8 +28,10 @@ struct DrawdownChart: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Drawdown Analysis")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text("Drawdown Analysis")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -35,27 +50,58 @@ struct DrawdownChart: View {
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
Chart {
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.interpolationMethod(.catmullRom)
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.symbolSize(20)
|
||||
.annotation(position: .bottom, spacing: 3) {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let sel = selectedPoint {
|
||||
RuleMark(x: .value("Selected", sel.date))
|
||||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
PointMark(
|
||||
x: .value("Selected", sel.date),
|
||||
y: .value("Drawdown", sel.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.symbolSize(70)
|
||||
.annotation(position: .bottom, spacing: 4) {
|
||||
ChartValueBubble(text: ChartLabels.percent(sel.drawdown), color: .negativeRed, prominent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXSelection(value: $selectedDate)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
@@ -74,6 +120,7 @@ struct DrawdownChart: View {
|
||||
}
|
||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||
.frame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
// Statistics
|
||||
HStack(spacing: 20) {
|
||||
@@ -96,6 +143,13 @@ struct DrawdownChart: View {
|
||||
)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
ChartDataTable(
|
||||
rows: drawdownTableRows,
|
||||
valueHeader: "Drawdown",
|
||||
deltaPrevHeader: "Δ prev",
|
||||
deltaFirstHeader: "Δ first"
|
||||
)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("Not enough data for drawdown analysis")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -114,6 +168,24 @@ struct DrawdownChart: View {
|
||||
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
|
||||
return sum / Double(data.count)
|
||||
}
|
||||
|
||||
private var drawdownTableRows: [ChartDataTableRow] {
|
||||
data.enumerated().map { idx, point in
|
||||
let val = abs(point.drawdown)
|
||||
let prevVal = idx > 0 ? abs(data[idx - 1].drawdown) : val
|
||||
let firstVal = abs(data.first?.drawdown ?? val)
|
||||
let deltaPrev = -(val - prevVal) // negative delta = improvement (less drawdown)
|
||||
let deltaFirst = -(val - firstVal)
|
||||
return ChartDataTableRow(
|
||||
label: chartTableDateLabel(point.date),
|
||||
value: String(format: "%.1f%%", val),
|
||||
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
|
||||
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
|
||||
isPrevPositive: deltaPrev >= 0,
|
||||
isFirstPositive: deltaFirst >= 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DrawdownStatView: View {
|
||||
@@ -139,6 +211,19 @@ struct DrawdownStatView: View {
|
||||
|
||||
struct VolatilityChartView: View {
|
||||
let data: [(date: Date, volatility: Double)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
private var selectedPoint: (date: Date, volatility: Double)? {
|
||||
guard let selectedDate else { return nil }
|
||||
return data.min(by: {
|
||||
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
|
||||
})
|
||||
}
|
||||
|
||||
var currentVolatility: Double {
|
||||
data.last?.volatility ?? 0
|
||||
@@ -151,19 +236,12 @@ struct VolatilityChartView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Volatility")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
HStack {
|
||||
Text("Volatility")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Current")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%.1f%%", currentVolatility))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(volatilityColor(currentVolatility))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,26 +250,41 @@ struct VolatilityChartView: View {
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
Chart {
|
||||
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(20)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average line
|
||||
RuleMark(y: .value("Average", averageVolatility))
|
||||
@@ -202,7 +295,23 @@ struct VolatilityChartView: View {
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let sel = selectedPoint {
|
||||
RuleMark(x: .value("Selected", sel.date))
|
||||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
PointMark(
|
||||
x: .value("Selected", sel.date),
|
||||
y: .value("Volatility", sel.volatility)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(70)
|
||||
.annotation(position: .top, spacing: 4) {
|
||||
ChartValueBubble(text: String(format: "%.1f%%", sel.volatility), prominent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXSelection(value: $selectedDate)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
@@ -220,14 +329,21 @@ struct VolatilityChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
// Volatility interpretation
|
||||
HStack(spacing: 16) {
|
||||
VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen)
|
||||
VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning)
|
||||
VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed)
|
||||
}
|
||||
ChartStatsRow(stats: [
|
||||
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
|
||||
ChartStat(label: "Max", value: String(format: "%.1f%%", data.map { $0.volatility }.max() ?? 0), color: .negativeRed),
|
||||
ChartStat(label: "Min", value: String(format: "%.1f%%", data.map { $0.volatility }.min() ?? 0), color: .positiveGreen),
|
||||
ChartStat(label: "Average", value: String(format: "%.1f%%", averageVolatility), color: .secondary),
|
||||
])
|
||||
.padding(.top, 8)
|
||||
ChartDataTable(
|
||||
rows: volatilityTableRows,
|
||||
valueHeader: "Volatility",
|
||||
deltaPrevHeader: "Δ prev",
|
||||
deltaFirstHeader: nil
|
||||
)
|
||||
} else {
|
||||
Text("Not enough data for volatility analysis")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -251,6 +367,21 @@ struct VolatilityChartView: View {
|
||||
return .negativeRed
|
||||
}
|
||||
}
|
||||
|
||||
private var volatilityTableRows: [ChartDataTableRow] {
|
||||
data.enumerated().map { idx, point in
|
||||
let prevVal = idx > 0 ? data[idx - 1].volatility : point.volatility
|
||||
let delta = point.volatility - prevVal
|
||||
return ChartDataTableRow(
|
||||
label: chartTableDateLabel(point.date),
|
||||
value: String(format: "%.1f%%", point.volatility),
|
||||
deltaPrev: idx > 0 ? String(format: "%+.1f%%", delta) : nil,
|
||||
deltaFirst: nil,
|
||||
isPrevPositive: delta <= 0,
|
||||
isFirstPositive: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VolatilityLevelView: View {
|
||||
|
||||
@@ -3,10 +3,21 @@ import Charts
|
||||
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
var title: String = "Performance by Category"
|
||||
/// Drill-down: tapping a row jumps to Evolution filtered to it.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
/// On iPhone, vertical bars collide once there are more than a handful of
|
||||
/// categories (labels + annotations fight for width). Horizontal bars give
|
||||
/// every name a full line.
|
||||
private var useHorizontalBars: Bool {
|
||||
horizontalSizeClass != .regular && data.count > 6
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
@@ -14,60 +25,20 @@ struct PerformanceBarChart: View {
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
ChartStatsRow(stats: perfStats)
|
||||
}
|
||||
|
||||
// Legend / Details
|
||||
if !data.isEmpty {
|
||||
if useHorizontalBars {
|
||||
horizontalChart
|
||||
} else {
|
||||
verticalChart
|
||||
}
|
||||
|
||||
// Legend / Details — rows drill down when the owner wires it up.
|
||||
VStack(spacing: 8) {
|
||||
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
legendRow(item)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@@ -83,12 +54,139 @@ struct PerformanceBarChart: View {
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var verticalChart: some View {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
}
|
||||
|
||||
private var horizontalChart: some View {
|
||||
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("CAGR", item.cagr),
|
||||
y: .value("Category", item.category)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: CGFloat(data.count) * 34 + 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
|
||||
let row = HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
|
||||
if onDrillDown != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
row.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
private var perfStats: [ChartStat] {
|
||||
guard !data.isEmpty else { return [] }
|
||||
let values = data.map { $0.cagr }
|
||||
let best = values.max() ?? 0
|
||||
let worst = values.min() ?? 0
|
||||
let avg = values.reduce(0, +) / Double(values.count)
|
||||
return [
|
||||
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
|
||||
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
|
||||
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal Bar Version
|
||||
|
||||
struct HorizontalPerformanceChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
|
||||
var sortedData: [(category: String, cagr: Double, color: String)] {
|
||||
data.sorted { $0.cagr > $1.cagr }
|
||||
@@ -100,8 +198,10 @@ struct HorizontalPerformanceChart: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
ForEach(sortedData, id: \.category) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct PeriodComparisonChartView: View {
|
||||
@Environment(\.chartImageExport) private var chartImageExport
|
||||
@State private var selectedMonth: Int?
|
||||
|
||||
/// Period charts are short by design (a handful of months) — labels always help.
|
||||
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
|
||||
viewModel.periodComparisonData.compactMap { series in
|
||||
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
|
||||
return (series.label, ChartLabels.percent(point.returnPct), Color(hex: series.colorHex) ?? .gray)
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
|
||||
private let colorA = Color(hex: "#3478F6") ?? .blue
|
||||
private let colorB = Color(hex: "#FF9500") ?? .orange
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Period vs Period")
|
||||
.font(.headline)
|
||||
|
||||
periodPickersSection
|
||||
chartContent
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Period Pickers
|
||||
|
||||
private var periodPickersSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
periodRow(label: "Period A", color: colorA, start: $viewModel.periodAStart, end: $viewModel.periodAEnd)
|
||||
Divider()
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
periodRow(label: "Period B", color: colorB, start: $viewModel.periodBStart, end: $viewModel.periodBEnd)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [colorA.opacity(0.06), colorB.opacity(0.06)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func periodRow(label: String, color: Color, start: Binding<Date>, end: Binding<Date>) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 3, height: 30)
|
||||
|
||||
Text(label)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 60, alignment: .leading)
|
||||
|
||||
DatePicker(
|
||||
"",
|
||||
selection: Binding<Date>(
|
||||
get: { firstOfMonth(start.wrappedValue) },
|
||||
set: { start.wrappedValue = firstOfMonth($0) }
|
||||
),
|
||||
displayedComponents: [.date]
|
||||
)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text("→")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
DatePicker(
|
||||
"",
|
||||
selection: Binding<Date>(
|
||||
get: { firstOfMonth(end.wrappedValue) },
|
||||
set: { end.wrappedValue = firstOfMonth($0) }
|
||||
),
|
||||
displayedComponents: [.date]
|
||||
)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Content
|
||||
|
||||
@ViewBuilder
|
||||
private var chartContent: some View {
|
||||
if viewModel.periodComparisonData.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
numericalSummary
|
||||
periodChart
|
||||
legend
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var numericalSummary: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.periodComparisonData) { series in
|
||||
let color = Color(hex: series.colorHex) ?? .gray
|
||||
let finalReturn = series.points.last?.returnPct ?? 0
|
||||
let maxReturn = series.points.map { $0.returnPct }.max() ?? 0
|
||||
let minReturn = series.points.map { $0.returnPct }.min() ?? 0
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(color)
|
||||
.frame(width: 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(series.label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(String(format: "%+.2f%%", finalReturn))
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.positiveGreen)
|
||||
Text(String(format: "%.1f%%", maxReturn))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.negativeRed)
|
||||
Text(String(format: "%.1f%%", minReturn))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(color.opacity(0.07))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(color.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No data available for the selected periods.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
}
|
||||
|
||||
// MARK: - Flat data for Chart
|
||||
|
||||
private struct PeriodPoint: Identifiable {
|
||||
let id: String
|
||||
let seriesId: String
|
||||
let seriesLabel: String
|
||||
let colorHex: String
|
||||
let monthOffset: Int
|
||||
let returnPct: Double
|
||||
}
|
||||
|
||||
private var flatPoints: [PeriodPoint] {
|
||||
var result: [PeriodPoint] = []
|
||||
for series in viewModel.periodComparisonData {
|
||||
for point in series.points {
|
||||
result.append(PeriodPoint(
|
||||
id: "\(series.id)-\(point.monthOffset)",
|
||||
seriesId: series.id,
|
||||
seriesLabel: series.label,
|
||||
colorHex: series.colorHex,
|
||||
monthOffset: point.monthOffset,
|
||||
returnPct: point.returnPct
|
||||
))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Last month offset per series — used to label only each series' endpoint.
|
||||
private var lastOffsetBySeries: [String: Int] {
|
||||
var m: [String: Int] = [:]
|
||||
for s in viewModel.periodComparisonData {
|
||||
m[s.id] = s.points.map(\.monthOffset).max()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
private var periodChart: some View {
|
||||
let points = flatPoints
|
||||
let endpoints = lastOffsetBySeries
|
||||
let seriesIds = viewModel.periodComparisonData.map { $0.id }
|
||||
let seriesColors: [Color] = viewModel.periodComparisonData.map {
|
||||
Color(hex: $0.colorHex) ?? .gray
|
||||
}
|
||||
|
||||
return Chart {
|
||||
ForEach(points) { point in
|
||||
LineMark(
|
||||
x: .value("Month", point.monthOffset),
|
||||
y: .value("Return", point.returnPct),
|
||||
series: .value("Period", point.seriesLabel)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||
.lineStyle(StrokeStyle(lineWidth: 2.5))
|
||||
|
||||
PointMark(
|
||||
x: .value("Month", point.monthOffset),
|
||||
y: .value("Return", point.returnPct)
|
||||
)
|
||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
||||
.symbolSize(point.monthOffset == endpoints[point.seriesId] ? 38 : 22)
|
||||
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
|
||||
spacing: 3) {
|
||||
if point.monthOffset == endpoints[point.seriesId] {
|
||||
ChartValueBubble(
|
||||
text: ChartLabels.percent(point.returnPct),
|
||||
color: Color(hex: point.colorHex) ?? .gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RuleMark(y: .value("Zero", 0))
|
||||
.foregroundStyle(Color.secondary.opacity(0.4))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
|
||||
if let selectedMonth {
|
||||
RuleMark(x: .value("Selected", selectedMonth))
|
||||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
.annotation(position: .top, spacing: 4) {
|
||||
ChartSelectionCard(
|
||||
title: "M\(selectedMonth + 1)",
|
||||
rows: selectionRows(for: selectedMonth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXSelection(value: $selectedMonth)
|
||||
.chartForegroundStyleScale(
|
||||
domain: viewModel.periodComparisonData.map { $0.label },
|
||||
range: seriesColors
|
||||
)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: xAxisValues) { value in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||
AxisValueLabel {
|
||||
if let idx = value.as(Int.self) {
|
||||
Text("M\(idx + 1)")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let d = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", d))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.15))
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
.onChange(of: seriesIds) { _, _ in }
|
||||
}
|
||||
|
||||
private var legend: some View {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(viewModel.periodComparisonData) { series in
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(hex: series.colorHex) ?? .gray)
|
||||
.frame(width: 20, height: 3)
|
||||
Text(series.label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var xAxisValues: [Int] {
|
||||
let maxOffset = viewModel.periodComparisonData
|
||||
.flatMap { $0.points }
|
||||
.map { $0.monthOffset }
|
||||
.max() ?? 0
|
||||
return Array(0...maxOffset)
|
||||
}
|
||||
|
||||
private func firstOfMonth(_ date: Date) -> Date {
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.year, .month], from: date)
|
||||
components.day = 1
|
||||
return calendar.date(from: components) ?? date
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,31 @@ import Charts
|
||||
struct PredictionChartView: View {
|
||||
let predictions: [Prediction]
|
||||
let historicalData: [(date: Date, value: Decimal)]
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
|
||||
private var labelsCompact: Bool { labelsSizeClass != .regular }
|
||||
|
||||
/// Nearest point (historical or predicted) to the selected date.
|
||||
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
|
||||
guard let selectedDate else { return nil }
|
||||
let all: [(date: Date, value: Decimal, isPrediction: Bool)] =
|
||||
historicalData.map { ($0.date, $0.value, false) } +
|
||||
predictions.map { ($0.date, $0.predictedValue, true) }
|
||||
return all.min(by: {
|
||||
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
|
||||
.font(.headline)
|
||||
if showsTitle {
|
||||
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -33,8 +52,9 @@ struct PredictionChartView: View {
|
||||
|
||||
if !predictions.isEmpty && historicalData.count >= 2 {
|
||||
Chart {
|
||||
// Historical data
|
||||
ForEach(historicalData, id: \.date) { item in
|
||||
// Historical data — thin labels to at most a handful.
|
||||
ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in
|
||||
let item = pair.element
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
@@ -48,6 +68,11 @@ struct PredictionChartView: View {
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(24)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence interval area
|
||||
@@ -74,7 +99,35 @@ struct PredictionChartView: View {
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.symbolSize(24)
|
||||
.symbolSize(prediction.id == predictions.last?.id ? 34 : 24)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
// Only the final forecast point (the KPIs cover the rest).
|
||||
if prediction.id == predictions.last?.id {
|
||||
ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bull scenario line (upper confidence interval)
|
||||
ForEach(predictions) { prediction in
|
||||
LineMark(
|
||||
x: .value("Date", prediction.date),
|
||||
y: .value("Bull", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue),
|
||||
series: .value("Scenario", "bull")
|
||||
)
|
||||
.foregroundStyle(Color.positiveGreen.opacity(0.7))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
||||
}
|
||||
|
||||
// Bear scenario line (lower confidence interval)
|
||||
ForEach(predictions) { prediction in
|
||||
LineMark(
|
||||
x: .value("Date", prediction.date),
|
||||
y: .value("Bear", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
|
||||
series: .value("Scenario", "bear")
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed.opacity(0.7))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
||||
}
|
||||
|
||||
// Connect historical to prediction
|
||||
@@ -96,7 +149,27 @@ struct PredictionChartView: View {
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
}
|
||||
|
||||
if let sel = selectedPoint {
|
||||
RuleMark(x: .value("Selected", sel.date))
|
||||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
PointMark(
|
||||
x: .value("Selected", sel.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(sel.isPrediction ? Color.appSecondary : Color.appPrimary)
|
||||
.symbolSize(80)
|
||||
.annotation(position: .top, spacing: 4) {
|
||||
ChartValueBubble(
|
||||
text: sel.value.compactCurrencyString,
|
||||
color: sel.isPrediction ? .appSecondary : .appPrimary,
|
||||
prominent: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXSelection(value: $selectedDate)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||
@@ -114,6 +187,17 @@ struct PredictionChartView: View {
|
||||
}
|
||||
.frame(height: 280)
|
||||
|
||||
// Stats row
|
||||
if let currentValue = historicalData.last?.value,
|
||||
let lastPred = predictions.last {
|
||||
ChartStatsRow(stats: [
|
||||
ChartStat(label: "Current", value: currentValue.compactCurrencyString, color: .appPrimary),
|
||||
ChartStat(label: "Base (\(predictions.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary),
|
||||
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
|
||||
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
|
||||
])
|
||||
}
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 6) {
|
||||
@@ -150,6 +234,24 @@ struct PredictionChartView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.positiveGreen.opacity(0.7))
|
||||
.frame(width: 20, height: 2)
|
||||
Text("Bull")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.negativeRed.opacity(0.7))
|
||||
.frame(width: 20, height: 2)
|
||||
Text("Bear")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Prediction details
|
||||
@@ -195,6 +297,14 @@ struct PredictionChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
ChartDataTable(
|
||||
rows: predictionTableRows,
|
||||
valueHeader: "Base",
|
||||
deltaPrevHeader: "Bull",
|
||||
deltaFirstHeader: "Bear"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
@@ -220,6 +330,19 @@ struct PredictionChartView: View {
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var predictionTableRows: [ChartDataTableRow] {
|
||||
predictions.map { pred in
|
||||
ChartDataTableRow(
|
||||
label: chartTableDateLabel(pred.date),
|
||||
value: pred.predictedValue.compactCurrencyString,
|
||||
deltaPrev: pred.confidenceInterval.upper.compactCurrencyString,
|
||||
deltaFirst: pred.confidenceInterval.lower.compactCurrencyString,
|
||||
isPrevPositive: true,
|
||||
isFirstPositive: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct YearOverYearChartView: View {
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
/// Hidden when the host (e.g. a dashboard card) already shows a title.
|
||||
var showsTitle: Bool = true
|
||||
@State private var selectedMonthIdx: Int?
|
||||
|
||||
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
private let lineColors: [Color] = [
|
||||
.appPrimary, .appSecondary, .orange, .purple, .pink, .teal
|
||||
]
|
||||
|
||||
private var allYears: [ChartsViewModel.YearSeries] { viewModel.yearOverYearData }
|
||||
|
||||
private var selectedSeries: [ChartsViewModel.YearSeries] {
|
||||
allYears.filter { viewModel.yoySelectedYears.contains($0.year) }
|
||||
.sorted { $0.year < $1.year }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if showsTitle {
|
||||
Text(String(localized: "chart_yoy_title"))
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if allYears.isEmpty {
|
||||
Text(String(localized: "chart_yoy_empty"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.frame(height: 200)
|
||||
} else {
|
||||
yearSelector
|
||||
if !selectedSeries.isEmpty {
|
||||
// KPIs arriba del todo (#147)
|
||||
yearEndStatsRow
|
||||
if selectedSeries.count == 2 {
|
||||
comparisonStatsHeader
|
||||
}
|
||||
if hasEstimate {
|
||||
Text(String(localized: "chart_yoy_estimated_note"))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
// Detalle debajo (#147)
|
||||
chartBody
|
||||
legend
|
||||
if selectedSeries.count == 2 {
|
||||
comparisonTable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Year Selector
|
||||
|
||||
private var yearSelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(allYears) { ys in
|
||||
let idx = allYears.firstIndex(where: { $0.id == ys.id }) ?? 0
|
||||
let color = lineColors[idx % lineColors.count]
|
||||
let isSelected = viewModel.yoySelectedYears.contains(ys.year)
|
||||
Button {
|
||||
if isSelected {
|
||||
viewModel.yoySelectedYears.remove(ys.year)
|
||||
} else {
|
||||
viewModel.yoySelectedYears.insert(ys.year)
|
||||
}
|
||||
} label: {
|
||||
Text(String(ys.year))
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1))
|
||||
.foregroundColor(isSelected ? color : .secondary)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart
|
||||
|
||||
/// Last month index that has data, per series id — label only that endpoint
|
||||
/// (the year's cumulative return) to avoid 24 overlapping labels.
|
||||
private func lastValidMonth(_ series: ChartsViewModel.YearSeries) -> Int? {
|
||||
(0..<12).last { !series.values[$0].isNaN }
|
||||
}
|
||||
|
||||
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
|
||||
selectedSeries.compactMap { series in
|
||||
let value = series.values[month]
|
||||
guard !value.isNaN else { return nil }
|
||||
let idx = allYears.firstIndex(where: { $0.id == series.id }) ?? 0
|
||||
return (String(series.year), ChartLabels.percent(value), lineColors[idx % lineColors.count])
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartBody: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
Chart {
|
||||
ForEach(selectedSeries) { yearSeries in
|
||||
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||
let color = lineColors[idx % lineColors.count]
|
||||
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||
let endMonth = lastValidMonth(yearSeries)
|
||||
ForEach(0..<12, id: \.self) { monthIdx in
|
||||
let value = yearSeries.values[monthIdx]
|
||||
if !value.isNaN {
|
||||
LineMark(
|
||||
x: .value("Month", monthIdx),
|
||||
y: .value("Return", value),
|
||||
series: .value("Year", String(yearSeries.year))
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Month", monthIdx),
|
||||
y: .value("Return", value)
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.symbolSize(monthIdx == endMonth ? 34 : 18)
|
||||
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
|
||||
if monthIdx == endMonth {
|
||||
ChartValueBubble(text: ChartLabels.percent(value), color: color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let selectedMonthIdx, selectedMonthIdx >= 0, selectedMonthIdx < 12 {
|
||||
RuleMark(x: .value("Selected", selectedMonthIdx))
|
||||
.foregroundStyle(Color.secondary.opacity(0.35))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
.annotation(position: .top, spacing: 4) {
|
||||
ChartSelectionCard(
|
||||
title: monthAbbreviations[selectedMonthIdx],
|
||||
rows: selectionRows(for: selectedMonthIdx)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXSelection(value: $selectedMonthIdx)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: Array(0..<12)) { value in
|
||||
AxisValueLabel {
|
||||
if let idx = value.as(Int.self) {
|
||||
Text(monthAbbreviations[idx])
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let d = value.as(Double.self) {
|
||||
Text(String(format: "%+.1f%%", d))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
} else {
|
||||
Text("iOS 16+ required for chart")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 220)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legend
|
||||
|
||||
private var legend: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(selectedSeries) { yearSeries in
|
||||
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(lineColors[idx % lineColors.count])
|
||||
.frame(width: 20, height: 3)
|
||||
Text(String(yearSeries.year))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Year-end stats chips
|
||||
|
||||
private var yearEndStatsRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(selectedSeries) { yearSeries in
|
||||
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||
let color = lineColors[idx % lineColors.count]
|
||||
let end = endValue(yearSeries)
|
||||
VStack(alignment: .center, spacing: 3) {
|
||||
Text(String(yearSeries.year))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : ""))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(end.value >= 0 ? color : .negativeRed)
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 2-year comparison: KPIs (top) and table (bottom)
|
||||
|
||||
@ViewBuilder
|
||||
private var comparisonStatsHeader: some View {
|
||||
let yearA = selectedSeries[0]
|
||||
let yearB = selectedSeries[1]
|
||||
let diffs = monthDiffs(yearA: yearA, yearB: yearB)
|
||||
let validDiffs = diffs.compactMap { $0 }
|
||||
let avgDiff = validDiffs.isEmpty ? 0 : validDiffs.reduce(0, +) / Double(validDiffs.count)
|
||||
let endAInfo = endValue(yearA)
|
||||
let endBInfo = endValue(yearB)
|
||||
let endDiff = endBInfo.value - endAInfo.value
|
||||
let endEstimated = endAInfo.estimated || endBInfo.estimated
|
||||
let bestDiff = validDiffs.max() ?? 0
|
||||
let worstDiff = validDiffs.min() ?? 0
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("\(yearA.year) vs \(yearB.year)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
ChartStatsRow(stats: [
|
||||
ChartStat(label: "Avg diff", value: String(format: "%+.1f%%", avgDiff),
|
||||
color: avgDiff >= 0 ? .positiveGreen : .negativeRed),
|
||||
ChartStat(label: "End diff", value: String(format: "%+.1f%%", endDiff) + (endEstimated ? "*" : ""),
|
||||
color: endDiff >= 0 ? .positiveGreen : .negativeRed),
|
||||
ChartStat(label: "Best month", value: String(format: "%+.1f%%", bestDiff),
|
||||
color: .positiveGreen),
|
||||
ChartStat(label: "Worst month", value: String(format: "%+.1f%%", worstDiff),
|
||||
color: .negativeRed),
|
||||
], showsOnRegularWidth: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var comparisonTable: some View {
|
||||
let yearA = selectedSeries[0]
|
||||
let yearB = selectedSeries[1]
|
||||
ChartDataTable(
|
||||
rows: comparisonTableRows(yearA: yearA, yearB: yearB),
|
||||
valueHeader: String(yearA.year),
|
||||
deltaPrevHeader: String(yearB.year),
|
||||
deltaFirstHeader: "Diff"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Effective year-end value: the forecast (estimated) for the current incomplete year,
|
||||
/// otherwise the last month with real data.
|
||||
private func endValue(_ s: ChartsViewModel.YearSeries) -> (value: Double, estimated: Bool) {
|
||||
if let f = s.forecastEndValue { return (f, true) }
|
||||
return (s.values.last(where: { !$0.isNaN }) ?? 0, false)
|
||||
}
|
||||
|
||||
private var hasEstimate: Bool {
|
||||
selectedSeries.contains { $0.forecastEndValue != nil }
|
||||
}
|
||||
|
||||
private func monthDiffs(yearA: ChartsViewModel.YearSeries, yearB: ChartsViewModel.YearSeries) -> [Double?] {
|
||||
(0..<12).map { idx in
|
||||
let a = yearA.values[idx]
|
||||
let b = yearB.values[idx]
|
||||
guard !a.isNaN, !b.isNaN else { return nil }
|
||||
return b - a
|
||||
}
|
||||
}
|
||||
|
||||
private func comparisonTableRows(
|
||||
yearA: ChartsViewModel.YearSeries,
|
||||
yearB: ChartsViewModel.YearSeries
|
||||
) -> [ChartDataTableRow] {
|
||||
(0..<12).compactMap { idx in
|
||||
let valA = yearA.values[idx]
|
||||
let valB = yearB.values[idx]
|
||||
guard !valA.isNaN || !valB.isNaN else { return nil }
|
||||
let diff = (!valA.isNaN && !valB.isNaN) ? valB - valA : 0
|
||||
return ChartDataTableRow(
|
||||
label: monthAbbreviations[idx],
|
||||
value: valA.isNaN ? "—" : String(format: "%+.1f%%", valA),
|
||||
deltaPrev: valB.isNaN ? "—" : String(format: "%+.1f%%", valB),
|
||||
deltaFirst: (!valA.isNaN && !valB.isNaN) ? String(format: "%+.1f%%", diff) : "—",
|
||||
isPrevPositive: valB >= 0,
|
||||
isFirstPositive: diff >= 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
// Calm 2.0: plain system grouped background — depth comes from content
|
||||
// hierarchy, not decoration (the old gradient + overflowing shapes also
|
||||
// intercepted touches on sibling views; see 1.4.2 build 50).
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appSecondary.opacity(0.12),
|
||||
Color.appAccent.opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.18))
|
||||
.frame(width: proxy.size.width * 0.7)
|
||||
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
|
||||
|
||||
RoundedRectangle(cornerRadius: 120, style: .continuous)
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
|
||||
.rotationEffect(.degrees(-12))
|
||||
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
|
||||
}
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user