Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d08c312c62 | |||
| 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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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 = 56;
|
||||
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.4.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.
@@ -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 = "0ETEST001000000000000000"
|
||||
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"
|
||||
|
||||
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,10 +113,36 @@ struct ContentView: View {
|
||||
}
|
||||
.task {
|
||||
await waitForDataAndResolveOnboarding()
|
||||
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion {
|
||||
// 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
|
||||
}
|
||||
.sheet(isPresented: $showingWhatsNew, onDismiss: {
|
||||
lastSeenWhatsNewVersion = currentVersion
|
||||
}) {
|
||||
WhatsNewView()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isReadyForContent: Bool {
|
||||
@@ -78,12 +155,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 +224,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 +305,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)
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct PortfolioJournalApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@@ -12,6 +44,10 @@ struct PortfolioJournalApp: App {
|
||||
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()
|
||||
|
||||
@@ -34,6 +70,29 @@ struct PortfolioJournalApp: App {
|
||||
.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()
|
||||
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,8 +7,14 @@ public class Account: NSManagedObject, Identifiable {
|
||||
return NSFetchRequest<Account>(entityName: "Account")
|
||||
}
|
||||
|
||||
@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 createdAt: Date
|
||||
@NSManaged public var currency: String?
|
||||
@NSManaged public var inputMode: String
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,8 +7,14 @@ 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
|
||||
|
||||
@@ -7,16 +7,22 @@ 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 monthlyContribution: NSDecimalNumber?
|
||||
@NSManaged public var createdAt: Date
|
||||
@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 +34,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 +110,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 +167,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 +201,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)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -25,6 +25,7 @@
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" 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"/>
|
||||
@@ -38,6 +39,7 @@
|
||||
<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"/>
|
||||
@@ -102,4 +104,13 @@
|
||||
<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,21 @@ 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
|
||||
@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 +30,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,35 @@ 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?
|
||||
|
||||
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 +164,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]
|
||||
@@ -150,6 +186,17 @@ class CoreDataStack: ObservableObject {
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoaded = true
|
||||
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 +224,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 +270,10 @@ 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: - Save Context
|
||||
@@ -258,12 +306,127 @@ 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")
|
||||
}
|
||||
// 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))"
|
||||
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")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +451,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,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PortfolioInsight: Identifiable {
|
||||
let id: String
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let value: String
|
||||
let accentColor: Color
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,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,286 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"Add a snapshot in ${applicationName}": {
|
||||
"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}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zeige mein Portfolio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Consulta mi cartera en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Consulte mon portefeuille dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Controlla il mio portafoglio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}でポートフォリオを確認"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Verifique minha carteira no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Log a value in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Erfasse einen Wert in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registra un valor en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enregistre une valeur dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registra un valore in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}で金額を記録"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registre um valor no ${applicationName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Log my ${source} balance in ${applicationName}": {
|
||||
"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}": {
|
||||
"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}": {
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Update my portfolio in ${applicationName}": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aktualisiere mein Portfolio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"es-ES": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Actualiza mi cartera en ${applicationName}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mets à jour mon portefeuille dans ${applicationName}"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aggiorna il mio portafoglio in ${applicationName}"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "${applicationName}でポートフォリオを更新"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"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,470 @@
|
||||
"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_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";
|
||||
"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";
|
||||
@@ -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,210 @@
|
||||
"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.";
|
||||
"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";
|
||||
"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";
|
||||
|
||||
@@ -123,3 +123,300 @@
|
||||
// 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.";
|
||||
"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";
|
||||
"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";
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"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_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";
|
||||
"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";
|
||||
@@ -0,0 +1,470 @@
|
||||
"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_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";
|
||||
"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";
|
||||
@@ -0,0 +1,470 @@
|
||||
"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_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" = "目標に向けて順調";
|
||||
"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" = "プレミアムを解除";
|
||||
@@ -0,0 +1,470 @@
|
||||
"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_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";
|
||||
"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";
|
||||
@@ -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,151 @@
|
||||
import AppIntents
|
||||
import Foundation
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,181 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
]}
|
||||
}
|
||||
|
||||
@@ -655,6 +655,184 @@ 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, snapshots: snapshots)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: 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]] {
|
||||
|
||||
@@ -31,6 +31,9 @@ class NotificationService: ObservableObject {
|
||||
await MainActor.run {
|
||||
self.isAuthorized = granted
|
||||
}
|
||||
if granted {
|
||||
scheduleMonthlyPerformanceSummary()
|
||||
}
|
||||
return granted
|
||||
} catch {
|
||||
print("Notification authorization error: \(error)")
|
||||
@@ -155,6 +158,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 +189,200 @@ 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")
|
||||
}
|
||||
|
||||
// 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.
|
||||
func scheduleReEngagementNotification() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = String(localized: "reengagement_title")
|
||||
content.body = String(localized: "reengagement_body")
|
||||
content.sound = .default
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: - 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,155 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import LinkPresentation
|
||||
|
||||
class ShareService {
|
||||
static let shared = ShareService()
|
||||
|
||||
private init() {}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sharePortfolioValue(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
changeLabel: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?
|
||||
) {
|
||||
FirebaseService.shared.logShare(type: "portfolio_value")
|
||||
let appName = Self.appDisplayName
|
||||
let text = Self.buildPortfolioValueShareText(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
appName: appName
|
||||
)
|
||||
|
||||
shareCard(
|
||||
cardTitle: "Portfolio Snapshot",
|
||||
fallbackText: text
|
||||
) {
|
||||
PortfolioValueShareCardView(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ViewBuilder card: () -> Content
|
||||
) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
let shareURL = GoalShareService.appStoreURL
|
||||
|
||||
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 +253,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
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,7 @@ class DashboardViewModel: ObservableObject {
|
||||
// MARK: - Chart Data
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
@Published var updateStreak: Int = 0
|
||||
|
||||
// MARK: - Portfolio Forecast
|
||||
|
||||
@@ -195,11 +197,18 @@ 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)
|
||||
|
||||
// Log screen view
|
||||
FirebaseService.shared.logScreenView(screenName: "Dashboard")
|
||||
}
|
||||
@@ -345,7 +354,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 +372,7 @@ class DashboardViewModel: ObservableObject {
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
return snapshots
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
@@ -371,6 +383,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 +398,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 +406,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 +494,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 +548,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 +661,82 @@ class DashboardViewModel: ObservableObject {
|
||||
sourcesNeedingUpdate.count
|
||||
}
|
||||
|
||||
var insights: [PortfolioInsight] {
|
||||
var result: [PortfolioInsight] = []
|
||||
guard portfolioSummary.totalValue > 0 else { return result }
|
||||
let total = portfolioSummary.totalValue
|
||||
|
||||
// 1. Milestone approaching (within 10% of next round milestone)
|
||||
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
|
||||
100000, 250000, 500000, 1_000_000,
|
||||
2_500_000, 5_000_000, 10_000_000]
|
||||
if let next = milestones.first(where: { $0 > total }) {
|
||||
let pct = NSDecimalNumber(decimal: total / next).doubleValue
|
||||
if pct >= 0.90 {
|
||||
let gap = next - total
|
||||
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
|
||||
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
|
||||
result.append(PortfolioInsight(
|
||||
id: "milestone",
|
||||
systemImage: "flag.checkered",
|
||||
title: String(localized: "insight_milestone_title"),
|
||||
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
|
||||
accentColor: .orange
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Year-to-date performance
|
||||
let ytdPct = portfolioSummary.yearChangePercentage
|
||||
if abs(ytdPct) >= 0.1 {
|
||||
let icon = ytdPct >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
|
||||
result.append(PortfolioInsight(
|
||||
id: "ytd",
|
||||
systemImage: icon,
|
||||
title: String(localized: "insight_ytd_title"),
|
||||
value: String(format: "%+.1f%%", ytdPct),
|
||||
accentColor: ytdPct >= 0 ? .positiveGreen : .negativeRed
|
||||
))
|
||||
}
|
||||
|
||||
// 3. All-time market gains
|
||||
let gains = portfolioSummary.allTimeReturn
|
||||
if gains > 0 {
|
||||
let gainStr = CurrencyFormatter.format(gains, style: .currency, maximumFractionDigits: 0)
|
||||
result.append(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
|
||||
))
|
||||
}
|
||||
|
||||
// 4. Tracking streak (>= 3 months)
|
||||
if updateStreak >= 3 {
|
||||
result.append(PortfolioInsight(
|
||||
id: "streak",
|
||||
systemImage: "flame.fill",
|
||||
title: String(localized: "insight_streak_title"),
|
||||
value: String(format: String(localized: "insight_streak_value"), updateStreak),
|
||||
accentColor: .orange
|
||||
))
|
||||
}
|
||||
|
||||
// 5. Forecast
|
||||
if let forecast = portfolioForecast, forecast.forecastValue > total {
|
||||
result.append(PortfolioInsight(
|
||||
id: "forecast",
|
||||
systemImage: "wand.and.stars",
|
||||
title: String(localized: "insight_forecast_title"),
|
||||
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
|
||||
accentColor: .purple
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var topCategories: [CategoryMetrics] {
|
||||
Array(categoryMetrics.prefix(5))
|
||||
}
|
||||
|
||||
@@ -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,7 +161,11 @@ class GoalsViewModel: ObservableObject {
|
||||
goalRepository.deleteGoal(goal)
|
||||
}
|
||||
|
||||
private func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
func archiveGoal(_ goal: Goal) {
|
||||
goalRepository.updateGoal(goal, isActive: !goal.isActive)
|
||||
}
|
||||
|
||||
func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
// Performance: Use cached completion date if available
|
||||
if let cached = cachedCompletionDates[goal.id] {
|
||||
return cached
|
||||
@@ -288,3 +317,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,12 @@ 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
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let iapService: IAPService
|
||||
@@ -37,6 +45,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 +70,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 +94,13 @@ class SettingsViewModel: ObservableObject {
|
||||
analyticsEnabled = settings.enableAnalytics
|
||||
currencyCode = settings.currency
|
||||
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
|
||||
backupRetentionCount = loadBackupRetention()
|
||||
backupsEnabled = loadBackupsEnabled()
|
||||
if backupsEnabled {
|
||||
refreshBackups()
|
||||
} else {
|
||||
backups = []
|
||||
}
|
||||
|
||||
// Load statistics directly from database to avoid async race conditions
|
||||
loadStatistics()
|
||||
@@ -297,7 +316,6 @@ class SettingsViewModel: ObservableObject {
|
||||
"Asset",
|
||||
"Account",
|
||||
"Goal",
|
||||
"Transaction",
|
||||
"PredictionCache"
|
||||
]
|
||||
|
||||
@@ -361,6 +379,85 @@ 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)
|
||||
}
|
||||
|
||||
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 +502,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,6 +30,11 @@ 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>()
|
||||
@@ -37,8 +46,10 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
self.mode = mode
|
||||
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 +68,7 @@ class SnapshotFormViewModel: ObservableObject {
|
||||
if let contribution = snapshot.contribution {
|
||||
includeContribution = true
|
||||
contributionString = formatDecimalForInput(contribution.decimalValue)
|
||||
originalContribution = contribution.decimalValue
|
||||
}
|
||||
notes = snapshot.notes ?? ""
|
||||
}
|
||||
@@ -88,8 +100,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 +114,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 +129,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 +214,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
|
||||
@@ -200,21 +203,29 @@ class SourceListViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
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,6 +3,8 @@ import Charts
|
||||
|
||||
struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
var title: String = "Asset Allocation"
|
||||
var showsTargetsComparison: Bool = true
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
|
||||
@@ -12,7 +14,7 @@ struct AllocationPieChart: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Asset Allocation")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
@@ -98,7 +100,9 @@ struct AllocationPieChart: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
if showsTargetsComparison {
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
}
|
||||
} else {
|
||||
Text("No allocation data available")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -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,69 @@
|
||||
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
|
||||
|
||||
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,149 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
// MARK: - Time-series chart zoom
|
||||
//
|
||||
// Shared zoom behavior for every chart with a Date X axis. Default state is
|
||||
// "fit all" (no behavior change); zooming in narrows the visible X domain and
|
||||
// enables horizontal scrolling so the user can pan through history. Zoom is
|
||||
// driven by pinch (magnification) and by explicit +/- buttons for
|
||||
// discoverability and accessibility (HIG: never make gestures the only way).
|
||||
|
||||
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
|
||||
struct ChartZoomModel {
|
||||
/// Currently visible X-domain length in seconds; nil = show everything.
|
||||
var visibleSpan: TimeInterval?
|
||||
/// Span captured when a pinch gesture begins.
|
||||
var pinchAnchor: TimeInterval?
|
||||
|
||||
init() {
|
||||
visibleSpan = nil
|
||||
pinchAnchor = nil
|
||||
}
|
||||
|
||||
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
|
||||
private static let zoomStep = 0.6 // per button tap
|
||||
|
||||
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
|
||||
let upper = max(fullSpan, 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 zoomIn(fullSpan: TimeInterval) {
|
||||
let current = visibleSpan ?? fullSpan
|
||||
visibleSpan = clamped(current * Self.zoomStep, fullSpan: fullSpan)
|
||||
}
|
||||
|
||||
mutating func zoomOut(fullSpan: TimeInterval) {
|
||||
guard let current = visibleSpan else { return }
|
||||
visibleSpan = clamped(current / Self.zoomStep, fullSpan: fullSpan)
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
visibleSpan = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Applies zoomable behavior to a Chart with a Date X axis.
|
||||
/// - Parameters:
|
||||
/// - dates: the full set of X values (used to compute the total span)
|
||||
/// - zoom: binding to the chart's zoom state
|
||||
@ViewBuilder
|
||||
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
|
||||
let fullSpan = ChartZoomHelper.fullSpan(of: dates)
|
||||
self
|
||||
.modifier(ZoomDomainModifier(zoom: zoom, fullSpan: fullSpan))
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
var model = zoom.wrappedValue
|
||||
if model.pinchAnchor == nil {
|
||||
model.pinchAnchor = model.visibleSpan ?? fullSpan
|
||||
}
|
||||
if let anchor = model.pinchAnchor, value.magnification > 0 {
|
||||
model.visibleSpan = model.clamped(anchor / value.magnification, fullSpan: fullSpan)
|
||||
}
|
||||
zoom.wrappedValue = model
|
||||
}
|
||||
.onEnded { _ in
|
||||
zoom.wrappedValue.pinchAnchor = nil
|
||||
}
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
ChartZoomControls(zoom: zoom, fullSpan: fullSpan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ChartZoomHelper {
|
||||
static func fullSpan(of dates: [Date]) -> TimeInterval {
|
||||
guard let min = dates.min(), let max = dates.max(), max > min else {
|
||||
return ChartZoomModel.minSpan
|
||||
}
|
||||
return max.timeIntervalSince(min)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ZoomDomainModifier: ViewModifier {
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let fullSpan: TimeInterval
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let span = zoom.visibleSpan {
|
||||
content
|
||||
.chartScrollableAxes(.horizontal)
|
||||
.chartXVisibleDomain(length: span)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact +/- (and reset when zoomed) control cluster shown on top of the chart.
|
||||
struct ChartZoomControls: View {
|
||||
@Binding var zoom: ChartZoomModel
|
||||
let fullSpan: TimeInterval
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
controlButton(systemName: "minus.magnifyingglass", enabled: zoom.visibleSpan != nil) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomOut(fullSpan: fullSpan) }
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_out"))
|
||||
|
||||
Divider().frame(height: 16)
|
||||
|
||||
controlButton(systemName: "plus.magnifyingglass",
|
||||
enabled: (zoom.visibleSpan ?? fullSpan) > ChartZoomModel.minSpan) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomIn(fullSpan: fullSpan) }
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_in"))
|
||||
|
||||
if zoom.visibleSpan != nil {
|
||||
Divider().frame(height: 16)
|
||||
controlButton(systemName: "arrow.counterclockwise", enabled: true) {
|
||||
withAnimation(.easeOut(duration: 0.15)) { zoom.reset() }
|
||||
}
|
||||
.accessibilityLabel(String(localized: "chart_zoom_reset"))
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
private func controlButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(enabled ? .appPrimary : .secondary.opacity(0.4))
|
||||
.frame(width: 32, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
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)
|
||||
}
|
||||
|
||||
/// Permanent labels only when the chart stays readable (few points AND few series).
|
||||
private var showAllLabels: Bool {
|
||||
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
|
||||
return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3
|
||||
}
|
||||
|
||||
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
|
||||
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(showAllLabels ? 32 : 16)
|
||||
.annotation(position: .top, spacing: 2) {
|
||||
if showAllLabels {
|
||||
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,16 @@ import Charts
|
||||
|
||||
struct DrawdownChart: View {
|
||||
let data: [(date: Date, drawdown: Double)]
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
|
||||
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
||||
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)
|
||||
@@ -35,27 +45,57 @@ 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(data, id: \.date) { item in
|
||||
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(showAllLabels ? 36 : 20)
|
||||
.annotation(position: .bottom, spacing: 3) {
|
||||
if showAllLabels {
|
||||
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 +114,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 +137,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 +162,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 +205,16 @@ struct DrawdownStatView: View {
|
||||
|
||||
struct VolatilityChartView: View {
|
||||
let data: [(date: Date, volatility: Double)]
|
||||
@State private var zoom = ChartZoomModel()
|
||||
@State private var selectedDate: Date?
|
||||
|
||||
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
||||
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
|
||||
@@ -156,15 +232,6 @@ struct VolatilityChartView: View {
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
Text("Measures price variability over time")
|
||||
@@ -172,26 +239,40 @@ 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(data, id: \.date) { item in
|
||||
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(showAllLabels ? 36 : 20)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if showAllLabels {
|
||||
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average line
|
||||
RuleMark(y: .value("Average", averageVolatility))
|
||||
@@ -202,7 +283,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 +317,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 +355,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,16 +3,21 @@ import Charts
|
||||
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
var title: String = "Performance by Category"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !data.isEmpty {
|
||||
ChartStatsRow(stats: perfStats)
|
||||
}
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
@@ -83,6 +88,19 @@ struct PerformanceBarChart: View {
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
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 var showAllLabels: Bool {
|
||||
(viewModel.periodComparisonData.map { $0.points.count }.max() ?? 0) <= ChartLabels.alwaysShowThreshold
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private var periodChart: some View {
|
||||
let points = flatPoints
|
||||
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(showAllLabels ? 40 : 30)
|
||||
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
|
||||
spacing: 3) {
|
||||
if showAllLabels {
|
||||
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,6 +4,22 @@ import Charts
|
||||
struct PredictionChartView: View {
|
||||
let predictions: [Prediction]
|
||||
let historicalData: [(date: Date, value: Decimal)]
|
||||
@State private var selectedDate: Date?
|
||||
|
||||
private var showAllLabels: Bool {
|
||||
historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -47,7 +63,12 @@ struct PredictionChartView: View {
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(24)
|
||||
.symbolSize(showAllLabels ? 36 : 24)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if showAllLabels {
|
||||
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence interval area
|
||||
@@ -74,7 +95,34 @@ struct PredictionChartView: View {
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.symbolSize(24)
|
||||
.symbolSize(showAllLabels ? 36 : 24)
|
||||
.annotation(position: .top, spacing: 3) {
|
||||
if showAllLabels {
|
||||
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 +144,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 +182,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 +229,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 +292,14 @@ struct PredictionChartView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
ChartDataTable(
|
||||
rows: predictionTableRows,
|
||||
valueHeader: "Base",
|
||||
deltaPrevHeader: "Bull",
|
||||
deltaFirstHeader: "Bear"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
@@ -220,6 +325,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,317 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct YearOverYearChartView: View {
|
||||
@ObservedObject var viewModel: ChartsViewModel
|
||||
@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) {
|
||||
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
|
||||
|
||||
/// Two selected years × 12 months stays readable with permanent labels.
|
||||
private var showAllLabels: Bool { selectedSeries.count <= 2 }
|
||||
|
||||
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
|
||||
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(showAllLabels ? 32 : 18)
|
||||
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
|
||||
if showAllLabels {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ struct AppBackground: View {
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
// Purely decorative — must never intercept touches. The circle overflows
|
||||
// its panel's bounds (offset -35% width), and in side-by-side layouts it
|
||||
// floats OVER sibling views (e.g. the Charts sidebar on iPad), silently
|
||||
// swallowing every tap underneath.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
|
||||
struct DashboardView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@@ -10,7 +11,12 @@ struct DashboardView: View {
|
||||
@State private var showingAddSource = false
|
||||
@State private var showingCustomize = false
|
||||
@State private var sectionConfigs = DashboardLayoutStore.load()
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@State private var pendingAlertDismissed = false
|
||||
@State private var fullScreenSection: DashboardSection? = nil
|
||||
@State private var dragTargetID: String? = nil
|
||||
@State private var showingQuickUpdate = false
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
init() {
|
||||
_viewModel = StateObject(wrappedValue: DashboardViewModel())
|
||||
@@ -23,9 +29,33 @@ struct DashboardView: View {
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
if viewModel.updateStreak >= 2 {
|
||||
streakBadge
|
||||
}
|
||||
|
||||
if viewModel.hasData {
|
||||
ForEach(visibleSections) { config in
|
||||
sectionView(for: config)
|
||||
if !pendingAlertDismissed && !viewModel.sourcesNeedingUpdate.isEmpty {
|
||||
PendingUpdatesAlertBanner(
|
||||
count: viewModel.sourcesNeedingUpdate.count,
|
||||
onDismiss: {
|
||||
withAnimation {
|
||||
pendingAlertDismissed = true
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
if !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
|
||||
if horizontalSizeClass == .regular {
|
||||
iPadDashboardLayout
|
||||
} else {
|
||||
ForEach(visibleSections) { config in
|
||||
sectionView(for: config)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyDashboardView(
|
||||
@@ -37,7 +67,14 @@ struct DashboardView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
if horizontalSizeClass == .regular, let section = fullScreenSection {
|
||||
iPadFullScreenOverlay(for: section)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||
.zIndex(10)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: fullScreenSection)
|
||||
.navigationTitle("Home")
|
||||
.refreshable {
|
||||
viewModel.refreshData()
|
||||
@@ -48,6 +85,13 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingQuickUpdate = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
accountFilterMenu
|
||||
}
|
||||
@@ -89,12 +133,34 @@ struct DashboardView: View {
|
||||
.sheet(isPresented: $showingCustomize) {
|
||||
DashboardCustomizeView(configs: $sectionConfigs)
|
||||
}
|
||||
.sheet(isPresented: $showingQuickUpdate) {
|
||||
QuickUpdateView()
|
||||
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openQuickUpdate)) { _ in
|
||||
showingQuickUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var streakBadge: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(String(format: String(localized: "streak_badge"), viewModel.updateStreak))
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.cornerRadius(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
@@ -135,6 +201,127 @@ struct DashboardView: View {
|
||||
sectionConfigs.filter { $0.isVisible }
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout Helpers
|
||||
|
||||
private var iPadGridSections: [DashboardSectionConfig] {
|
||||
visibleSections.filter { $0.id != DashboardSection.totalValue.id }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iPadDashboardLayout: some View {
|
||||
// Total Portfolio Value — always full width, never in the grid
|
||||
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
||||
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
||||
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
||||
|
||||
// Other cards — 2-column grid with drag-and-drop
|
||||
if !iPadGridSections.isEmpty {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible())],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(iPadGridSections) { config in
|
||||
sectionView(for: config)
|
||||
.gridCellColumns(config.columnSpan)
|
||||
.overlay {
|
||||
if dragTargetID == config.id {
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
.draggable(config.id)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let fromID = items.first else { return false }
|
||||
return handleIPadDrop(fromID: fromID, toID: config.id)
|
||||
} isTargeted: { isTargeted in
|
||||
dragTargetID = isTargeted ? config.id : nil
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
toggleColumnSpan(for: config.id)
|
||||
} label: {
|
||||
Label(
|
||||
config.columnSpan == 2 ? "Normal width" : "Expand to full width",
|
||||
systemImage: config.columnSpan == 2 ? "rectangle" : "rectangle.expand.diagonal"
|
||||
)
|
||||
}
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
fullScreenSection = DashboardSection(rawValue: config.id)
|
||||
}
|
||||
} label: {
|
||||
Label("View full screen", systemImage: "arrow.up.left.and.arrow.down.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iPadFullScreenOverlay(for section: DashboardSection) -> some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.opacity(0.97)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
.font(.title3.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
fullScreenSection = nil
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
||||
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
||||
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
||||
|
||||
if let config = sectionConfigs.first(where: { $0.id == section.id }) {
|
||||
sectionView(for: DashboardSectionConfig(id: config.id, isVisible: true, isCollapsed: false))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: 800)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIPadDrop(fromID: String, toID: String) -> Bool {
|
||||
guard fromID != toID,
|
||||
let fromIndex = sectionConfigs.firstIndex(where: { $0.id == fromID }),
|
||||
let toIndex = sectionConfigs.firstIndex(where: { $0.id == toID }) else { return false }
|
||||
withAnimation {
|
||||
sectionConfigs.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
|
||||
}
|
||||
DashboardLayoutStore.save(sectionConfigs)
|
||||
dragTargetID = nil
|
||||
return true
|
||||
}
|
||||
|
||||
private func toggleColumnSpan(for id: String) {
|
||||
guard let index = sectionConfigs.firstIndex(where: { $0.id == id }) else { return }
|
||||
withAnimation {
|
||||
sectionConfigs[index].columnSpan = sectionConfigs[index].columnSpan == 2 ? 1 : 2
|
||||
}
|
||||
DashboardLayoutStore.save(sectionConfigs)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionView(for config: DashboardSectionConfig) -> some View {
|
||||
if let section = DashboardSection(rawValue: config.id) {
|
||||
@@ -145,14 +332,10 @@ struct DashboardView: View {
|
||||
} else {
|
||||
TotalValueCard(
|
||||
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
||||
changeText: calmModeEnabled
|
||||
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
||||
: viewModel.portfolioSummary.formattedDayChange,
|
||||
changeLabel: calmModeEnabled ? "since last update" : "today",
|
||||
isPositive: calmModeEnabled
|
||||
? viewModel.latestPortfolioChange.absolute >= 0
|
||||
: viewModel.isDayChangePositive,
|
||||
forecast: viewModel.portfolioForecast,
|
||||
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
|
||||
changeLabel: "since last check-in",
|
||||
isPositive: viewModel.latestPortfolioChange.absolute >= 0,
|
||||
forecast: showForecast ? viewModel.portfolioForecast : nil,
|
||||
isPremium: iapService.isPremium,
|
||||
onUnlockTap: {
|
||||
viewModel.showingPaywall = true
|
||||
@@ -160,17 +343,23 @@ struct DashboardView: View {
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||
isYearPositive: viewModel.isYearChangePositive,
|
||||
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0,
|
||||
onShareTap: {
|
||||
ShareService.shared.sharePortfolioValue(
|
||||
totalValue: viewModel.portfolioSummary.formattedTotalValue,
|
||||
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
|
||||
changeLabel: "since last check-in",
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
case .monthlyCheckIn:
|
||||
if config.isCollapsed {
|
||||
CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)")
|
||||
} else {
|
||||
MonthlyCheckInCard(
|
||||
lastUpdated: viewModel.formattedLastUpdate,
|
||||
lastUpdatedDate: viewModel.portfolioSummary.lastUpdated
|
||||
)
|
||||
MonthlyCheckInCard(lastUpdated: viewModel.formattedLastUpdate)
|
||||
}
|
||||
case .momentumStreaks:
|
||||
if config.isCollapsed {
|
||||
@@ -210,11 +399,12 @@ struct DashboardView: View {
|
||||
CategoryBreakdownCard(categories: viewModel.topCategories)
|
||||
}
|
||||
case .goals:
|
||||
let homeGoals = goalsViewModel.goals.filter { !GoalsViewModel.isAchieved(progress: goalsViewModel.progress(for: $0)) }
|
||||
if config.isCollapsed {
|
||||
CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active")
|
||||
CompactCard(title: "Goals", subtitle: "\(homeGoals.count) active")
|
||||
} else {
|
||||
GoalsSummaryCard(
|
||||
goals: goalsViewModel.goals,
|
||||
goals: homeGoals,
|
||||
progressProvider: goalsViewModel.progress(for:),
|
||||
currentValueProvider: goalsViewModel.totalValue(for:),
|
||||
paceStatusProvider: goalsViewModel.paceStatus(for:),
|
||||
@@ -231,17 +421,28 @@ struct DashboardView: View {
|
||||
PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate)
|
||||
}
|
||||
case .periodReturns:
|
||||
if !calmModeEnabled {
|
||||
if config.isCollapsed {
|
||||
CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange)
|
||||
} else {
|
||||
PeriodReturnsCard(
|
||||
monthChange: viewModel.portfolioSummary.formattedMonthChange,
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||
isMonthPositive: viewModel.isMonthChangePositive,
|
||||
isYearPositive: viewModel.isYearChangePositive,
|
||||
isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||
)
|
||||
}
|
||||
case .contributionsVsReturns:
|
||||
let contrib = viewModel.portfolioSummary.totalContributions
|
||||
let returns = viewModel.portfolioSummary.allTimeReturn
|
||||
if contrib > 0 || returns != 0 {
|
||||
if config.isCollapsed {
|
||||
CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange)
|
||||
CompactCard(title: "Invested vs. Returns", subtitle: contrib.currencyString)
|
||||
} else {
|
||||
PeriodReturnsCard(
|
||||
monthChange: viewModel.portfolioSummary.formattedMonthChange,
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn,
|
||||
isMonthPositive: viewModel.isMonthChangePositive,
|
||||
isYearPositive: viewModel.isYearChangePositive,
|
||||
isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0
|
||||
ContributionsVsReturnsCard(
|
||||
totalContributions: contrib,
|
||||
totalReturns: returns
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -266,12 +467,27 @@ struct TotalValueCard: View {
|
||||
var sinceInceptionChange: String?
|
||||
var isYearPositive: Bool = true
|
||||
var isSinceInceptionPositive: Bool = true
|
||||
var onShareTap: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Total Portfolio Value")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Total Portfolio Value")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
Spacer()
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
if let onShareTap {
|
||||
Button(action: onShareTap) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
@@ -371,13 +587,16 @@ struct TotalValueCard: View {
|
||||
|
||||
struct MonthlyCheckInCard: View {
|
||||
let lastUpdated: String
|
||||
let lastUpdatedDate: Date?
|
||||
@State private var showingStartOptions = false
|
||||
@State private var startDestinationActive = false
|
||||
@State private var shouldDuplicatePrevious = false
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)],
|
||||
predicate: NSPredicate(format: "completionTime != nil"),
|
||||
animation: .none
|
||||
) private var latestJournalEntry: FetchedResults<JournalEntry>
|
||||
|
||||
private var effectiveLastCheckInDate: Date? {
|
||||
MonthlyCheckInStore.latestCompletionDate() ?? lastUpdatedDate
|
||||
latestJournalEntry.first?.completionTime
|
||||
}
|
||||
|
||||
private var checkInProgress: Double {
|
||||
@@ -391,7 +610,24 @@ struct MonthlyCheckInCard: View {
|
||||
|
||||
private var nextCheckInDate: Date? {
|
||||
guard let last = effectiveLastCheckInDate else { return nil }
|
||||
return last.adding(months: 1)
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
|
||||
return effective.adding(months: 1).endOfMonth
|
||||
}
|
||||
|
||||
private var isOverdue: Bool {
|
||||
guard let next = nextCheckInDate else { return false }
|
||||
return Date() > next
|
||||
}
|
||||
|
||||
private var daysUntilDeadline: Int? {
|
||||
guard let next = nextCheckInDate else { return nil }
|
||||
return Calendar.current.dateComponents([.day], from: Date().startOfDay, to: next.startOfDay).day
|
||||
}
|
||||
|
||||
private var progressBarTint: Color {
|
||||
if isOverdue { return .red }
|
||||
if let days = daysUntilDeadline, days <= 3 { return .orange }
|
||||
return .appSecondary
|
||||
}
|
||||
|
||||
private var reminderDate: Date? {
|
||||
@@ -407,12 +643,54 @@ struct MonthlyCheckInCard: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// True during the first half of the month (before mid-month threshold) when the previous
|
||||
/// period's check-in should be offered for update instead of starting a new one.
|
||||
private var isBeforeMidMonth: Bool {
|
||||
guard effectiveLastCheckInDate != nil else { return false }
|
||||
let cal = Calendar.current
|
||||
let day = cal.component(.day, from: Date())
|
||||
let daysInMonth = cal.range(of: .day, in: .month, for: Date())?.count ?? 30
|
||||
return day < daysInMonth / 2
|
||||
}
|
||||
|
||||
/// Reference date to pass to MonthlyCheckInView depending on the current phase.
|
||||
private var navigationReferenceDate: Date {
|
||||
if isBeforeMidMonth {
|
||||
return Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||
}
|
||||
// Use day 25 so effectiveMonth always resolves to the current calendar month
|
||||
var comps = Calendar.current.dateComponents([.year, .month], from: Date())
|
||||
comps.day = 25
|
||||
return Calendar.current.date(from: comps) ?? Date()
|
||||
}
|
||||
|
||||
private var buttonLabel: String {
|
||||
if isBeforeMidMonth {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM"
|
||||
formatter.locale = .current
|
||||
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||
return String(format: NSLocalizedString("checkin_update_month", comment: ""), formatter.string(from: prev))
|
||||
}
|
||||
return NSLocalizedString("checkin_start_new", comment: "")
|
||||
}
|
||||
|
||||
private var currentMonthLabel: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "LLLL yyyy"
|
||||
if isBeforeMidMonth {
|
||||
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||
return formatter.string(from: prev)
|
||||
}
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Check-in")
|
||||
.font(.headline)
|
||||
|
||||
Text("Keep a calm, deliberate rhythm. Update your sources and add a short note.")
|
||||
Text(currentMonthLabel)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -423,13 +701,6 @@ struct MonthlyCheckInCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
AchievementsView(referenceDate: Date())
|
||||
} label: {
|
||||
Image(systemName: "trophy.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
if let reminderDate {
|
||||
Button {
|
||||
let title = String(
|
||||
@@ -450,26 +721,33 @@ struct MonthlyCheckInCard: View {
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
Button("Start") {
|
||||
showingStartOptions = true
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(.appSecondary)
|
||||
.tint(progressBarTint)
|
||||
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text("Next check-in: \(nextDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(isOverdue ? .red : .secondary)
|
||||
}
|
||||
|
||||
Button {
|
||||
startDestinationActive = true
|
||||
} label: {
|
||||
Text(buttonLabel)
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
NavigationLink(
|
||||
isActive: $startDestinationActive
|
||||
) {
|
||||
MonthlyCheckInView(duplicatePrevious: shouldDuplicatePrevious)
|
||||
MonthlyCheckInView(referenceDate: navigationReferenceDate)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -478,21 +756,6 @@ struct MonthlyCheckInCard: View {
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
.confirmationDialog(
|
||||
"Start Monthly Check-in",
|
||||
isPresented: $showingStartOptions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Start from scratch") {
|
||||
shouldDuplicatePrevious = false
|
||||
startDestinationActive = true
|
||||
}
|
||||
Button("Duplicate previous month") {
|
||||
shouldDuplicatePrevious = true
|
||||
startDestinationActive = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultReminderTime: Date {
|
||||
@@ -618,7 +881,7 @@ struct MomentumStreaksCard: View {
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
.onAppear(perform: refreshStats)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
|
||||
refreshStats()
|
||||
}
|
||||
}
|
||||
@@ -691,7 +954,7 @@ struct MomentumStreaksCompactCard: View {
|
||||
subtitle: "Streak: \(stats.currentStreak)x • Best: \(stats.bestStreak)x"
|
||||
)
|
||||
.onAppear(perform: refreshStats)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
|
||||
refreshStats()
|
||||
}
|
||||
}
|
||||
@@ -970,6 +1233,36 @@ struct EmptyDashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pending Updates Alert Banner
|
||||
|
||||
struct PendingUpdatesAlertBanner: View {
|
||||
let count: Int
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("\(count) source\(count == 1 ? "" : "s") pending update")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appWarning)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pending Updates Card
|
||||
|
||||
struct PendingUpdatesCard: View {
|
||||
@@ -989,7 +1282,7 @@ struct PendingUpdatesCard: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
ForEach(sources.prefix(3)) { source in
|
||||
ForEach(sources.prefix(3), id: \.objectID) { source in
|
||||
NavigationLink(destination: SourceDetailView(source: source, iapService: iapService)) {
|
||||
HStack {
|
||||
Circle()
|
||||
@@ -1053,6 +1346,12 @@ struct GoalsSummaryCard: View {
|
||||
ForEach(goals.prefix(2)) { goal in
|
||||
let currentValue = currentValueProvider(goal)
|
||||
let paceStatus = paceStatusProvider(goal)
|
||||
let isAchieved = GoalsViewModel.isAchieved(progress: progressProvider(goal))
|
||||
let targetUrgency = GoalsViewModel.urgencyLevel(
|
||||
targetDate: goal.targetDate,
|
||||
isBehind: paceStatus?.isBehind ?? false,
|
||||
isAchieved: isAchieved
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
@@ -1083,6 +1382,12 @@ struct GoalsSummaryCard: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target: \(targetDate.mediumDateString)")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
|
||||
}
|
||||
|
||||
if let etaText = etaProvider(goal) {
|
||||
Text(etaText)
|
||||
.font(.caption2)
|
||||
@@ -1128,6 +1433,72 @@ struct EmptyGoalsCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contributions vs Returns Card
|
||||
|
||||
struct ContributionsVsReturnsCard: View {
|
||||
let totalContributions: Decimal
|
||||
let totalReturns: Decimal
|
||||
|
||||
private var total: Decimal { totalContributions + totalReturns }
|
||||
private var contributionFraction: Double {
|
||||
guard total > 0 else { return 0.5 }
|
||||
return NSDecimalNumber(decimal: totalContributions / total).doubleValue
|
||||
}
|
||||
private var returnFraction: Double { 1 - contributionFraction }
|
||||
private var isReturnPositive: Bool { totalReturns >= 0 }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(String(localized: "contributions_vs_returns_title"))
|
||||
.font(.headline)
|
||||
|
||||
// Bar
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.appSecondary)
|
||||
.frame(width: max(4, geo.size.width * CGFloat(contributionFraction)))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(height: 16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.frame(height: 16)
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(Color.appSecondary).frame(width: 10, height: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(localized: "contributions_vs_returns_invested"))
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
Text(totalContributions.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed).frame(width: 10, height: 10)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(String(localized: "contributions_vs_returns_returns"))
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
let prefix = totalReturns >= 0 ? "+" : ""
|
||||
Text("\(prefix)\(totalReturns.currencyString)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DashboardView()
|
||||
.environmentObject(IAPService())
|
||||
|
||||
@@ -9,6 +9,7 @@ struct EvolutionChartCard: View {
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = true
|
||||
@State private var chartWidth: CGFloat = 300
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
@@ -17,6 +18,30 @@ struct EvolutionChartCard: View {
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private static let compactXAxisDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = .autoupdatingCurrent
|
||||
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// Calculates the optimal month stride so labels never overlap,
|
||||
/// using the actual rendered width of the chart instead of just data count.
|
||||
private func xAxisMonthStride(for width: CGFloat) -> Int {
|
||||
// ~50pt for Y-axis, ~44pt per "Jan 24" label
|
||||
let usableWidth = max(width - 50, 80)
|
||||
let maxLabels = max(2, Int(usableWidth / 44))
|
||||
let rawStride = max(1, Int(ceil(Double(data.count) / Double(maxLabels))))
|
||||
switch rawStride {
|
||||
case ...1: return 1
|
||||
case ...2: return 2
|
||||
case ...3: return 3
|
||||
case ...4: return 4
|
||||
case ...6: return 6
|
||||
default: return 12
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
@@ -84,8 +109,17 @@ struct EvolutionChartCard: View {
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride(for: chartWidth))) { value in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
|
||||
.foregroundStyle(Color.secondary.opacity(0.2))
|
||||
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
|
||||
.foregroundStyle(Color.secondary.opacity(0.28))
|
||||
AxisValueLabel {
|
||||
if let date = value.as(Date.self) {
|
||||
Text(date, formatter: Self.compactXAxisDateFormatter)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
@@ -124,6 +158,13 @@ struct EvolutionChartCard: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear { chartWidth = geo.size.width }
|
||||
.onChange(of: geo.size.width) { _, w in chartWidth = w }
|
||||
}
|
||||
)
|
||||
// Performance: Use GPU rendering for smoother scrolling
|
||||
.drawingGroup()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InsightsRow: View {
|
||||
let insights: [PortfolioInsight]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(String(localized: "insights_section_title"))
|
||||
.font(.headline)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(insights) { insight in
|
||||
insightChip(insight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func insightChip(_ insight: PortfolioInsight) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: insight.systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(insight.accentColor)
|
||||
Text(insight.title)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(insight.value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(insight.accentColor.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(insight.accentColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct MonthlyCheckInView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
||||
let referenceDate: Date
|
||||
let duplicatePrevious: Bool
|
||||
@State private var referenceDate: Date
|
||||
|
||||
@State private var monthlyNote: String
|
||||
@State private var starRating: Int
|
||||
@@ -12,11 +13,12 @@ struct MonthlyCheckInView: View {
|
||||
@FocusState private var noteFocused: Bool
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
@State private var addingSource: InvestmentSource?
|
||||
@State private var didApplyDuplicate = false
|
||||
@State private var showBatchUpdate = false
|
||||
@State private var showAchievementSatisfactionDialog = false
|
||||
@State private var showAppStoreReviewAlert = false
|
||||
|
||||
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
|
||||
self.referenceDate = referenceDate
|
||||
self.duplicatePrevious = duplicatePrevious
|
||||
init(referenceDate: Date = Date()) {
|
||||
_referenceDate = State(initialValue: referenceDate)
|
||||
_monthlyNote = State(initialValue: MonthlyCheckInStore.note(for: referenceDate))
|
||||
_starRating = State(initialValue: MonthlyCheckInStore.rating(for: referenceDate) ?? 0)
|
||||
_selectedMood = State(initialValue: MonthlyCheckInStore.mood(for: referenceDate))
|
||||
@@ -27,12 +29,13 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
|
||||
private var checkInProgress: Double {
|
||||
guard let last = lastCompletionDate,
|
||||
let nextDate = nextCheckInDate else { return 1 }
|
||||
let totalDays = Double(max(1, last.startOfDay.daysBetween(nextDate.startOfDay)))
|
||||
guard totalDays > 0 else { return 1 }
|
||||
let elapsedDays = Double(last.startOfDay.daysBetween(Date()))
|
||||
return min(max(elapsedDays / totalDays, 0), 1)
|
||||
guard let nextDate = nextCheckInDate else { return 1 }
|
||||
// Period starts at the first day of the month that opens the interval.
|
||||
// e.g. monthly → Feb 1; quarterly → Jan 1 (3 months ending Mar 31)
|
||||
let periodStart = nextDate.adding(months: -(checkInIntervalMonths - 1)).startOfMonth
|
||||
let totalDays = Double(max(1, periodStart.startOfDay.daysBetween(nextDate.startOfDay)))
|
||||
let elapsedDays = Double(max(0, periodStart.startOfDay.daysBetween(Date().startOfDay)))
|
||||
return min(elapsedDays / totalDays, 1)
|
||||
}
|
||||
|
||||
private var checkInIntervalMonths: Int {
|
||||
@@ -52,11 +55,43 @@ struct MonthlyCheckInView: View {
|
||||
|
||||
private var nextCheckInDate: Date? {
|
||||
guard let last = lastCompletionDate else { return nil }
|
||||
return last.adding(months: checkInIntervalMonths)
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
|
||||
return effective.adding(months: checkInIntervalMonths).endOfMonth
|
||||
}
|
||||
|
||||
private var isOverdue: Bool {
|
||||
guard let next = nextCheckInDate else { return false }
|
||||
return Date() > next
|
||||
}
|
||||
|
||||
private var daysUntilDeadline: Int? {
|
||||
guard let next = nextCheckInDate else { return nil }
|
||||
return Calendar.current.dateComponents([.day], from: Date().startOfDay, to: next.startOfDay).day
|
||||
}
|
||||
|
||||
private var progressBarTint: Color {
|
||||
if isOverdue { return .red }
|
||||
if let days = daysUntilDeadline, days <= 3 { return .orange }
|
||||
return .appSecondary
|
||||
}
|
||||
|
||||
private var canGoToNextMonth: Bool {
|
||||
guard let nextMonth = Calendar.current.date(byAdding: .month, value: 1, to: referenceDate) else { return false }
|
||||
return nextMonth <= Date()
|
||||
}
|
||||
|
||||
private func navigateMonth(offset: Int) {
|
||||
guard let newDate = Calendar.current.date(byAdding: .month, value: offset, to: referenceDate) else { return }
|
||||
referenceDate = newDate
|
||||
monthlyNote = MonthlyCheckInStore.note(for: newDate)
|
||||
starRating = MonthlyCheckInStore.rating(for: newDate) ?? 0
|
||||
selectedMood = MonthlyCheckInStore.mood(for: newDate)
|
||||
viewModel.selectedRange = DateRange.month(containing: newDate)
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
private var canAddNewCheckIn: Bool {
|
||||
lastCompletionDate == nil || checkInProgress >= 0.7
|
||||
true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -64,6 +99,7 @@ struct MonthlyCheckInView: View {
|
||||
VStack(spacing: 20) {
|
||||
headerCard
|
||||
summaryCard
|
||||
monthlyHighlightsCard
|
||||
reflectionCard
|
||||
sourcesCard
|
||||
notesCard
|
||||
@@ -71,21 +107,54 @@ struct MonthlyCheckInView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Monthly Check-in")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
navigateMonth(offset: -1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
Text(monthLabel)
|
||||
.font(.headline)
|
||||
|
||||
Button {
|
||||
navigateMonth(offset: 1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(canGoToNextMonth ? .appPrimary : .secondary.opacity(0.3))
|
||||
}
|
||||
.disabled(!canGoToNextMonth)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
shareMonthlyCheckIn()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
if duplicatePrevious, !didApplyDuplicate {
|
||||
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
|
||||
didApplyDuplicate = true
|
||||
}
|
||||
viewModel.refresh()
|
||||
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
|
||||
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
|
||||
selectedMood = MonthlyCheckInStore.mood(for: referenceDate)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
|
||||
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
|
||||
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
|
||||
selectedMood = MonthlyCheckInStore.mood(for: referenceDate)
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
@@ -104,76 +173,107 @@ struct MonthlyCheckInView: View {
|
||||
.sheet(item: $addingSource) { source in
|
||||
AddSnapshotView(source: source)
|
||||
}
|
||||
.sheet(isPresented: $showBatchUpdate) {
|
||||
viewModel.refresh()
|
||||
} content: {
|
||||
let batchSaveDate = referenceDate.isSameMonth(as: Date()) ? Date() : referenceDate.endOfMonth
|
||||
BatchUpdateView(sources: viewModel.sources, saveDate: batchSaveDate)
|
||||
}
|
||||
.onChange(of: starRating) { _, newValue in
|
||||
MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate)
|
||||
}
|
||||
.onChange(of: selectedMood) { _, newValue in
|
||||
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"checkin_enjoying_dialog_title",
|
||||
isPresented: $showAchievementSatisfactionDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(1...5, id: \.self) { value in
|
||||
Button(value == 1
|
||||
? String(localized: "rating_1_star")
|
||||
: String(format: NSLocalizedString("rating_n_stars", comment: ""), value)
|
||||
) {
|
||||
if value == 5 {
|
||||
showAppStoreReviewAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(String(localized: "not_now"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("checkin_enjoying_dialog_message")
|
||||
}
|
||||
.alert("app_store_review_title", isPresented: $showAppStoreReviewAlert) {
|
||||
Button(String(localized: "not_now"), role: .cancel) {}
|
||||
Button(String(localized: "write_review")) {
|
||||
ReviewPromptService.shared.markStoreReviewCompleted()
|
||||
openURL(ReviewPromptService.appStoreWriteReviewURL())
|
||||
}
|
||||
} message: {
|
||||
Text("app_store_review_message")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This Month")
|
||||
.font(.headline)
|
||||
if referenceDate.isSameMonth(as: Date()) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(progressBarTint)
|
||||
|
||||
if let date = lastCompletionDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("last_check_in", comment: ""),
|
||||
date.friendlyDescription
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("No check-in yet this month")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("next_check_in", comment: ""),
|
||||
nextDate.mediumDateString
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Start your first check-in anytime.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(.appSecondary)
|
||||
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("next_check_in", comment: ""),
|
||||
nextDate.mediumDateString
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Start your first check-in anytime.")
|
||||
if let completed = MonthlyCheckInStore.completionDate(for: referenceDate) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
Text("Completed \(completed.friendlyDescription)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
let previousUnlockedAchievementKeys = unlockedAchievementKeys()
|
||||
let now = Date()
|
||||
let completionDate = referenceDate.isSameMonth(as: now)
|
||||
? now
|
||||
: min(referenceDate.endOfMonth, now)
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
|
||||
ReviewPromptService.shared.recordMonthlyCheckInCompleted()
|
||||
NotificationService.shared.scheduleMonthlyCheckIn()
|
||||
viewModel.refresh()
|
||||
let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys)
|
||||
if ReviewPromptService.shared.shouldAskForAchievementSatisfaction(
|
||||
newlyUnlockedAchievementKeys: newlyUnlockedAchievementKeys
|
||||
) {
|
||||
showAchievementSatisfactionDialog = true
|
||||
}
|
||||
} label: {
|
||||
Text("Mark Check-in Complete")
|
||||
let isCompleted = MonthlyCheckInStore.completionDate(for: referenceDate) != nil
|
||||
Text(isCompleted ? "Update Check-in" : "Mark Check-in Complete")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.background(isCompleted ? Color.appSecondary.opacity(0.1) : Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.disabled(!canAddNewCheckIn)
|
||||
|
||||
if !canAddNewCheckIn {
|
||||
Text("Editing stays open. New check-ins unlock after 70% of the month.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
@@ -181,6 +281,27 @@ struct MonthlyCheckInView: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var monthLabel: String {
|
||||
Self.monthLabel(for: referenceDate, relativeTo: Date(), locale: .current)
|
||||
}
|
||||
|
||||
private func unlockedAchievementKeys() -> Set<String> {
|
||||
Set(
|
||||
MonthlyCheckInStore
|
||||
.achievementStatuses(referenceDate: referenceDate)
|
||||
.filter(\.isUnlocked)
|
||||
.map(\.id)
|
||||
)
|
||||
}
|
||||
|
||||
static func monthLabel(for date: Date, relativeTo referenceDate: Date, locale: Locale) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "LLLL yyyy"
|
||||
formatter.locale = locale
|
||||
let effectiveMonth = MonthlyCheckInStore.effectiveMonth(for: date, relativeTo: referenceDate)
|
||||
return formatter.string(from: effectiveMonth)
|
||||
}
|
||||
|
||||
private var reflectionCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
@@ -304,12 +425,113 @@ struct MonthlyCheckInView: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var sourcePerformances: [(name: String, diff: Decimal, percentage: Double)] {
|
||||
viewModel.sources.compactMap { source -> (name: String, diff: Decimal, percentage: Double)? in
|
||||
let snapshots = source.sortedSnapshotsByDateAscending
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
let latest = snapshots[snapshots.count - 1]
|
||||
let previous = snapshots[snapshots.count - 2]
|
||||
let diff = latest.decimalValue - previous.decimalValue
|
||||
let pct = previous.decimalValue > 0
|
||||
? NSDecimalNumber(decimal: diff / previous.decimalValue * 100).doubleValue
|
||||
: 0
|
||||
return (name: source.name, diff: diff, percentage: pct)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var monthlyHighlightsCard: some View {
|
||||
let perfs = sourcePerformances
|
||||
if perfs.count >= 2 {
|
||||
let best = perfs.max(by: { $0.percentage < $1.percentage })
|
||||
let worst = perfs.min(by: { $0.percentage < $1.percentage })
|
||||
let bestContributor = perfs.max(by: { abs($0.diff) < abs($1.diff) })
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Highlights")
|
||||
.font(.headline)
|
||||
|
||||
if let best {
|
||||
highlightRow(
|
||||
icon: "arrow.up.circle.fill",
|
||||
iconColor: .positiveGreen,
|
||||
label: "Best Performer",
|
||||
name: best.name,
|
||||
percentage: best.percentage,
|
||||
diff: best.diff,
|
||||
valueColor: .positiveGreen
|
||||
)
|
||||
}
|
||||
|
||||
if let worst, worst.name != best?.name {
|
||||
highlightRow(
|
||||
icon: "arrow.down.circle.fill",
|
||||
iconColor: .negativeRed,
|
||||
label: "Worst Performer",
|
||||
name: worst.name,
|
||||
percentage: worst.percentage,
|
||||
diff: worst.diff,
|
||||
valueColor: .negativeRed
|
||||
)
|
||||
}
|
||||
|
||||
if let contributor = bestContributor,
|
||||
contributor.name != best?.name {
|
||||
highlightRow(
|
||||
icon: "star.circle.fill",
|
||||
iconColor: .appAccent,
|
||||
label: "Best Contributor",
|
||||
name: contributor.name,
|
||||
percentage: contributor.percentage,
|
||||
diff: contributor.diff,
|
||||
valueColor: .financialColor(for: contributor.diff)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func highlightRow(icon: String, iconColor: Color, label: String, name: String, percentage: Double, diff: Decimal, valueColor: Color) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(iconColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(String(format: "%+.1f%%", percentage))
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(valueColor)
|
||||
Text("(\(diff.compactCurrencyString))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sourcesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Update Sources")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if viewModel.sources.count > 1 {
|
||||
Button {
|
||||
showBatchUpdate = true
|
||||
} label: {
|
||||
Label("Batch Update", systemImage: "square.and.pencil")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -320,9 +542,18 @@ struct MonthlyCheckInView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.sources) { source in
|
||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||
let latestSnapshot = source.latestSnapshot
|
||||
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
|
||||
let snapshots = source.sortedSnapshotsByDateAscending
|
||||
let previousSnapshot: Snapshot? = {
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
return snapshots[snapshots.count - 2]
|
||||
}()
|
||||
let valueDiff: Decimal? = {
|
||||
guard let latest = latestSnapshot, let previous = previousSnapshot else { return nil }
|
||||
return latest.decimalValue - previous.decimalValue
|
||||
}()
|
||||
Button {
|
||||
if updatedThisCycle, let snapshot = latestSnapshot {
|
||||
editingSnapshot = snapshot
|
||||
@@ -345,7 +576,13 @@ struct MonthlyCheckInView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(latestSnapshot?.date.relativeDescription ?? String(localized: "date_never"))
|
||||
if let diff = valueDiff, updatedThisCycle {
|
||||
Text(diff >= 0 ? "+\(diff.compactCurrencyString)" : diff.compactCurrencyString)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(diff >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
|
||||
Text(latestSnapshot?.date.relativeDayDescription ?? String(localized: "date_never"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -421,7 +658,7 @@ struct MonthlyCheckInView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.recentNotes) { snapshot in
|
||||
ForEach(viewModel.recentNotes, id: \.objectID) { snapshot in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.source?.name ?? "Source")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -444,6 +681,21 @@ struct MonthlyCheckInView: View {
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func shareMonthlyCheckIn() {
|
||||
let summary = viewModel.monthlySummary
|
||||
ShareService.shared.shareMonthlyCheckIn(summary: summary, appName: appDisplayName)
|
||||
}
|
||||
|
||||
private 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"
|
||||
}
|
||||
}
|
||||
|
||||
struct AchievementsView: View {
|
||||
@@ -493,18 +745,18 @@ struct AchievementsView: View {
|
||||
private var headerCard: some View {
|
||||
let total = max(achievementStatuses.count, 1)
|
||||
let unlockedCount = unlockedAchievements.count
|
||||
let progress = Double(unlockedCount) / Double(total)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "achievements_progress_title"))
|
||||
.font(.headline)
|
||||
ProgressView(value: progress)
|
||||
.tint(.appSecondary)
|
||||
|
||||
AchievementMilestoneBar(statuses: achievementStatuses)
|
||||
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("achievements_unlocked_count", comment: ""),
|
||||
unlockedCount,
|
||||
achievementStatuses.count
|
||||
total
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
@@ -621,6 +873,222 @@ private extension MonthlyCheckInView {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Achievement Milestone Bar
|
||||
|
||||
struct AchievementMilestoneBar: View {
|
||||
let statuses: [MonthlyCheckInAchievementStatus]
|
||||
|
||||
var body: some View {
|
||||
let sorted = statuses.sorted { $0.isUnlocked && !$1.isUnlocked }
|
||||
let unlockedCount = sorted.filter(\.isUnlocked).count
|
||||
let total = max(sorted.count, 1)
|
||||
let progress = Double(unlockedCount) / Double(total)
|
||||
|
||||
GeometryReader { geo in
|
||||
let barWidth = geo.size.width
|
||||
let circleSize: CGFloat = 18
|
||||
let barY = geo.size.height / 2
|
||||
|
||||
// Background track
|
||||
Capsule()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(width: barWidth, height: 6)
|
||||
.position(x: barWidth / 2, y: barY)
|
||||
|
||||
// Filled track
|
||||
Capsule()
|
||||
.fill(Color.positiveGreen)
|
||||
.frame(width: barWidth * progress, height: 6)
|
||||
.position(x: barWidth * progress / 2, y: barY)
|
||||
|
||||
// Milestone circles on the bar
|
||||
ForEach(Array(sorted.enumerated()), id: \.element.id) { index, status in
|
||||
let x = total == 1
|
||||
? barWidth / 2
|
||||
: circleSize / 2 + (barWidth - circleSize) * Double(index) / Double(total - 1)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(status.isUnlocked ? Color.positiveGreen : Color.gray.opacity(0.3))
|
||||
.frame(width: circleSize, height: circleSize)
|
||||
Circle()
|
||||
.stroke(Color(.systemBackground), lineWidth: 2)
|
||||
.frame(width: circleSize, height: circleSize)
|
||||
|
||||
if status.isUnlocked {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.position(x: x, y: barY)
|
||||
}
|
||||
}
|
||||
.frame(height: 24)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Batch Update View
|
||||
|
||||
struct BatchUpdateView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let sources: [InvestmentSource]
|
||||
let saveDate: Date
|
||||
@State private var values: [UUID: String] = [:]
|
||||
@State private var contributions: [UUID: String] = [:]
|
||||
@State private var savedCount = 0
|
||||
|
||||
init(sources: [InvestmentSource], saveDate: Date = Date()) {
|
||||
self.sources = sources
|
||||
self.saveDate = saveDate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(sources, id: \.objectID) { source in
|
||||
sourceRow(source)
|
||||
}
|
||||
|
||||
if filledCount > 0 {
|
||||
Section {
|
||||
Button {
|
||||
saveAll()
|
||||
} label: {
|
||||
Text(filledCount == 1
|
||||
? String(localized: "save_1_snapshot")
|
||||
: String(format: NSLocalizedString("save_n_snapshots", comment: ""), filledCount))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Batch Update")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { saveAll() }
|
||||
.disabled(filledCount == 0)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
prefillCurrentValues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceRow(_ source: InvestmentSource) -> some View {
|
||||
let currencyCode = source.account?.currency
|
||||
?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
let symbol = CurrencyFormatter.symbol(for: currencyCode)
|
||||
let previousValue = source.latestSnapshot?.decimalValue
|
||||
let isDetailed = InputMode(rawValue: source.account?.inputMode ?? "") == .detailed
|
||||
let valueBinding = Binding<String>(
|
||||
get: { values[source.id] ?? "" },
|
||||
set: { values[source.id] = $0 }
|
||||
)
|
||||
let contributionBinding = Binding<String>(
|
||||
get: { contributions[source.id] ?? "" },
|
||||
set: { contributions[source.id] = $0 }
|
||||
)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(source.category?.color ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(source.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
if let prev = previousValue {
|
||||
Text(prev.currencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(symbol)
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Current value", text: valueBinding)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(8)
|
||||
|
||||
if isDetailed {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Contribution this period (optional)", text: contributionBinding)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.appSecondary.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var filledCount: Int {
|
||||
values.values.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.count
|
||||
}
|
||||
|
||||
private func prefillCurrentValues() {
|
||||
for source in sources {
|
||||
guard values[source.id] == nil,
|
||||
let latest = source.latestSnapshot else { continue }
|
||||
let currencyCode = source.account?.currency
|
||||
?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
values[source.id] = CurrencyFormatter.formatForInput(latest.decimalValue, currencyCode: currencyCode)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAll() {
|
||||
let repository = SnapshotRepository()
|
||||
var count = 0
|
||||
|
||||
for source in sources {
|
||||
guard let input = values[source.id],
|
||||
!input.trimmingCharacters(in: .whitespaces).isEmpty else { continue }
|
||||
|
||||
let currencyCode = source.account?.currency
|
||||
?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
let symbol = CurrencyFormatter.symbol(for: currencyCode)
|
||||
|
||||
guard let parsed = CurrencyFormatter.parseUserInput(input, currencySymbol: symbol),
|
||||
parsed >= 0 else { continue }
|
||||
|
||||
let contributionInput = contributions[source.id] ?? ""
|
||||
let parsedContribution: Decimal? = contributionInput.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
? nil
|
||||
: CurrencyFormatter.parseUserInput(contributionInput, currencySymbol: symbol)
|
||||
|
||||
repository.createSnapshot(
|
||||
for: source,
|
||||
date: saveDate,
|
||||
value: parsed,
|
||||
contribution: parsedContribution
|
||||
)
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
count += 1
|
||||
}
|
||||
|
||||
savedCount = count
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
MonthlyCheckInView()
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import UIKit
|
||||
|
||||
struct QuickUpdateView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)],
|
||||
predicate: NSPredicate(format: "isActive == YES"),
|
||||
animation: .default
|
||||
) private var sources: FetchedResults<InvestmentSource>
|
||||
|
||||
@State private var values: [NSManagedObjectID: String] = [:]
|
||||
@State private var contributions: [NSManagedObjectID: String] = [:]
|
||||
@State private var isSaving = false
|
||||
@State private var saveError: String?
|
||||
|
||||
// Clipboard round-trip: copy a value in your bank app, come back, one tap fills
|
||||
// the next pending source and the focus advances — no retyping, no memorizing.
|
||||
@FocusState private var focusedSource: NSManagedObjectID?
|
||||
@State private var clipboardAmount: Decimal?
|
||||
@State private var lastSuggestedRaw: String?
|
||||
|
||||
/// First source (list order) still without a value — the "active" one.
|
||||
private var nextEmptySource: InvestmentSource? {
|
||||
sources.first { source in
|
||||
(values[source.objectID] ?? "").trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
if sources.isEmpty {
|
||||
ContentUnavailableView(
|
||||
String(localized: "quick_update_no_sources"),
|
||||
systemImage: "list.bullet",
|
||||
description: Text(String(localized: "quick_update_no_sources_body"))
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if let amount = clipboardAmount, let target = nextEmptySource {
|
||||
Section {
|
||||
Button {
|
||||
applyClipboard(amount, to: target)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.on.clipboard.fill")
|
||||
Text(String(
|
||||
format: String(localized: "quick_update_paste_suggestion"),
|
||||
amount.currencyString, target.name
|
||||
))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.listRowBackground(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(sources) { source in
|
||||
sourceRow(source)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "quick_update_section_header"))
|
||||
} footer: {
|
||||
Text(String(localized: "quick_update_section_footer"))
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "quick_update_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "cancel")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "quick_update_save")) {
|
||||
saveAll()
|
||||
}
|
||||
.disabled(isSaving || filledValues.isEmpty)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { saveError != nil },
|
||||
set: { if !$0 { saveError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { saveError = nil }
|
||||
} message: {
|
||||
Text(saveError ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
prefillContributions()
|
||||
checkClipboard()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { checkClipboard() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceRow(_ source: InvestmentSource) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(source.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if source.objectID == nextEmptySource?.objectID {
|
||||
Text(String(localized: "quick_update_next_badge"))
|
||||
.font(.caption2.weight(.bold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
if source.latestValue != .zero {
|
||||
Text(source.latestValue.currencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
TextField(
|
||||
String(localized: "quick_update_placeholder"),
|
||||
text: valueBinding(for: source)
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 120)
|
||||
.font(.subheadline)
|
||||
.focused($focusedSource, equals: source.objectID)
|
||||
}
|
||||
|
||||
if contributions[source.objectID] != nil {
|
||||
HStack {
|
||||
Text(String(localized: "quick_update_contribution_label"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
TextField(
|
||||
String(localized: "quick_update_contribution_placeholder"),
|
||||
text: contributionBinding(for: source)
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 120)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filledValues: [NSManagedObjectID: String] {
|
||||
values.filter { !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
}
|
||||
|
||||
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
|
||||
Binding(
|
||||
get: { values[source.objectID] ?? "" },
|
||||
set: { values[source.objectID] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func contributionBinding(for source: InvestmentSource) -> Binding<String> {
|
||||
Binding(
|
||||
get: { contributions[source.objectID] ?? "" },
|
||||
set: { contributions[source.objectID] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the pasteboard and offers the parsed amount for the next pending source.
|
||||
/// Skips values already suggested (or already typed) so returning to the app
|
||||
/// with the same clipboard doesn't nag.
|
||||
private func checkClipboard() {
|
||||
guard let raw = UIPasteboard.general.string, raw != lastSuggestedRaw,
|
||||
let parsed = CurrencyFormatter.parseUserInput(raw), parsed > 0,
|
||||
nextEmptySource != nil else {
|
||||
return
|
||||
}
|
||||
lastSuggestedRaw = raw
|
||||
clipboardAmount = parsed
|
||||
}
|
||||
|
||||
private func applyClipboard(_ amount: Decimal, to source: InvestmentSource) {
|
||||
values[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
|
||||
clipboardAmount = nil
|
||||
// Advance focus to the next source still pending, so the user can keep going
|
||||
// (type directly or hop to the next bank app and come back).
|
||||
if let next = nextEmptySource {
|
||||
focusedSource = next.objectID
|
||||
}
|
||||
}
|
||||
|
||||
private func prefillContributions() {
|
||||
for source in sources {
|
||||
if let amount = MonthlyContributionStore.contribution(for: source.id) {
|
||||
contributions[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAll() {
|
||||
isSaving = true
|
||||
let now = Date()
|
||||
|
||||
for source in sources {
|
||||
guard let raw = values[source.objectID],
|
||||
!raw.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||
let value = CurrencyFormatter.parseUserInput(raw) else { continue }
|
||||
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.id = UUID()
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
snapshot.date = now
|
||||
snapshot.source = source
|
||||
|
||||
if let contribRaw = contributions[source.objectID],
|
||||
!contribRaw.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||
let contrib = CurrencyFormatter.parseUserInput(contribRaw) {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contrib)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
// Reschedule source reminders so they reflect the new snapshots
|
||||
for source in sources where values[source.objectID].map({ !$0.isEmpty }) == true {
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
}
|
||||
// Keep the share-extension mirror in sync with what was just saved
|
||||
SharedQuickUpdateSync.refreshMirror()
|
||||
dismiss()
|
||||
} catch {
|
||||
saveError = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MonthlyCheckInShareCardView: View {
|
||||
let summary: MonthlySummary
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(summary.formattedMonthYear)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
Text("Monthly Check-in")
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
metricRow("Starting", summary.formattedStartingValue)
|
||||
metricRow("Ending", summary.formattedEndingValue)
|
||||
if summary.contributions != 0 {
|
||||
metricRow("Contributions", summary.formattedContributions)
|
||||
}
|
||||
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
||||
|
||||
Spacer(minLength: 0)
|
||||
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 300)
|
||||
.background(LinearGradient.appPrimaryGradient)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PortfolioValueShareCardView: View {
|
||||
let totalValue: String
|
||||
let changeText: String
|
||||
let changeLabel: String
|
||||
let yearChange: String?
|
||||
let sinceInceptionChange: String?
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Portfolio Snapshot")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
Text("Total Portfolio Value")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
Text(totalValue)
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("\(changeText) \(changeLabel)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
if let yearChange {
|
||||
metricRow("YoY", yearChange)
|
||||
}
|
||||
|
||||
if let sinceInceptionChange {
|
||||
metricRow("Since inception", sinceInceptionChange)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
shareFooter(appName: appName, qrCodeImage: qrCodeImage)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 290)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appSecondary, Color.appPrimary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func metricRow(_ title: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Powered by")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let qrCodeImage {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
} else {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.title3)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,31 @@ struct GoalEditorView: View {
|
||||
self.goal = goal
|
||||
}
|
||||
|
||||
private var currencySymbol: String {
|
||||
if let account = account, let code = account.currency, !code.isEmpty {
|
||||
return CurrencyFormatter.symbol(for: code)
|
||||
}
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
}
|
||||
|
||||
private var currencyCode: String {
|
||||
if let account = account, let code = account.currency, !code.isEmpty {
|
||||
return code
|
||||
}
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Goal name", text: $name)
|
||||
TextField("Target amount", text: $targetAmount)
|
||||
.keyboardType(.decimalPad)
|
||||
HStack {
|
||||
Text(currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Target amount", text: $targetAmount)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
|
||||
Toggle("Add target date", isOn: $includeTargetDate)
|
||||
if includeTargetDate {
|
||||
@@ -51,7 +69,7 @@ struct GoalEditorView: View {
|
||||
guard let goal, !didLoadGoal else { return }
|
||||
name = goal.name ?? ""
|
||||
if let amount = goal.targetAmount?.decimalValue {
|
||||
targetAmount = NSDecimalNumber(decimal: amount).stringValue
|
||||
targetAmount = formatDecimalForInput(amount)
|
||||
}
|
||||
if let target = goal.targetDate {
|
||||
includeTargetDate = true
|
||||
@@ -87,10 +105,57 @@ struct GoalEditorView: View {
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
let locale = CurrencyFormatter.locale(for: currencyCode)
|
||||
let stripped = value
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !stripped.isEmpty else { return nil }
|
||||
|
||||
let decimalSep = locale.decimalSeparator ?? "."
|
||||
let groupingSep = locale.groupingSeparator ?? ""
|
||||
|
||||
// Detect alternate decimal BEFORE removing separators
|
||||
let usesAlternateDecimal =
|
||||
(decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
|
||||
(decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
|
||||
|
||||
if usesAlternateDecimal {
|
||||
let normalized = stripped
|
||||
.replacingOccurrences(of: groupingSep, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = locale
|
||||
|
||||
if let result = formatter.number(from: cleaned)?.decimalValue {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback for mixed locale input
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: decimalSep, with: ".")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
return Decimal(string: cleaned)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.number(from: normalized)?.decimalValue
|
||||
}
|
||||
|
||||
private func formatDecimalForInput(_ decimal: Decimal) -> String {
|
||||
let locale = CurrencyFormatter.locale(for: currencyCode)
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = locale
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,22 +5,39 @@ struct GoalShareCardView: View {
|
||||
let progress: Double
|
||||
let currentValue: Decimal
|
||||
let targetValue: Decimal
|
||||
var targetDate: Date? = nil
|
||||
var estimatedCompletionDate: Date? = nil
|
||||
var privacyMode: Bool = false
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
private var hasExtraContent: Bool {
|
||||
targetDate != nil || estimatedCompletionDate != nil
|
||||
}
|
||||
|
||||
private var cardHeight: CGFloat {
|
||||
let base: CGFloat = hasExtraContent ? 340 : 290
|
||||
return privacyMode ? base + 10 : base
|
||||
}
|
||||
|
||||
private var displayCurrentValue: String {
|
||||
privacyMode ? "***" : currentValue.currencyString
|
||||
}
|
||||
|
||||
private var progressText: String {
|
||||
let percent = Int((progress * 100).rounded())
|
||||
return "\(percent)%"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Goal Progress")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Text(name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "sparkles")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Header with goal name
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Goal Progress")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Text(name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
@@ -32,25 +49,118 @@ struct GoalShareCardView: View {
|
||||
)
|
||||
.frame(height: 10)
|
||||
|
||||
HStack {
|
||||
Text(currentValue.currencyString)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("of \(targetValue.currencyString)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Text("\(progressText) complete")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
|
||||
if privacyMode {
|
||||
HStack {
|
||||
Text("Progress")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
Spacer()
|
||||
Text(progressText)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Text(displayCurrentValue)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("of \(targetValue.currencyString)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.right")
|
||||
Text("Track yours in Portfolio Journal")
|
||||
if let targetDate {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "calendar")
|
||||
.font(.caption)
|
||||
Text("Target: \(targetDate.mediumDateString)")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
if let estimatedCompletionDate {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.caption)
|
||||
Text("Est. completion: \(estimatedCompletionDate.mediumDateString)")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
if privacyMode {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "eye.slash")
|
||||
Text("Privacy mode enabled")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Branding footer with QR code
|
||||
VStack(spacing: 10) {
|
||||
// Divider line
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// App icon and branding
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Powered by")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text("Portfolio Journal")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// QR Code
|
||||
if let qrCodeImage {
|
||||
VStack(spacing: 4) {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
|
||||
Text("Scan to download")
|
||||
.font(.system(size: 7, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
} else {
|
||||
// Fallback if QR code generation fails
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 28))
|
||||
Text("App Store")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 320, height: 220)
|
||||
.padding(20)
|
||||
.frame(width: 320, height: cardHeight)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
@@ -74,3 +184,24 @@ struct GoalShareCardView: View {
|
||||
targetValue: 1_000_000
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("With Dates") {
|
||||
GoalShareCardView(
|
||||
name: "1M Goal",
|
||||
progress: 0.42,
|
||||
currentValue: 420_000,
|
||||
targetValue: 1_000_000,
|
||||
targetDate: Date().addingTimeInterval(365 * 24 * 60 * 60),
|
||||
estimatedCompletionDate: Date().addingTimeInterval(300 * 24 * 60 * 60)
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Privacy Mode") {
|
||||
GoalShareCardView(
|
||||
name: "1M Goal",
|
||||
progress: 0.42,
|
||||
currentValue: 420_000,
|
||||
targetValue: 1_000_000,
|
||||
privacyMode: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ struct GoalsView: View {
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
@State private var goalFilter: GoalFilter = .active
|
||||
@State private var goalToDelete: Goal?
|
||||
|
||||
private enum GoalFilter: String, CaseIterable, Identifiable {
|
||||
case active, archived, all
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .active: return String(localized: "goals_filter_active")
|
||||
case .archived: return String(localized: "goals_filter_archived")
|
||||
case .all: return String(localized: "goals_filter_all")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -12,29 +26,37 @@ struct GoalsView: View {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
if filteredGoals.isEmpty {
|
||||
emptyState(for: goalFilter)
|
||||
} else {
|
||||
Section {
|
||||
ForEach(viewModel.goals) { goal in
|
||||
ForEach(filteredGoals) { goal in
|
||||
GoalRowView(
|
||||
goal: goal,
|
||||
progress: viewModel.progress(for: goal),
|
||||
totalValue: viewModel.totalValue(for: goal),
|
||||
paceStatus: viewModel.paceStatus(for: goal)
|
||||
paceStatus: viewModel.paceStatus(for: goal),
|
||||
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
|
||||
onEdit: { editingGoal = goal }
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingGoal = goal
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteGoal(goal)
|
||||
goalToDelete = goal
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
viewModel.archiveGoal(goal)
|
||||
} label: {
|
||||
Label(
|
||||
goal.isActive ? String(localized: "goal_archive") : String(localized: "goal_unarchive"),
|
||||
systemImage: goal.isActive ? "archivebox" : "arrow.uturn.backward"
|
||||
)
|
||||
}
|
||||
.tint(goal.isActive ? .orange : .blue)
|
||||
|
||||
Button {
|
||||
editingGoal = goal
|
||||
} label: {
|
||||
@@ -50,6 +72,14 @@ struct GoalsView: View {
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
|
||||
ForEach(GoalFilter.allCases) { filter in
|
||||
Text(filter.label).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
@@ -77,23 +107,107 @@ struct GoalsView: View {
|
||||
viewModel.showAllAccounts = showAll
|
||||
viewModel.refresh()
|
||||
}
|
||||
.alert(String(localized: "goal_delete_title"), isPresented: Binding(
|
||||
get: { goalToDelete != nil },
|
||||
set: { if !$0 { goalToDelete = nil } }
|
||||
)) {
|
||||
Button(String(localized: "goal_delete_confirm"), role: .destructive) {
|
||||
if let goal = goalToDelete {
|
||||
viewModel.deleteGoal(goal)
|
||||
}
|
||||
goalToDelete = nil
|
||||
}
|
||||
Button(String(localized: "cancel"), role: .cancel) {
|
||||
goalToDelete = nil
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: "goal_delete_message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
private var filteredGoals: [Goal] {
|
||||
switch goalFilter {
|
||||
case .active:
|
||||
return viewModel.goals.filter { $0.isActive }
|
||||
case .archived:
|
||||
return viewModel.goals.filter { !$0.isActive }
|
||||
case .all:
|
||||
return viewModel.goals
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func emptyState(for filter: GoalFilter) -> some View {
|
||||
switch filter {
|
||||
case .active:
|
||||
if viewModel.goals.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
} label: {
|
||||
Text(String(localized: "goals_empty_add_cta"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "archivebox")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(localized: "goals_all_active_achieved"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Switch to \"Archived\" or \"All\" to see other goals.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
case .archived:
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "archivebox")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(localized: "goals_empty_archived"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
case .all:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,50 +216,126 @@ struct GoalRowView: View {
|
||||
let progress: Double
|
||||
let totalValue: Decimal
|
||||
let paceStatus: GoalPaceStatus?
|
||||
let estimatedCompletionDate: Date?
|
||||
let onEdit: () -> Void
|
||||
|
||||
@State private var showingShareOptions = false
|
||||
|
||||
private var isAchieved: Bool {
|
||||
GoalsViewModel.isAchieved(progress: progress)
|
||||
}
|
||||
|
||||
private var targetUrgency: GoalUrgencyLevel {
|
||||
GoalsViewModel.urgencyLevel(
|
||||
targetDate: goal.targetDate,
|
||||
isBehind: paceStatus?.isBehind ?? false,
|
||||
isAchieved: isAchieved
|
||||
)
|
||||
}
|
||||
|
||||
private var targetDateColor: Color {
|
||||
switch targetUrgency {
|
||||
case .normal:
|
||||
return .secondary
|
||||
case .warning:
|
||||
return .appWarning
|
||||
case .critical:
|
||||
return .negativeRed
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Button(action: onEdit) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(isAchieved ? .appSecondary : .primary)
|
||||
if isAchieved {
|
||||
Text("Achieved")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
progress: progress,
|
||||
currentValue: totalValue,
|
||||
targetValue: goal.targetDecimal
|
||||
tint: isAchieved ? .appSuccess : .appSecondary,
|
||||
iconColor: isAchieved ? .appSuccess : .appSecondary
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(targetDateColor)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(
|
||||
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
Button {
|
||||
showingShareOptions = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.confirmationDialog("Share Goal", isPresented: $showingShareOptions, titleVisibility: .visible) {
|
||||
Button("Share with amounts") {
|
||||
shareGoal(privacyMode: false)
|
||||
}
|
||||
Button("Share (privacy mode)") {
|
||||
shareGoal(privacyMode: true)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Choose how to share your goal progress")
|
||||
}
|
||||
}
|
||||
|
||||
private func shareGoal(privacyMode: Bool) {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
progress: progress,
|
||||
currentValue: totalValue,
|
||||
targetValue: goal.targetDecimal,
|
||||
targetDate: goal.targetDate,
|
||||
estimatedCompletionDate: estimatedCompletionDate,
|
||||
privacyMode: privacyMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,49 +7,171 @@ struct JournalView: View {
|
||||
@State private var scrubberLabel = ""
|
||||
@State private var scrubberOffset: CGFloat = 0
|
||||
@State private var currentVisibleMonth: Date?
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
if horizontalSizeClass == .regular {
|
||||
iPadJournalLayout
|
||||
} else {
|
||||
iPhoneJournalLayout
|
||||
}
|
||||
}
|
||||
|
||||
List {
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.")
|
||||
// MARK: - iPad Layout (list + inline detail)
|
||||
|
||||
private var iPadJournalLayout: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left: month list
|
||||
NavigationStack {
|
||||
journalListContent(isPad: true)
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
}
|
||||
.frame(width: 320)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right: monthly check-in detail
|
||||
NavigationStack {
|
||||
if let date = selectedDate {
|
||||
MonthlyCheckInView(referenceDate: date)
|
||||
} else {
|
||||
noMonthSelectedView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout (push navigation)
|
||||
|
||||
private var iPhoneJournalLayout: some View {
|
||||
NavigationStack {
|
||||
journalListContent(isPad: false)
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared List Content
|
||||
|
||||
private func journalListContent(isPad: Bool) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
if searchText.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.appSecondary)
|
||||
Text(String(localized: "journal_empty_title"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "journal_empty_body"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("No matching notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
}
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
if isPad {
|
||||
Button {
|
||||
selectedDate = entry.date
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(
|
||||
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
|
||||
? Color.appPrimary.opacity(0.08)
|
||||
: Color.clear
|
||||
)
|
||||
.id(entry.date)
|
||||
.onAppear { currentVisibleMonth = entry.date }
|
||||
} else {
|
||||
NavigationLink {
|
||||
MonthlyCheckInView(referenceDate: entry.date)
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
}
|
||||
.id(entry.date)
|
||||
.onAppear {
|
||||
currentVisibleMonth = entry.date
|
||||
}
|
||||
.onAppear { currentVisibleMonth = entry.date }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
monthScrubber(proxy: proxy)
|
||||
}
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
monthScrubber(proxy: proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var noMonthSelectedView: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
VStack(spacing: 32) {
|
||||
// Icon badge con gradiente
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.appSecondary, Color.appSecondary.lighter()],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.frame(width: 120, height: 120)
|
||||
.shadow(color: Color.appSecondary.opacity(0.25), radius: 28, y: 10)
|
||||
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Text("Select a Month")
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text("Choose a month from the list on the\nleft to view your check-in notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Label("Select from the list", systemImage: "arrow.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appSecondary)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(Color.appSecondary.opacity(0.2), lineWidth: 1))
|
||||
}
|
||||
.padding(48)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var filteredMonthlyNotes: [MonthlyNoteItem] {
|
||||
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasQuery = !trimmedQuery.isEmpty
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shown before onboarding on a fresh install when iCloud is available.
|
||||
/// Lets the user choose between restoring from iCloud or starting fresh.
|
||||
struct OnboardingICloudCheckView: View {
|
||||
/// Called when the user decides to start fresh (no iCloud restore).
|
||||
let onSkip: () -> Void
|
||||
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showRestartPrompt = false
|
||||
|
||||
var body: some View {
|
||||
if showRestartPrompt {
|
||||
restartPromptView
|
||||
} else {
|
||||
checkView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Check View
|
||||
|
||||
private var checkView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 28) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
.frame(width: 140, height: 140)
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.22))
|
||||
.frame(width: 100, height: 100)
|
||||
Image(systemName: "icloud.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
Text(String(localized: "icloud_check_title"))
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(String(localized: "icloud_check_description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
cloudSyncEnabled = true
|
||||
showRestartPrompt = true
|
||||
} label: {
|
||||
Label("Restore from iCloud", systemImage: "icloud.and.arrow.down")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button {
|
||||
onSkip()
|
||||
} label: {
|
||||
Text("Start Fresh")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(AppBackground())
|
||||
}
|
||||
|
||||
// MARK: - Restart Prompt View
|
||||
|
||||
private var restartPromptView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 28) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.positiveGreen.opacity(0.12))
|
||||
.frame(width: 140, height: 140)
|
||||
Circle()
|
||||
.fill(Color.positiveGreen.opacity(0.22))
|
||||
.frame(width: 100, height: 100)
|
||||
Image(systemName: "checkmark.icloud.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
Text(String(localized: "icloud_enabled_title"))
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(String(localized: "icloud_enabled_description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
// "Got it" just acknowledges — the user must close and reopen manually.
|
||||
// The button stays active so it doesn't look broken.
|
||||
Button {
|
||||
// No-op: user needs to close and reopen the app.
|
||||
// Nothing to navigate to; this session has no CloudKit container.
|
||||
} label: {
|
||||
Text("Got it")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.positiveGreen)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(AppBackground())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingICloudCheckView(onSkip: {})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user