Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,7 @@
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -24,6 +25,20 @@
|
||||
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;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -47,6 +62,8 @@
|
||||
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; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -91,6 +108,16 @@
|
||||
path = PortfolioJournalWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PortfolioJournalTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PortfolioJournalUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -102,6 +129,7 @@
|
||||
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
|
||||
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
|
||||
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
|
||||
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -114,6 +142,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0ETEST0042F31000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EUITEST042F31000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -123,6 +165,8 @@
|
||||
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */,
|
||||
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
|
||||
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
|
||||
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
|
||||
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
|
||||
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
||||
0E241E3A2F0DA93A00283E2F /* Products */,
|
||||
);
|
||||
@@ -133,6 +177,8 @@
|
||||
children = (
|
||||
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */,
|
||||
0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */,
|
||||
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
|
||||
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -158,6 +204,7 @@
|
||||
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -172,6 +219,7 @@
|
||||
0E53752A2F0FD08100F31390 /* FirebaseCore */,
|
||||
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
|
||||
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
|
||||
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
|
||||
);
|
||||
productName = PortfolioJournal;
|
||||
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
|
||||
@@ -199,6 +247,52 @@
|
||||
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";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -215,6 +309,14 @@
|
||||
0E241ECB2F0DAA3C00283E2F = {
|
||||
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 +341,8 @@
|
||||
targets = (
|
||||
0E241E382F0DA93A00283E2F /* PortfolioJournal */,
|
||||
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */,
|
||||
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
|
||||
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -258,6 +362,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0ETEST0062F31000000000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EUITEST062F31000000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -275,14 +393,61 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0ETEST0052F31000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0EUITEST052F31000000001 /* 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 */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -292,8 +457,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 = 20;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -311,7 +477,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -330,8 +496,9 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -349,7 +516,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -488,7 +655,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -501,7 +668,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -521,7 +688,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -534,7 +701,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -547,6 +714,100 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
0ETEST0082F31000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
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.3.1;
|
||||
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 = 20;
|
||||
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.3.1;
|
||||
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 = 20;
|
||||
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.3.1;
|
||||
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 = 20;
|
||||
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.3.1;
|
||||
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;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -577,6 +838,24 @@
|
||||
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;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
@@ -614,6 +893,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"
|
||||
|
||||
@@ -2,6 +2,7 @@ import UIKit
|
||||
import UserNotifications
|
||||
import FirebaseCore
|
||||
import FirebaseAnalytics
|
||||
import FirebaseCrashlytics
|
||||
import GoogleMobileAds
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
@@ -14,16 +14,29 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isUnlocked = false
|
||||
@State private var resolvedOnboardingCompleted: Bool?
|
||||
@State private var iCloudCheckDone = false
|
||||
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
|
||||
|
||||
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 {
|
||||
@@ -78,12 +91,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
|
||||
@@ -166,7 +198,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)
|
||||
|
||||
@@ -34,6 +34,22 @@ 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()
|
||||
} 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
@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 +27,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 +103,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 +160,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 +194,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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -75,7 +75,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"/>
|
||||
|
||||
@@ -21,11 +21,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]
|
||||
@@ -177,42 +213,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 +259,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 +295,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,294 @@
|
||||
"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 %@";
|
||||
@@ -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,56 @@
|
||||
"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.";
|
||||
|
||||
@@ -123,3 +123,122 @@
|
||||
// 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.";
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"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é %@";
|
||||
@@ -0,0 +1,294 @@
|
||||
"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 %@";
|
||||
@@ -0,0 +1,294 @@
|
||||
"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 %@" = "%@ に完了";
|
||||
@@ -0,0 +1,294 @@
|
||||
"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 %@";
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -1,44 +1,190 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
class GoalShareService {
|
||||
static let shared = GoalShareService()
|
||||
|
||||
/// App Store URL - auto-redirects to user's country
|
||||
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal/id6744983373")!
|
||||
|
||||
/// 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
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -49,6 +49,13 @@ class IAPService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) {
|
||||
isPremium = value
|
||||
isFamilyShared = familyShared
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
updateListenerTask?.cancel()
|
||||
}
|
||||
@@ -133,6 +140,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,6 +161,14 @@ 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
|
||||
}
|
||||
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = result {
|
||||
if transaction.productID == Self.premiumProductID {
|
||||
@@ -253,4 +275,20 @@ 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 (4 max, outcome-focused)
|
||||
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")),
|
||||
("xmark.circle",
|
||||
String(localized: "paywall_benefit_noads_title"),
|
||||
String(localized: "paywall_benefit_noads_subtitle"))
|
||||
]}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,62 @@ extension Notification.Name {
|
||||
static let didResetData = Notification.Name("didResetData")
|
||||
}
|
||||
|
||||
// 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 monthly check-in notification on the 1st of each month at 9am.
|
||||
/// Only schedules if not already pending.
|
||||
func scheduleMonthlyCheckIn() {
|
||||
guard isAuthorized else { return }
|
||||
|
||||
let identifier = "monthly_checkin"
|
||||
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_checkin_notification_title")
|
||||
content.body = String(localized: "monthly_checkin_notification_body")
|
||||
content.sound = .default
|
||||
|
||||
var components = DateComponents()
|
||||
components.day = 1
|
||||
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 check-in notification error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Refresh
|
||||
|
||||
extension NotificationService {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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"
|
||||
private let minCheckInsBetweenPrompts = 3
|
||||
private let minDaysBetweenPrompts = 90
|
||||
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,153 @@
|
||||
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) {
|
||||
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?
|
||||
) {
|
||||
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 +251,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,6 +5,7 @@ enum MonthlyCheckInStore {
|
||||
private static let completionsKey = "monthlyCheckInCompletions"
|
||||
private static let legacyLastCheckInKey = "lastCheckInDate"
|
||||
private static let entriesKey = "monthlyCheckInEntries"
|
||||
private static let graceDays = 20
|
||||
|
||||
// MARK: - Public Accessors
|
||||
|
||||
@@ -44,7 +45,7 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
static func monthKey(for date: Date) -> String {
|
||||
Self.monthFormatter.string(from: date)
|
||||
Self.monthFormatter.string(from: effectiveMonth(for: date))
|
||||
}
|
||||
|
||||
static func allNotes() -> [(date: Date, note: String)] {
|
||||
@@ -74,9 +75,70 @@ enum MonthlyCheckInStore {
|
||||
}
|
||||
|
||||
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 = monthKey(for: targetMonth)
|
||||
var entries = loadEntries()
|
||||
var didChange = false
|
||||
|
||||
let calendar = Calendar.current
|
||||
if calendar.isDate(month, inSameDayAs: completionDate),
|
||||
calendar.component(.day, from: completionDate) > graceDays {
|
||||
let completedEntries = entries.compactMap { key, entry -> (month: Date, entry: MonthlyCheckInEntry)? in
|
||||
guard let monthDate = monthFormatter.date(from: key)?.startOfMonth else { return nil }
|
||||
guard entry.completionTime != nil else { return nil }
|
||||
guard monthDate < targetMonth else { return nil }
|
||||
return (month: monthDate, entry: entry)
|
||||
}
|
||||
|
||||
if let lastCompleted = completedEntries.max(by: { $0.month < $1.month }) {
|
||||
var cursor = lastCompleted.month.adding(months: 1).startOfMonth
|
||||
while cursor < targetMonth {
|
||||
let key = monthFormatter.string(from: cursor)
|
||||
if entries[key] == nil {
|
||||
let fallbackCompletion = min(cursor.endOfMonth, completionDate)
|
||||
let entry = MonthlyCheckInEntry(
|
||||
note: lastCompleted.entry.note,
|
||||
rating: lastCompleted.entry.rating,
|
||||
mood: lastCompleted.entry.mood,
|
||||
completionTime: fallbackCompletion.timeIntervalSince1970,
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
entries[key] = entry
|
||||
didChange = true
|
||||
}
|
||||
cursor = cursor.adding(months: 1).startOfMonth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the target month entry exists.
|
||||
var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry(
|
||||
note: nil,
|
||||
rating: nil,
|
||||
mood: nil,
|
||||
completionTime: legacyCompletion(for: targetKey),
|
||||
createdAt: Date().timeIntervalSince1970
|
||||
)
|
||||
targetEntry.completionTime = completionDate.timeIntervalSince1970
|
||||
entries[targetKey] = targetEntry
|
||||
didChange = true
|
||||
|
||||
// Mark all previous pending entries as completed as well.
|
||||
for (key, entry) in entries {
|
||||
guard let entryMonth = monthFormatter.date(from: key)?.startOfMonth else { continue }
|
||||
guard entryMonth < targetMonth else { continue }
|
||||
guard entry.completionTime == nil else { continue }
|
||||
|
||||
var updated = entry
|
||||
let fallbackCompletion = min(entryMonth.endOfMonth, completionDate)
|
||||
updated.completionTime = fallbackCompletion.timeIntervalSince1970
|
||||
entries[key] = updated
|
||||
didChange = true
|
||||
}
|
||||
|
||||
guard didChange else { return }
|
||||
saveEntries(entries)
|
||||
persistLegacyMirrors(entries)
|
||||
}
|
||||
|
||||
static func latestCompletionDate() -> Date? {
|
||||
@@ -93,6 +155,21 @@ enum MonthlyCheckInStore {
|
||||
return Date(timeIntervalSince1970: legacy)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return date.startOfMonth
|
||||
}
|
||||
|
||||
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
|
||||
let cutoff = referenceDate.endOfMonth
|
||||
let entries = allEntries().filter { $0.date <= cutoff }
|
||||
|
||||
@@ -85,9 +85,12 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
@Published var selectedChartType: ChartType = .evolution
|
||||
@Published var selectedCategory: Category?
|
||||
@Published var selectedSource: InvestmentSource?
|
||||
@Published var selectedSourceIds: Set<UUID> = []
|
||||
@Published var selectedTimeRange: TimeRange = .year
|
||||
@Published var selectedAccount: Account?
|
||||
@Published var showAllAccounts = true
|
||||
@Published var selectedBreakdown: BreakdownMode = .category
|
||||
|
||||
@Published var evolutionData: [(date: Date, value: Decimal)] = []
|
||||
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
|
||||
@@ -100,6 +103,7 @@ class ChartsViewModel: ObservableObject {
|
||||
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
|
||||
@Published var volatilityData: [(date: Date, volatility: Double)] = []
|
||||
@Published var predictionData: [Prediction] = []
|
||||
@Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = []
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingPaywall = false
|
||||
@@ -111,7 +115,8 @@ class ChartsViewModel: ObservableObject {
|
||||
case month = "1M"
|
||||
case quarter = "3M"
|
||||
case halfYear = "6M"
|
||||
case year = "1Y"
|
||||
case year = "12M"
|
||||
case yearToDate = "YTD"
|
||||
case all = "All"
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -122,9 +127,33 @@ class ChartsViewModel: ObservableObject {
|
||||
case .quarter: return 3
|
||||
case .halfYear: return 6
|
||||
case .year: return 12
|
||||
case .yearToDate: return nil
|
||||
case .all: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func startDate(referenceDate: Date = Date()) -> Date? {
|
||||
switch self {
|
||||
case .month, .quarter, .halfYear, .year:
|
||||
guard let months else { return nil }
|
||||
return referenceDate.adding(months: -months).startOfDay
|
||||
case .yearToDate:
|
||||
return referenceDate.startOfYear
|
||||
case .all:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BreakdownMode: String, CaseIterable, Identifiable {
|
||||
case category = "By Category"
|
||||
case source = "By Source"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
static func supportsAllocationTargets(for breakdown: BreakdownMode) -> Bool {
|
||||
breakdown == .category
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
@@ -147,8 +176,11 @@ class ChartsViewModel: ObservableObject {
|
||||
private var lastChartType: ChartType?
|
||||
private var lastTimeRange: TimeRange?
|
||||
private var lastCategoryId: UUID?
|
||||
private var lastSourceId: UUID?
|
||||
private var lastSourceIds: Set<UUID> = []
|
||||
private var lastAccountId: UUID?
|
||||
private var lastShowAllAccounts: Bool = true
|
||||
private var lastBreakdown: BreakdownMode = .category
|
||||
private var cachedSnapshots: [Snapshot]?
|
||||
private var isUpdateInProgress = false
|
||||
|
||||
@@ -179,10 +211,12 @@ class ChartsViewModel: ObservableObject {
|
||||
// Performance: Combine all selection changes into a single debounced stream
|
||||
// This prevents multiple rapid updates when switching between views
|
||||
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
|
||||
.combineLatest($showAllAccounts)
|
||||
.combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
|
||||
.combineLatest($selectedSourceIds)
|
||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] combined, showAll in
|
||||
.sink { [weak self] outer, sourceIds in
|
||||
guard let self else { return }
|
||||
let (combined, selectedSource, selectedBreakdown, showAll) = outer
|
||||
let (chartType, category, timeRange, _) = combined
|
||||
|
||||
// Performance: Skip update if nothing meaningful changed
|
||||
@@ -190,15 +224,21 @@ class ChartsViewModel: ObservableObject {
|
||||
let hasChanges = self.lastChartType != chartType ||
|
||||
self.lastTimeRange != timeRange ||
|
||||
self.lastCategoryId != category?.id ||
|
||||
self.lastSourceId != selectedSource?.id ||
|
||||
self.lastSourceIds != sourceIds ||
|
||||
self.lastAccountId != safeSelectedAccountId ||
|
||||
self.lastShowAllAccounts != showAll
|
||||
self.lastShowAllAccounts != showAll ||
|
||||
self.lastBreakdown != selectedBreakdown
|
||||
|
||||
if hasChanges {
|
||||
self.lastChartType = chartType
|
||||
self.lastTimeRange = timeRange
|
||||
self.lastCategoryId = category?.id
|
||||
self.lastSourceId = selectedSource?.id
|
||||
self.lastSourceIds = sourceIds
|
||||
self.lastAccountId = safeSelectedAccountId
|
||||
self.lastShowAllAccounts = showAll
|
||||
self.lastBreakdown = selectedBreakdown
|
||||
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
|
||||
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
|
||||
}
|
||||
@@ -226,12 +266,25 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
selectedChartType = chartType
|
||||
let allowedRanges = availableTimeRanges(for: chartType)
|
||||
if !allowedRanges.contains(selectedTimeRange) {
|
||||
selectedTimeRange = allowedRanges.first ?? .year
|
||||
}
|
||||
FirebaseService.shared.logChartViewed(
|
||||
chartType: chartType.rawValue,
|
||||
isPremium: chartType.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
func availableTimeRanges(for chartType: ChartType) -> [TimeRange] {
|
||||
switch chartType {
|
||||
case .evolution, .performance:
|
||||
return [.all, .yearToDate, .year, .quarter]
|
||||
default:
|
||||
return [.month, .quarter, .halfYear, .year, .all]
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
|
||||
// Performance: Prevent re-entrancy
|
||||
guard !isUpdateInProgress else { return }
|
||||
@@ -244,7 +297,13 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let sources: [InvestmentSource]
|
||||
if let category = category {
|
||||
if !selectedSourceIds.isEmpty {
|
||||
sources = sourceRepository.sources.filter { source in
|
||||
selectedSourceIds.contains(source.id) && shouldIncludeSource(source)
|
||||
}
|
||||
} else if let selectedSource {
|
||||
sources = sourceRepository.sources.filter { $0.id == selectedSource.id && shouldIncludeSource($0) }
|
||||
} else if let category = category {
|
||||
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
|
||||
} else {
|
||||
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
@@ -266,6 +325,10 @@ class ChartsViewModel: ObservableObject {
|
||||
cachedSnapshots = snapshots
|
||||
}
|
||||
|
||||
if let cutoffDate = timeRange.startDate() {
|
||||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
let completedSnapshots = filterSnapshotsForCharts(
|
||||
sources: sources,
|
||||
snapshots: snapshots
|
||||
@@ -281,10 +344,15 @@ class ChartsViewModel: ObservableObject {
|
||||
)
|
||||
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
|
||||
case .allocation:
|
||||
calculateAllocationData(for: sources)
|
||||
calculateAllocationData(for: sources, breakdown: selectedBreakdown)
|
||||
calculateAllocationEvolutionData(from: completedSnapshots)
|
||||
case .performance:
|
||||
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
|
||||
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource)
|
||||
calculatePerformanceData(
|
||||
for: sources,
|
||||
snapshotsBySource: completedSnapshotsBySource,
|
||||
breakdown: selectedBreakdown
|
||||
)
|
||||
case .contributions:
|
||||
calculateContributionsData(from: completedSnapshots)
|
||||
case .rollingReturn:
|
||||
@@ -306,6 +374,10 @@ class ChartsViewModel: ObservableObject {
|
||||
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||
selectedCategory = nil
|
||||
}
|
||||
if let selected = selectedSource,
|
||||
!availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
|
||||
selectedSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
func availableCategories(
|
||||
@@ -317,13 +389,26 @@ class ChartsViewModel: ObservableObject {
|
||||
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
|
||||
|
||||
switch chartType {
|
||||
case .evolution, .prediction:
|
||||
case .evolution, .prediction, .allocation, .performance:
|
||||
return filtered
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func availableSources(
|
||||
for chartType: ChartType,
|
||||
sources: [InvestmentSource]? = nil
|
||||
) -> [InvestmentSource] {
|
||||
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
switch chartType {
|
||||
case .evolution, .allocation, .performance:
|
||||
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
|
||||
if showAllAccounts || selectedAccount == nil {
|
||||
return true
|
||||
@@ -341,6 +426,22 @@ class ChartsViewModel: ObservableObject {
|
||||
return selected.id
|
||||
}
|
||||
|
||||
/// Source-specific color palette (distinct from category colors, same order as Color.sourceColors)
|
||||
private static let sourceColorHexes: [String] = [
|
||||
"#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899",
|
||||
"#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981"
|
||||
]
|
||||
|
||||
/// Assigns a unique color to each source based on its sorted position among all sources.
|
||||
private func sourceColorMap(for sources: [InvestmentSource]) -> [UUID: String] {
|
||||
let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
var map: [UUID: String] = [:]
|
||||
for (index, source) in sorted.enumerated() {
|
||||
map[source.id] = Self.sourceColorHexes[index % Self.sourceColorHexes.count]
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private func categoriesForStackedChart(
|
||||
sources: [InvestmentSource],
|
||||
selectedCategory: Category?
|
||||
@@ -404,13 +505,25 @@ class ChartsViewModel: ObservableObject {
|
||||
return sampled
|
||||
}
|
||||
|
||||
// MARK: - Effective Month Mapping
|
||||
|
||||
/// Maps a snapshot date to its effective check-in month.
|
||||
/// Snapshots on days 1–20 are attributed to the previous month's check-in.
|
||||
private func chartMonth(for snapshotDate: Date) -> DateComponents {
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
|
||||
return Calendar.current.dateComponents([.year, .month], from: effective)
|
||||
}
|
||||
|
||||
private func chartMonthStart(for snapshotDate: Date) -> Date {
|
||||
MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate)
|
||||
}
|
||||
|
||||
// MARK: - Chart Calculations
|
||||
|
||||
private func calculateEvolutionData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
chartMonth(for: snapshot.date)
|
||||
}
|
||||
|
||||
var series: [(date: Date, value: Decimal)] = []
|
||||
@@ -479,21 +592,104 @@ class ChartsViewModel: ObservableObject {
|
||||
categoryEvolutionData = points
|
||||
}
|
||||
|
||||
private func calculateAllocationData(for sources: [InvestmentSource]) {
|
||||
let categories = categoryRepository.categories
|
||||
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
private func calculateAllocationData(for sources: [InvestmentSource], breakdown: BreakdownMode) {
|
||||
switch breakdown {
|
||||
case .category:
|
||||
let categories = categoryRepository.categories
|
||||
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
allocationData = categories.compactMap { category in
|
||||
let categorySources = valuesByCategory[category.id] ?? []
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
guard categoryValue > 0 else { return nil }
|
||||
allocationData = categories.compactMap { category in
|
||||
let categorySources = valuesByCategory[category.id] ?? []
|
||||
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
guard categoryValue > 0 else { return nil }
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
value: categoryValue,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
return (
|
||||
category: category.name,
|
||||
value: categoryValue,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
case .source:
|
||||
let colorMap = sourceColorMap(for: sources)
|
||||
allocationData = sources.compactMap { source in
|
||||
guard source.latestValue > 0 else { return nil }
|
||||
return (
|
||||
category: source.name,
|
||||
value: source.latestValue,
|
||||
color: colorMap[source.id] ?? "#6B7280"
|
||||
)
|
||||
}.sorted { $0.value > $1.value }
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
chartMonth(for: snapshot.date)
|
||||
}
|
||||
|
||||
let sortedMonths = groupedByMonth.keys.sorted {
|
||||
let d1 = ($0.year ?? 0) * 100 + ($0.month ?? 0)
|
||||
let d2 = ($1.year ?? 0) * 100 + ($1.month ?? 0)
|
||||
return d1 < d2
|
||||
}
|
||||
|
||||
// First pass: compute totals per category across all months for stable ordering
|
||||
var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:]
|
||||
var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = []
|
||||
|
||||
for monthKey in sortedMonths {
|
||||
guard let monthSnapshots = groupedByMonth[monthKey],
|
||||
let monthDate = Calendar.current.date(from: monthKey) else { continue }
|
||||
|
||||
var categoryTotals: [String: (value: Decimal, color: String)] = [:]
|
||||
var sourceLatest: [UUID: Snapshot] = [:]
|
||||
|
||||
for snapshot in monthSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
if let existing = sourceLatest[sourceId] {
|
||||
if snapshot.date > existing.date {
|
||||
sourceLatest[sourceId] = snapshot
|
||||
}
|
||||
} else {
|
||||
sourceLatest[sourceId] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
for (_, snapshot) in sourceLatest {
|
||||
let categoryName = snapshot.source?.category?.name ?? "Other"
|
||||
let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280"
|
||||
let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex)
|
||||
categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex)
|
||||
|
||||
let globalExisting = globalCategoryTotals[categoryName] ?? (total: 0, color: colorHex)
|
||||
globalCategoryTotals[categoryName] = (total: globalExisting.total + snapshot.decimalValue, color: colorHex)
|
||||
}
|
||||
|
||||
let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value }
|
||||
guard total > 0 else { continue }
|
||||
monthlyData.append((date: monthDate, categories: categoryTotals))
|
||||
}
|
||||
|
||||
// Stable category order based on overall totals (largest first)
|
||||
let stableCategoryOrder = globalCategoryTotals
|
||||
.sorted { $0.value.total > $1.value.total }
|
||||
.map { $0.key }
|
||||
|
||||
// Second pass: emit data points in stable order
|
||||
var result: [(date: Date, category: String, percentage: Double, color: String)] = []
|
||||
for month in monthlyData {
|
||||
let total = month.categories.values.reduce(Decimal.zero) { $0 + $1.value }
|
||||
guard total > 0 else { continue }
|
||||
|
||||
for category in stableCategoryOrder {
|
||||
guard let info = month.categories[category] else { continue }
|
||||
let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue
|
||||
result.append((date: month.date, category: category, percentage: percentage, color: info.color))
|
||||
}
|
||||
}
|
||||
|
||||
allocationEvolutionData = result
|
||||
}
|
||||
|
||||
private func completedMonthKeys(
|
||||
@@ -505,8 +701,7 @@ class ChartsViewModel: ObservableObject {
|
||||
guard !sourceIds.isEmpty else { return [] }
|
||||
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
chartMonth(for: snapshot.date)
|
||||
}
|
||||
|
||||
var completed: Set<DateComponents> = []
|
||||
@@ -530,7 +725,7 @@ class ChartsViewModel: ObservableObject {
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
return snapshots
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
@@ -540,12 +735,11 @@ class ChartsViewModel: ObservableObject {
|
||||
)
|
||||
|
||||
return snapshots.filter { snapshot in
|
||||
let monthDate = snapshot.date.startOfMonth
|
||||
let monthDate = chartMonthStart(for: snapshot.date)
|
||||
if monthDate <= lastCompleted {
|
||||
return true
|
||||
}
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
let key = DateComponents(year: components.year, month: components.month)
|
||||
let key = chartMonth(for: snapshot.date)
|
||||
return completedMonthsAfter.contains(key)
|
||||
}
|
||||
}
|
||||
@@ -561,40 +755,66 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
private func calculatePerformanceData(
|
||||
for sources: [InvestmentSource],
|
||||
snapshotsBySource: [UUID: [Snapshot]]
|
||||
snapshotsBySource: [UUID: [Snapshot]],
|
||||
breakdown: BreakdownMode
|
||||
) {
|
||||
let categories = categoryRepository.categories
|
||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
switch breakdown {
|
||||
case .category:
|
||||
let categories = categoryRepository.categories
|
||||
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
|
||||
|
||||
performanceData = categories.compactMap { category in
|
||||
let categorySources = sourcesByCategory[category.id] ?? []
|
||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
performanceData = categories.compactMap { category in
|
||||
let categorySources = sourcesByCategory[category.id] ?? []
|
||||
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
|
||||
let id = source.id
|
||||
return snapshotsBySource[id]
|
||||
}.flatMap { $0 }
|
||||
guard snapshots.count >= 2 else { return nil }
|
||||
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
return (
|
||||
category: category.name,
|
||||
cagr: cagr,
|
||||
color: category.colorHex
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
case .source:
|
||||
let colorMap = sourceColorMap(for: sources)
|
||||
performanceData = sources.compactMap { source in
|
||||
guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil }
|
||||
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
|
||||
guard let first = monthlyTotals.first,
|
||||
let last = monthlyTotals.last,
|
||||
first.totalValue > 0 else { return nil }
|
||||
|
||||
let cagr = calculationService.calculateCAGR(
|
||||
startValue: first.totalValue,
|
||||
endValue: last.totalValue,
|
||||
startDate: first.date,
|
||||
endDate: last.date
|
||||
)
|
||||
|
||||
return (
|
||||
category: source.name,
|
||||
cagr: cagr,
|
||||
color: colorMap[source.id] ?? "#6B7280"
|
||||
)
|
||||
}.sorted { $0.cagr > $1.cagr }
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateContributionsData(from snapshots: [Snapshot]) {
|
||||
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
||||
let grouped = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
|
||||
contributionsData = grouped.map { date, items in
|
||||
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
return (date: date, amount: total)
|
||||
@@ -658,7 +878,7 @@ class ChartsViewModel: ObservableObject {
|
||||
|
||||
private func calculateCashflowData(from snapshots: [Snapshot]) {
|
||||
let monthlyTotals = monthlyTotals(from: snapshots)
|
||||
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
|
||||
let contributionsByMonth = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) }
|
||||
.mapValues { items in
|
||||
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
|
||||
}
|
||||
@@ -736,36 +956,25 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
|
||||
guard !months.isEmpty else { return [] }
|
||||
|
||||
var snapshotsBySource: [UUID: [Snapshot]] = [:]
|
||||
for snapshot in sortedSnapshots {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
snapshotsBySource[sourceId, default: []].append(snapshot)
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||
chartMonthStart(for: snapshot.date)
|
||||
}
|
||||
|
||||
var indices: [UUID: Int] = [:]
|
||||
let months = groupedByMonth.keys.sorted()
|
||||
guard !months.isEmpty else { return [] }
|
||||
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||
|
||||
for (index, month) in months.enumerated() {
|
||||
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
|
||||
var total: Decimal = 0
|
||||
|
||||
for (sourceId, sourceSnapshots) in snapshotsBySource {
|
||||
var currentIndex = indices[sourceId] ?? 0
|
||||
var latest: Snapshot?
|
||||
|
||||
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
|
||||
latest = sourceSnapshots[currentIndex]
|
||||
currentIndex += 1
|
||||
for month in months {
|
||||
if let monthSnapshots = groupedByMonth[month] {
|
||||
for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) {
|
||||
guard let sourceId = snapshot.source?.id else { continue }
|
||||
latestBySource[sourceId] = snapshot
|
||||
}
|
||||
|
||||
indices[sourceId] = currentIndex
|
||||
total += latest?.decimalValue ?? 0
|
||||
}
|
||||
|
||||
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
|
||||
totals.append((date: month, totalValue: total))
|
||||
}
|
||||
|
||||
@@ -775,8 +984,7 @@ class ChartsViewModel: ObservableObject {
|
||||
private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
|
||||
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
|
||||
return DateComponents(year: components.year, month: components.month)
|
||||
chartMonth(for: snapshot.date)
|
||||
}
|
||||
|
||||
var totals: [(date: Date, totalValue: Decimal)] = []
|
||||
|
||||
@@ -195,7 +195,11 @@ 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()
|
||||
@@ -360,7 +364,7 @@ class DashboardViewModel: ObservableObject {
|
||||
snapshots: [Snapshot]
|
||||
) -> [Snapshot] {
|
||||
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
|
||||
return []
|
||||
return snapshots
|
||||
}
|
||||
|
||||
let completedMonthsAfter = completedMonthKeys(
|
||||
@@ -371,6 +375,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 +390,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 +398,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() {
|
||||
|
||||
@@ -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,7 @@ class GoalsViewModel: ObservableObject {
|
||||
goalRepository.deleteGoal(goal)
|
||||
}
|
||||
|
||||
private func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
func estimateCompletionDate(for goal: Goal) -> Date? {
|
||||
// Performance: Use cached completion date if available
|
||||
if let cached = cachedCompletionDates[goal.id] {
|
||||
return cached
|
||||
@@ -288,3 +313,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,52 @@ 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)
|
||||
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
|
||||
|
||||
@@ -37,8 +41,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 ?? "") {
|
||||
@@ -88,8 +94,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 +108,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 +123,9 @@ 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)
|
||||
}
|
||||
|
||||
var formattedValue: String {
|
||||
@@ -207,6 +202,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ struct ChartsContainerView: View {
|
||||
@StateObject private var viewModel: ChartsViewModel
|
||||
@StateObject private var goalsViewModel = GoalsViewModel()
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
|
||||
@@ -21,18 +22,16 @@ struct ChartsContainerView: View {
|
||||
// Chart Type Selector
|
||||
chartTypeSelector
|
||||
|
||||
// Time Range Selector
|
||||
if viewModel.selectedChartType != .allocation &&
|
||||
viewModel.selectedChartType != .performance &&
|
||||
viewModel.selectedChartType != .riskReturn {
|
||||
timeRangeSelector
|
||||
// Paywall nudge for free users
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
|
||||
}
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
if viewModel.selectedChartType == .evolution ||
|
||||
viewModel.selectedChartType == .prediction {
|
||||
categoryFilter
|
||||
}
|
||||
// Unified Filters
|
||||
filtersSection
|
||||
|
||||
// Chart Content
|
||||
chartContent
|
||||
@@ -82,7 +81,7 @@ struct ChartsContainerView: View {
|
||||
private var chartTypeSelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)) { chartType in
|
||||
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled).filter { showForecast || $0 != .prediction }) { chartType in
|
||||
ChartTypeButton(
|
||||
chartType: chartType,
|
||||
isSelected: viewModel.selectedChartType == chartType,
|
||||
@@ -97,11 +96,79 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified Filters Section
|
||||
|
||||
private var hasAnyFilter: Bool {
|
||||
let chartType = viewModel.selectedChartType
|
||||
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
|
||||
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
|
||||
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
|
||||
let hasBreakdown = chartType == .allocation || chartType == .performance
|
||||
return hasTimeRange || hasCategories || hasSources || hasBreakdown
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var filtersSection: some View {
|
||||
if hasAnyFilter {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
let chartType = viewModel.selectedChartType
|
||||
|
||||
// Time Range
|
||||
if chartType != .allocation && chartType != .riskReturn {
|
||||
filterRow(icon: "calendar", label: "Period") {
|
||||
timeRangeSelector
|
||||
}
|
||||
}
|
||||
|
||||
// Breakdown (category vs source)
|
||||
if chartType == .allocation || chartType == .performance {
|
||||
filterRow(icon: "square.grid.2x2", label: "Group") {
|
||||
breakdownSelector
|
||||
}
|
||||
}
|
||||
|
||||
// Category
|
||||
let availableCategories = viewModel.availableCategories(for: chartType)
|
||||
if !availableCategories.isEmpty {
|
||||
filterRow(icon: "tag", label: "Category") {
|
||||
categoryFilter
|
||||
}
|
||||
}
|
||||
|
||||
// Source
|
||||
let availableSources = viewModel.availableSources(for: chartType)
|
||||
if !availableSources.isEmpty {
|
||||
filterRow(icon: "building.2", label: "Source") {
|
||||
sourceFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func filterRow<Content: View>(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(label)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Time Range Selector
|
||||
|
||||
private var timeRangeSelector: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.TimeRange.allCases) { range in
|
||||
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
|
||||
Button {
|
||||
viewModel.selectedTimeRange = range
|
||||
} label: {
|
||||
@@ -158,6 +225,7 @@ struct ChartsContainerView: View {
|
||||
ForEach(availableCategories) { category in
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
viewModel.selectedSource = nil
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
@@ -186,6 +254,104 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Filter
|
||||
|
||||
@ViewBuilder
|
||||
private var sourceFilter: some View {
|
||||
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
|
||||
if !availableSources.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if availableSources.count > 1 {
|
||||
Button {
|
||||
viewModel.selectedSource = nil
|
||||
viewModel.selectedSourceIds.removeAll()
|
||||
} label: {
|
||||
Text("All Sources")
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
|
||||
let sourceId = source.id
|
||||
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
|
||||
let sourceColor = Color.sourceColor(at: index)
|
||||
Button {
|
||||
viewModel.selectedSource = nil
|
||||
if isSelected {
|
||||
viewModel.selectedSourceIds.remove(sourceId)
|
||||
} else {
|
||||
viewModel.selectedSourceIds.insert(sourceId)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(sourceColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(source.name)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
isSelected
|
||||
? sourceColor
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
isSelected
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Breakdown Selector
|
||||
|
||||
private var breakdownSelector: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
|
||||
Button {
|
||||
viewModel.selectedBreakdown = mode
|
||||
} label: {
|
||||
Text(mode.rawValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
viewModel.selectedBreakdown == mode
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedBreakdown == mode
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Content
|
||||
|
||||
@ViewBuilder
|
||||
@@ -204,9 +370,22 @@ struct ChartsContainerView: View {
|
||||
goals: goalsViewModel.goals
|
||||
)
|
||||
case .allocation:
|
||||
AllocationPieChart(data: viewModel.allocationData)
|
||||
VStack(spacing: 20) {
|
||||
AllocationPieChart(
|
||||
data: viewModel.allocationData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation",
|
||||
showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown)
|
||||
)
|
||||
|
||||
if !viewModel.allocationEvolutionData.isEmpty {
|
||||
AllocationEvolutionChart(data: viewModel.allocationEvolutionData)
|
||||
}
|
||||
}
|
||||
case .performance:
|
||||
PerformanceBarChart(data: viewModel.performanceData)
|
||||
PerformanceBarChart(
|
||||
data: viewModel.performanceData,
|
||||
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
|
||||
)
|
||||
case .contributions:
|
||||
ContributionsChartView(data: viewModel.contributionsData)
|
||||
case .rollingReturn:
|
||||
@@ -347,6 +526,28 @@ struct EvolutionChartView: View {
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private static let compactXAxisDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = .autoupdatingCurrent
|
||||
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private var xAxisMonthStride: Int {
|
||||
switch data.count {
|
||||
case ...8:
|
||||
return 1
|
||||
case ...16:
|
||||
return 2
|
||||
case ...30:
|
||||
return 3
|
||||
case ...48:
|
||||
return 4
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedCategoryData: [CategoryEvolutionPoint] {
|
||||
guard !categoryData.isEmpty else { return [] }
|
||||
|
||||
@@ -451,8 +652,17 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride)) { 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 {
|
||||
@@ -809,6 +1019,104 @@ struct CashflowStackedChartView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation Evolution Chart
|
||||
|
||||
struct AllocationEvolutionDataPoint: Identifiable {
|
||||
let id: String
|
||||
let date: Date
|
||||
let category: String
|
||||
let percentage: Double
|
||||
let color: String
|
||||
}
|
||||
|
||||
struct AllocationEvolutionChart: View {
|
||||
let data: [(date: Date, category: String, percentage: Double, color: String)]
|
||||
|
||||
private var identifiableData: [AllocationEvolutionDataPoint] {
|
||||
data.enumerated().map { index, item in
|
||||
AllocationEvolutionDataPoint(
|
||||
id: "\(index)-\(item.category)",
|
||||
date: item.date,
|
||||
category: item.category,
|
||||
percentage: item.percentage,
|
||||
color: item.color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Allocation Over Time")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
chartView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
Text("Not enough data to show allocation evolution.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
}
|
||||
|
||||
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
|
||||
private var stableCategoryNames: [String] {
|
||||
var seen = Set<String>()
|
||||
var ordered: [String] = []
|
||||
for item in data {
|
||||
if seen.insert(item.category).inserted {
|
||||
ordered.append(item.category)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private var stableCategoryColors: [Color] {
|
||||
stableCategoryNames.map { name in
|
||||
if let hex = data.first(where: { $0.category == name })?.color {
|
||||
return Color(hex: hex) ?? .gray
|
||||
}
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart(identifiableData) { item in
|
||||
BarMark(
|
||||
x: .value("Date", item.date, unit: .month),
|
||||
y: .value("Percentage", item.percentage)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.category))
|
||||
}
|
||||
.chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let pct = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", pct))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChartsContainerView(iapService: IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
|
||||
@@ -3,10 +3,11 @@ 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)")
|
||||
|
||||
@@ -11,6 +11,8 @@ struct DashboardView: View {
|
||||
@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
|
||||
|
||||
init() {
|
||||
_viewModel = StateObject(wrappedValue: DashboardViewModel())
|
||||
@@ -24,6 +26,18 @@ struct DashboardView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
if viewModel.hasData {
|
||||
if !pendingAlertDismissed && !viewModel.sourcesNeedingUpdate.isEmpty {
|
||||
PendingUpdatesAlertBanner(
|
||||
count: viewModel.sourcesNeedingUpdate.count,
|
||||
onDismiss: {
|
||||
withAnimation {
|
||||
pendingAlertDismissed = true
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
ForEach(visibleSections) { config in
|
||||
sectionView(for: config)
|
||||
}
|
||||
@@ -148,11 +162,11 @@ struct DashboardView: View {
|
||||
changeText: calmModeEnabled
|
||||
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
||||
: viewModel.portfolioSummary.formattedDayChange,
|
||||
changeLabel: calmModeEnabled ? "since last update" : "today",
|
||||
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
||||
isPositive: calmModeEnabled
|
||||
? viewModel.latestPortfolioChange.absolute >= 0
|
||||
: viewModel.isDayChangePositive,
|
||||
forecast: viewModel.portfolioForecast,
|
||||
forecast: showForecast ? viewModel.portfolioForecast : nil,
|
||||
isPremium: iapService.isPremium,
|
||||
onUnlockTap: {
|
||||
viewModel.showingPaywall = true
|
||||
@@ -160,7 +174,18 @@ 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: calmModeEnabled
|
||||
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
||||
: viewModel.portfolioSummary.formattedDayChange,
|
||||
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
||||
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
||||
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
case .monthlyCheckIn:
|
||||
@@ -210,11 +235,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:),
|
||||
@@ -266,12 +292,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))
|
||||
@@ -372,12 +413,11 @@ 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
|
||||
@State private var cachedCompletionDate: Date?
|
||||
|
||||
private var effectiveLastCheckInDate: Date? {
|
||||
MonthlyCheckInStore.latestCompletionDate() ?? lastUpdatedDate
|
||||
cachedCompletionDate ?? lastUpdatedDate
|
||||
}
|
||||
|
||||
private var checkInProgress: Double {
|
||||
@@ -391,7 +431,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 +464,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 +522,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 +542,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,20 +577,13 @@ 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
|
||||
.onAppear {
|
||||
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
|
||||
}
|
||||
.onChange(of: startDestinationActive) { _, isActive in
|
||||
if !isActive {
|
||||
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
|
||||
}
|
||||
Button("Duplicate previous month") {
|
||||
shouldDuplicatePrevious = true
|
||||
startDestinationActive = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,6 +1062,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 +1111,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 +1175,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 +1211,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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
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 +12,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 +28,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 +54,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 +98,7 @@ struct MonthlyCheckInView: View {
|
||||
VStack(spacing: 20) {
|
||||
headerCard
|
||||
summaryCard
|
||||
monthlyHighlightsCard
|
||||
reflectionCard
|
||||
sourcesCard
|
||||
notesCard
|
||||
@@ -71,16 +106,44 @@ 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
|
||||
@@ -104,76 +167,106 @@ 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()
|
||||
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 +274,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 +418,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 +535,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 +569,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 +651,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 +674,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 +738,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 +866,221 @@ 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
|
||||
)
|
||||
count += 1
|
||||
}
|
||||
|
||||
savedCount = count
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
MonthlyCheckInView()
|
||||
|
||||
@@ -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,7 @@ struct GoalsView: View {
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
@State private var showAchievedGoals = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -12,21 +13,23 @@ struct GoalsView: View {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
if filteredGoals.isEmpty {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
hiddenAchievedState
|
||||
}
|
||||
} 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) {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteGoal(goal)
|
||||
@@ -50,6 +53,12 @@ struct GoalsView: View {
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") {
|
||||
showAchievedGoals.toggle()
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
@@ -80,6 +89,11 @@ struct GoalsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredGoals: [Goal] {
|
||||
guard !showAchievedGoals else { return viewModel.goals }
|
||||
return viewModel.goals.filter { !viewModel.isAchieved($0) }
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
@@ -95,6 +109,22 @@ struct GoalsView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
private var hiddenAchievedState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "party.popper")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.appSecondary)
|
||||
Text("All visible goals are achieved")
|
||||
.font(.headline)
|
||||
Text("Tap \"Show Achieved\" to review completed goals.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalRowView: View {
|
||||
@@ -102,50 +132,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {})
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var onboardingCompleted: Bool
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@State private var useSampleData = true
|
||||
@State private var useSampleData = false
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showingImportSheet = false
|
||||
@@ -13,28 +14,28 @@ struct OnboardingView: View {
|
||||
|
||||
private let pages: [OnboardingPage] = [
|
||||
OnboardingPage(
|
||||
icon: "chart.pie.fill",
|
||||
title: "Long-Term Tracking",
|
||||
description: "A calm, offline-first portfolio tracker for investors who update monthly.",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: String(localized: "onboarding_clarity_title"),
|
||||
description: String(localized: "onboarding_clarity_desc"),
|
||||
color: .appPrimary
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "calendar.circle.fill",
|
||||
title: "Monthly Check-ins",
|
||||
description: "Build a deliberate habit. Update sources, log contributions, and add a short note.",
|
||||
icon: "checkmark.circle.fill",
|
||||
title: String(localized: "onboarding_habit_title"),
|
||||
description: String(localized: "onboarding_habit_desc"),
|
||||
color: .positiveGreen
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "bell.badge.fill",
|
||||
title: "Gentle Reminders",
|
||||
description: "Get a monthly nudge to review your portfolio without realtime noise.",
|
||||
color: .appWarning
|
||||
icon: "leaf.fill",
|
||||
title: String(localized: "onboarding_calm_title"),
|
||||
description: String(localized: "onboarding_calm_desc"),
|
||||
color: .appSecondary
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "leaf.fill",
|
||||
title: "Calm Mode",
|
||||
description: "Hide short-term swings and focus on contributions and long-term growth.",
|
||||
color: .appSecondary
|
||||
icon: "flag.checkered",
|
||||
title: String(localized: "onboarding_goals_title"),
|
||||
description: String(localized: "onboarding_goals_desc"),
|
||||
color: .appWarning
|
||||
)
|
||||
]
|
||||
|
||||
@@ -220,88 +221,80 @@ struct OnboardingQuickStartView: View {
|
||||
let onAddSource: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
Spacer().frame(height: 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
// Header
|
||||
VStack(spacing: 10) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
Text("Quick Start")
|
||||
.font(.title.weight(.bold))
|
||||
Text(String(localized: "onboarding_quickstart_title"))
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Pick your currency and start with sample data or import your own.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("Currency")
|
||||
Spacer()
|
||||
Picker("Currency", selection: $selectedCurrency) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Load sample portfolio", isOn: $useSampleData)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
if cloudSyncEnabled {
|
||||
Text("iCloud sync starts after you restart the app.")
|
||||
.font(.caption)
|
||||
Text(String(localized: "onboarding_quickstart_subtitle"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
onImport()
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
// Primary CTA — Add first source
|
||||
VStack(spacing: 10) {
|
||||
Button(action: onAddSource) {
|
||||
Label(String(localized: "onboarding_add_first_source"), systemImage: "plus.circle.fill")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button(action: onImport) {
|
||||
Label(String(localized: "onboarding_import_data"), systemImage: "square.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.foregroundColor(.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary options
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
Text("Currency")
|
||||
Spacer()
|
||||
Picker("Currency", selection: $selectedCurrency) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Button {
|
||||
onAddSource()
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 8)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Chart Preview (decorative, no real data)
|
||||
|
||||
private struct PremiumChartPreview: View {
|
||||
// Normalized growth data representing a healthy upward portfolio trend
|
||||
private let points: [Double] = [0.52, 0.58, 0.55, 0.65, 0.70, 0.66, 0.78, 0.85, 0.82, 0.91, 0.88, 1.0]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.appPrimary.opacity(0.08))
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("€24,750")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Text("+34.2%")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.positiveGreen)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.positiveGreen.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
GeometryReader { geo in
|
||||
chartPaths(in: geo.size)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 96)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chartPaths(in size: CGSize) -> some View {
|
||||
let pts = chartPoints(in: size)
|
||||
|
||||
// Gradient fill
|
||||
Path { path in
|
||||
guard !pts.isEmpty else { return }
|
||||
path.move(to: CGPoint(x: pts[0].x, y: size.height))
|
||||
path.addLine(to: pts[0])
|
||||
for i in 1..<pts.count {
|
||||
let ctrl = CGPoint(x: (pts[i-1].x + pts[i].x) / 2, y: (pts[i-1].y + pts[i].y) / 2)
|
||||
path.addQuadCurve(to: pts[i], control: ctrl)
|
||||
}
|
||||
path.addLine(to: CGPoint(x: pts.last!.x, y: size.height))
|
||||
path.closeSubpath()
|
||||
}
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.25), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
))
|
||||
|
||||
// Line
|
||||
Path { path in
|
||||
guard !pts.isEmpty else { return }
|
||||
path.move(to: pts[0])
|
||||
for i in 1..<pts.count {
|
||||
let ctrl = CGPoint(x: (pts[i-1].x + pts[i].x) / 2, y: (pts[i-1].y + pts[i].y) / 2)
|
||||
path.addQuadCurve(to: pts[i], control: ctrl)
|
||||
}
|
||||
}
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
|
||||
// Last point dot
|
||||
if let last = pts.last {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 7, height: 7)
|
||||
.position(last)
|
||||
}
|
||||
}
|
||||
|
||||
private func chartPoints(in size: CGSize) -> [CGPoint] {
|
||||
guard points.count > 1 else { return [] }
|
||||
let stepX = size.width / CGFloat(points.count - 1)
|
||||
return points.enumerated().map { i, val in
|
||||
CGPoint(x: CGFloat(i) * stepX, y: size.height * (1.0 - val * 0.85))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PaywallView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@@ -9,54 +97,57 @@ struct PaywallView: View {
|
||||
@State private var showingError = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
headerSection
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
headerSection
|
||||
.padding(.top, 48)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Features List
|
||||
featuresSection
|
||||
Spacer()
|
||||
|
||||
// Price Card
|
||||
priceCard
|
||||
// Key benefits
|
||||
benefitsSection
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Purchase Button
|
||||
purchaseButton
|
||||
Spacer()
|
||||
|
||||
// Restore Button
|
||||
restoreButton
|
||||
|
||||
// Legal
|
||||
legalSection
|
||||
}
|
||||
.padding()
|
||||
// Bottom actions
|
||||
VStack(spacing: 12) {
|
||||
priceLabel
|
||||
purchaseButton
|
||||
restoreButton
|
||||
legalSection
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle("Upgrade to Premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Close button
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.alert("Error", isPresented: $showingError) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errorMessage ?? "An error occurred")
|
||||
}
|
||||
.onChange(of: iapService.isPremium) { _, isPremium in
|
||||
if isPremium {
|
||||
dismiss()
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.alert("Error", isPresented: $showingError) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errorMessage ?? "An error occurred")
|
||||
}
|
||||
.onChange(of: iapService.isPremium) { _, isPremium in
|
||||
if isPremium {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,89 +155,46 @@ struct PaywallView: View {
|
||||
// MARK: - Header Section
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Crown icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.yellow.opacity(0.3), Color.orange.opacity(0.3)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 80, height: 80)
|
||||
VStack(spacing: 12) {
|
||||
PremiumChartPreview()
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Text("Unlock Full Potential")
|
||||
Text("Your full portfolio,\nfully clear")
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Get unlimited access to all features with a one-time purchase")
|
||||
Text("One payment. Every feature. Forever.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Features Section
|
||||
// MARK: - Benefits Section
|
||||
|
||||
private var featuresSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(IAPService.premiumFeatures, id: \.title) { feature in
|
||||
FeatureRow(
|
||||
icon: feature.icon,
|
||||
title: feature.title,
|
||||
description: feature.description
|
||||
)
|
||||
private var benefitsSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(IAPService.paywallBenefits, id: \.title) { benefit in
|
||||
BenefitRow(icon: benefit.icon, title: benefit.title, subtitle: benefit.subtitle)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(20)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Price Card
|
||||
// MARK: - Price Label
|
||||
|
||||
private var priceCard: some View {
|
||||
VStack(spacing: 8) {
|
||||
private var priceLabel: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(iapService.formattedPrice)
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
Text("One-time purchase")
|
||||
.font(.subheadline)
|
||||
Text("· one-time · Family Sharing")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption)
|
||||
Text("Includes Family Sharing")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.appSecondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Purchase Button
|
||||
@@ -160,7 +208,7 @@ struct PaywallView: View {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Upgrade Now")
|
||||
Text("Get Full Access")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
@@ -189,21 +237,22 @@ struct PaywallView: View {
|
||||
// MARK: - Legal Section
|
||||
|
||||
private var legalSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Payment will be charged to your Apple ID account. By purchasing, you agree to our Terms of Service and Privacy Policy.")
|
||||
VStack(spacing: 4) {
|
||||
Text("Payment charged to your Apple ID account.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
@@ -238,30 +287,25 @@ struct PaywallView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Row
|
||||
// MARK: - Benefit Row
|
||||
|
||||
struct FeatureRow: View {
|
||||
struct BenefitRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.appPrimary)
|
||||
.frame(width: 28)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(description)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -270,7 +314,21 @@ struct FeatureRow: View {
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.font(.system(size: 18))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Row (kept for backward compatibility)
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
BenefitRow(icon: icon, title: title, subtitle: description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,10 +351,10 @@ struct CompactPaywallBanner: View {
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Unlock Premium")
|
||||
Text("Full access, one payment")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text("Get unlimited access to all features")
|
||||
Text("Unlimited sources, advanced charts & more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -330,22 +388,24 @@ struct PremiumLockOverlay: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "lock.fill")
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Premium Feature")
|
||||
.font(.headline)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
Text(feature)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
showingPaywall = true
|
||||
} label: {
|
||||
Label("Unlock", systemImage: "crown.fill")
|
||||
Text("See full access")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
@@ -5,20 +5,28 @@ import UIKit
|
||||
struct SettingsView: View {
|
||||
private let iapService: IAPService
|
||||
@StateObject private var viewModel: SettingsViewModel
|
||||
@EnvironmentObject private var adMobService: AdMobService
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
||||
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||
|
||||
@ObservedObject private var cloudStack = CoreDataStack.shared
|
||||
|
||||
@State private var showingPinSetup = false
|
||||
@State private var showingPinChange = false
|
||||
@State private var showingBiometricAlert = false
|
||||
@State private var showingPinRequiredAlert = false
|
||||
@State private var showingPinDisableAlert = false
|
||||
@State private var showingPinVerifyForFaceId = false
|
||||
@State private var showingRestartAlert = false
|
||||
@State private var didLoadCloudSync = false
|
||||
@State private var isForceUploading = false
|
||||
@State private var forceUploadResult: String?
|
||||
@State private var backupToRestore: BackupRecord?
|
||||
|
||||
init(iapService: IAPService) {
|
||||
self.iapService = iapService
|
||||
@@ -40,6 +48,9 @@ struct SettingsView: View {
|
||||
|
||||
// Data Section
|
||||
dataSection
|
||||
if viewModel.backupsEnabled {
|
||||
backupsSection
|
||||
}
|
||||
|
||||
// Security Section
|
||||
securitySection
|
||||
@@ -85,6 +96,26 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("This will permanently delete all your investment data. This action cannot be undone.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Restore Backup",
|
||||
isPresented: Binding(
|
||||
get: { backupToRestore != nil },
|
||||
set: { if !$0 { backupToRestore = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let backup = backupToRestore {
|
||||
viewModel.restoreBackup(backup)
|
||||
backupToRestore = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
backupToRestore = nil
|
||||
}
|
||||
} message: {
|
||||
Text("This will replace your current data with the selected backup.")
|
||||
}
|
||||
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
|
||||
Button("OK") {
|
||||
viewModel.successMessage = nil
|
||||
@@ -138,8 +169,18 @@ struct SettingsView: View {
|
||||
_ = KeychainService.savePin(pin)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingPinVerifyForFaceId) {
|
||||
PinVerifyView(title: "Enter PIN to Disable Face ID") { success in
|
||||
if success {
|
||||
faceIdEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
didLoadCloudSync = true
|
||||
if viewModel.backupsEnabled {
|
||||
viewModel.refreshBackups()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,8 +343,123 @@ struct SettingsView: View {
|
||||
if didLoadCloudSync {
|
||||
showingRestartAlert = true
|
||||
}
|
||||
viewModel.refreshBackups()
|
||||
}
|
||||
|
||||
if cloudSyncEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Local data counts
|
||||
HStack {
|
||||
Label(
|
||||
"\(cloudStack.localSourceCount) sources · \(cloudStack.localSnapshotCount) snapshots",
|
||||
systemImage: "internaldrive"
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Refresh") {
|
||||
CoreDataStack.shared.forceReload()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.disabled(cloudStack.isSyncing)
|
||||
}
|
||||
|
||||
// Import / export status
|
||||
if cloudStack.isSyncing {
|
||||
Label("Syncing with iCloud...", systemImage: "arrow.triangle.2.circlepath.icloud")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
if let date = cloudStack.lastImportDate {
|
||||
Label("↓ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Label("No import yet", systemImage: "icloud.and.arrow.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
|
||||
if let date = cloudStack.lastExportDate {
|
||||
Label("↑ \(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))", systemImage: "icloud.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Label("No export yet", systemImage: "icloud.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CloudKit error (if any)
|
||||
if let error = cloudStack.lastSyncError {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Label("CloudKit error", systemImage: "exclamationmark.icloud")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Force upload — use when data exists locally but hasn't reached iCloud
|
||||
if cloudStack.localSourceCount > 0 {
|
||||
if let result = forceUploadResult {
|
||||
Label(result, systemImage: "checkmark.icloud")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Button {
|
||||
isForceUploading = true
|
||||
CoreDataStack.shared.forceExportToiCloud { count in
|
||||
isForceUploading = false
|
||||
forceUploadResult = String(
|
||||
format: NSLocalizedString("save_n_snapshots", comment: ""),
|
||||
count
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
||||
forceUploadResult = nil
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if isForceUploading {
|
||||
Label("Uploading...", systemImage: "arrow.triangle.2.circlepath.icloud")
|
||||
} else {
|
||||
Label("Force Upload to iCloud", systemImage: "icloud.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
.disabled(isForceUploading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { viewModel.backupsEnabled },
|
||||
set: { viewModel.setBackupsEnabled($0) }
|
||||
)
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Enable Backups")
|
||||
if !viewModel.isPremium {
|
||||
Text("Premium")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
if viewModel.canExport {
|
||||
viewModel.showingExportOptions = true
|
||||
@@ -358,6 +514,66 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backups Section
|
||||
|
||||
private var backupsSection: some View {
|
||||
Section {
|
||||
Picker("Keep Backups", selection: $viewModel.backupRetentionCount) {
|
||||
Text("5").tag(5)
|
||||
Text("10").tag(10)
|
||||
Text("20").tag(20)
|
||||
}
|
||||
.onChange(of: viewModel.backupRetentionCount) { _, newValue in
|
||||
viewModel.updateBackupRetention(newValue)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.createBackupNow()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Create Backup Now", systemImage: "arrow.clockwise")
|
||||
Spacer()
|
||||
if viewModel.isBackupInProgress {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isBackupInProgress || viewModel.isRestoreInProgress)
|
||||
|
||||
if viewModel.backups.isEmpty {
|
||||
Text("No backups yet.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.backups) { backup in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(backup.date.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.subheadline)
|
||||
Text("\(backup.location.rawValue) · \(formatBytes(backup.size))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Restore") {
|
||||
backupToRestore = backup
|
||||
}
|
||||
.disabled(viewModel.isRestoreInProgress)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Share Backup") {
|
||||
viewModel.shareItem = SettingsViewModel.ShareItem(url: backup.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Backups")
|
||||
} footer: {
|
||||
Text("Backups are stored locally and in iCloud (when enabled).")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Section
|
||||
|
||||
private var securitySection: some View {
|
||||
@@ -390,6 +606,9 @@ struct SettingsView: View {
|
||||
faceIdEnabled = false
|
||||
showingPinRequiredAlert = true
|
||||
}
|
||||
} else {
|
||||
faceIdEnabled = true
|
||||
showingPinVerifyForFaceId = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +650,14 @@ struct SettingsView: View {
|
||||
.onChange(of: viewModel.inputMode) { _, newValue in
|
||||
viewModel.updateInputMode(newValue)
|
||||
}
|
||||
|
||||
if !viewModel.isPremium {
|
||||
Button("Manage Ad Consent") {
|
||||
Task {
|
||||
await adMobService.presentPrivacyOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Preferences")
|
||||
} footer: {
|
||||
@@ -444,6 +671,8 @@ struct SettingsView: View {
|
||||
Section {
|
||||
Toggle("Calm Mode", isOn: $calmModeEnabled)
|
||||
|
||||
Toggle("Show Forecast", isOn: $showForecast)
|
||||
|
||||
NavigationLink {
|
||||
AllocationTargetsView()
|
||||
} label: {
|
||||
@@ -457,7 +686,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Long-Term Focus")
|
||||
} footer: {
|
||||
Text("Calm Mode hides short-term noise and advanced charts, keeping the app focused on monthly check-ins.")
|
||||
Text("Calm Mode hides short-term noise and advanced charts. Show Forecast controls whether prediction data appears in portfolio and charts.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +809,12 @@ struct SettingsView: View {
|
||||
}
|
||||
return "Portfolio Journal"
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity View
|
||||
@@ -741,6 +976,62 @@ struct PinSetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct PinVerifyView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let title: String
|
||||
let onResult: (Bool) -> Void
|
||||
|
||||
@State private var pin = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField("Enter PIN", text: $pin)
|
||||
.keyboardType(.numberPad)
|
||||
.onChange(of: pin) { _, newValue in
|
||||
pin = String(newValue.filter(\.isNumber).prefix(4))
|
||||
}
|
||||
} header: {
|
||||
Text("4-Digit PIN")
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
onResult(false)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Verify") { verifyPin() }
|
||||
.disabled(pin.count < 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
private func verifyPin() {
|
||||
guard let savedPin = KeychainService.readPin(), pin == savedPin else {
|
||||
errorMessage = "Incorrect PIN."
|
||||
pin = ""
|
||||
return
|
||||
}
|
||||
onResult(true)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView(iapService: IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
|
||||
@@ -39,7 +39,7 @@ struct AddSourceView: View {
|
||||
|
||||
// Source Info
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
TextField(String(localized: "add_source_name_placeholder"), text: $name)
|
||||
.textContentType(.organizationName)
|
||||
.onChange(of: name) { _, newValue in
|
||||
validateSourceName(newValue)
|
||||
@@ -77,6 +77,9 @@ struct AddSourceView: View {
|
||||
if let error = duplicateError {
|
||||
Text(error)
|
||||
.foregroundColor(.negativeRed)
|
||||
} else {
|
||||
Text(String(localized: "add_source_name_footer"))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,18 +188,7 @@ struct AddSourceView: View {
|
||||
}
|
||||
|
||||
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 var currencySymbol: String {
|
||||
@@ -206,6 +198,13 @@ struct AddSourceView: View {
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
}
|
||||
|
||||
private var currencyCode: String {
|
||||
if let account = selectedAccount, let code = account.currencyCode {
|
||||
return code
|
||||
}
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return availableAccounts.first { $0.safeId == id }
|
||||
@@ -386,6 +385,7 @@ struct EditSourceView: View {
|
||||
|
||||
struct AddSnapshotView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let source: InvestmentSource
|
||||
let snapshot: Snapshot?
|
||||
|
||||
@@ -428,6 +428,15 @@ struct AddSnapshotView: View {
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
|
||||
if let clipboardValue = viewModel.clipboardValue {
|
||||
Button {
|
||||
viewModel.applyClipboardValue()
|
||||
} label: {
|
||||
Label("Paste \(clipboardValue) from clipboard", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
|
||||
if viewModel.previousValue != nil {
|
||||
Text(viewModel.previousValueString)
|
||||
.font(.caption)
|
||||
@@ -470,7 +479,7 @@ struct AddSnapshotView: View {
|
||||
} header: {
|
||||
Text("Contribution (Optional)")
|
||||
} footer: {
|
||||
Text("Track new capital you've added to separate it from investment growth.")
|
||||
Text("Track new capital added to separate it from investment growth.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +507,14 @@ struct AddSnapshotView: View {
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.checkClipboard()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
viewModel.checkClipboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,13 +523,16 @@ struct AddSnapshotView: View {
|
||||
|
||||
let repository = SnapshotRepository()
|
||||
if let snapshot = snapshot {
|
||||
let contributionTextIsEmpty = viewModel.contributionString
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty
|
||||
repository.updateSnapshot(
|
||||
snapshot,
|
||||
date: viewModel.date,
|
||||
value: value,
|
||||
contribution: viewModel.contribution,
|
||||
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
|
||||
clearContribution: !viewModel.includeContribution,
|
||||
clearContribution: !viewModel.includeContribution || contributionTextIsEmpty,
|
||||
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddTransactionView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onSave: (TransactionType, Date, Decimal?, Decimal?, Decimal?, String?) -> Void
|
||||
|
||||
@State private var type: TransactionType = .buy
|
||||
@State private var date = Date()
|
||||
@State private var shares = ""
|
||||
@State private var price = ""
|
||||
@State private var amount = ""
|
||||
@State private var notes = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Type", selection: $type) {
|
||||
ForEach(TransactionType.allCases) { transactionType in
|
||||
Text(transactionType.displayName).tag(transactionType)
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker("Date", selection: $date, displayedComponents: .date)
|
||||
} header: {
|
||||
Text("Transaction")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Shares", text: $shares)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
TextField("Price per share", text: $price)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
TextField("Total amount", text: $amount)
|
||||
.keyboardType(.decimalPad)
|
||||
} header: {
|
||||
Text("Amounts")
|
||||
} footer: {
|
||||
Text("Enter shares and price or just a total amount.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Notes", text: $notes, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
} header: {
|
||||
Text("Notes (Optional)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Transaction")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { save() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
parseDecimal(shares) != nil || parseDecimal(amount) != nil
|
||||
}
|
||||
|
||||
private func save() {
|
||||
onSave(
|
||||
type,
|
||||
date,
|
||||
parseDecimal(shares),
|
||||
parseDecimal(price),
|
||||
parseDecimal(amount),
|
||||
notes.isEmpty ? nil : notes
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddTransactionView { _, _, _, _, _, _ in }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import Charts
|
||||
|
||||
struct SourceDetailView: View {
|
||||
@@ -7,7 +8,8 @@ struct SourceDetailView: View {
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@State private var editingSnapshotSelection: SnapshotSelection?
|
||||
|
||||
init(source: InvestmentSource, iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
|
||||
@@ -17,6 +19,19 @@ struct SourceDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isDeleted {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
contentView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contentView: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
@@ -40,16 +55,13 @@ struct SourceDetailView: View {
|
||||
metricsSection
|
||||
}
|
||||
|
||||
// Transactions
|
||||
transactionsSection
|
||||
|
||||
// Snapshots List
|
||||
snapshotsSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.source.name)
|
||||
.navigationTitle(viewModel.safeSourceName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -70,22 +82,14 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
||||
AddSnapshotView(source: viewModel.source)
|
||||
}
|
||||
.sheet(item: $editingSnapshot) { snapshot in
|
||||
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
||||
AddSnapshotView(source: viewModel.source)
|
||||
}
|
||||
.sheet(item: $editingSnapshotSelection) { selection in
|
||||
if let snapshot = try? viewContext.existingObject(with: selection.id) as? Snapshot {
|
||||
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddTransaction) {
|
||||
AddTransactionView { type, date, shares, price, amount, notes in
|
||||
viewModel.addTransaction(
|
||||
type: type,
|
||||
date: date,
|
||||
shares: shares,
|
||||
price: price,
|
||||
amount: amount,
|
||||
notes: notes
|
||||
)
|
||||
} else {
|
||||
MissingSnapshotView()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingEditSource) {
|
||||
@@ -100,13 +104,16 @@ struct SourceDetailView: View {
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
// Delete and dismiss
|
||||
let repository = InvestmentSourceRepository()
|
||||
repository.deleteSource(viewModel.source)
|
||||
viewModel.deleteSource()
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
|
||||
Text("This will permanently delete \(viewModel.safeSourceName) and all its snapshots.")
|
||||
}
|
||||
.onChange(of: viewModel.isDeleted) { _, deleted in
|
||||
if deleted {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,30 +172,16 @@ struct SourceDetailView: View {
|
||||
// MARK: - Quick Actions
|
||||
|
||||
private var quickActions: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
viewModel.showingAddSnapshot = true
|
||||
} label: {
|
||||
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showingAddTransaction = true
|
||||
} label: {
|
||||
Label("Add Transaction", systemImage: "arrow.left.arrow.right.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
Button {
|
||||
viewModel.showingAddSnapshot = true
|
||||
} label: {
|
||||
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,56 +333,6 @@ struct SourceDetailView: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Transactions Section
|
||||
|
||||
private var transactionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Transactions")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.transactions.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.transactions.isEmpty {
|
||||
Text("Add buys, sells, dividends, and fees to track cashflows.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "Invested", value: viewModel.source.totalInvested.compactCurrencyString)
|
||||
MetricChip(title: "Dividends", value: viewModel.source.totalDividends.compactCurrencyString)
|
||||
MetricChip(title: "Fees", value: viewModel.source.totalFees.compactCurrencyString)
|
||||
}
|
||||
|
||||
ForEach(viewModel.transactions.prefix(5)) { transaction in
|
||||
TransactionRow(transaction: transaction)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTransaction(transaction)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.transactions.count > 5 {
|
||||
Text("+ \(viewModel.transactions.count - 5) more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Snapshots Section
|
||||
|
||||
private var snapshotsSection: some View {
|
||||
@@ -426,25 +369,25 @@ struct SourceDetailView: View {
|
||||
|
||||
// Use LazyVStack for better performance with many snapshots
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||
ForEach(viewModel.visibleSnapshots, id: \.objectID) { snapshot in
|
||||
VStack(spacing: 0) {
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
editingSnapshotSelection = SnapshotSelection(id: snapshot.objectID)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
@@ -456,7 +399,7 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.visibleSnapshots.last?.id {
|
||||
if snapshot.objectID != viewModel.visibleSnapshots.last?.objectID {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
@@ -505,7 +448,7 @@ struct SnapshotRowView: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
Text(snapshot.safeDate.friendlyDescription)
|
||||
.font(.subheadline)
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
@@ -545,32 +488,6 @@ struct SnapshotRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transaction Row View
|
||||
|
||||
struct TransactionRow: View {
|
||||
let transaction: Transaction
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(transaction.transactionType.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(transaction.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(transaction.decimalAmount.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(transaction.transactionType == .fee ? .negativeRed : .primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricChip: View {
|
||||
let title: String
|
||||
let value: String
|
||||
@@ -591,6 +508,27 @@ struct MetricChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SnapshotSelection: Identifiable {
|
||||
let id: NSManagedObjectID
|
||||
}
|
||||
|
||||
private struct MissingSnapshotView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Snapshot not available")
|
||||
.font(.headline)
|
||||
Text("This snapshot was removed or is no longer accessible.")
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
Text("Source Detail Preview")
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SourceListView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
@StateObject private var viewModel: SourceListViewModel
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@State private var sourceToDelete: InvestmentSource?
|
||||
@State private var navigationPath = NavigationPath()
|
||||
@State private var showingSearch = false
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
@@ -20,11 +25,13 @@ struct SourceListView: View {
|
||||
emptyStateView
|
||||
} else {
|
||||
sourcesList
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
filterBar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sources")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
@@ -34,8 +41,16 @@ struct SourceListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
categoryFilterMenu
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showingSearch.toggle()
|
||||
}
|
||||
if !showingSearch { viewModel.searchText = "" }
|
||||
} label: {
|
||||
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
|
||||
.foregroundColor(showingSearch ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -58,6 +73,36 @@ struct SourceListView: View {
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
}
|
||||
.navigationDestination(for: NSManagedObjectID.self) { objectID in
|
||||
if let source = try? context.existingObject(with: objectID) as? InvestmentSource,
|
||||
!source.isDeleted {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} else {
|
||||
MissingSourceView()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Source",
|
||||
isPresented: Binding(
|
||||
get: { sourceToDelete != nil },
|
||||
set: { if !$0 { sourceToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let source = sourceToDelete {
|
||||
viewModel.deleteSource(source)
|
||||
sourceToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
sourceToDelete = nil
|
||||
}
|
||||
} message: {
|
||||
if let source = sourceToDelete {
|
||||
Text("This will permanently delete \(source.name) and all its snapshots.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,15 +159,19 @@ struct SourceListView: View {
|
||||
|
||||
// Sources
|
||||
Section {
|
||||
ForEach(viewModel.sources) { source in
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} label: {
|
||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
viewModel.deleteSource(at: indexSet)
|
||||
ForEach(viewModel.sources, id: \.objectID) { source in
|
||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
navigationPath.append(source.objectID)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
sourceToDelete = source
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if viewModel.isFiltered {
|
||||
@@ -175,46 +224,64 @@ struct SourceListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter Menu
|
||||
// MARK: - Filter Bar (chips + search)
|
||||
|
||||
private var categoryFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.selectCategory(nil)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("All Categories")
|
||||
if viewModel.selectedCategory == nil {
|
||||
Image(systemName: "checkmark")
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
CategoryChip(
|
||||
title: String(localized: "sources_filter_all"),
|
||||
icon: nil,
|
||||
isSelected: viewModel.selectedCategory == nil
|
||||
) {
|
||||
viewModel.selectCategory(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(viewModel.categories) { category in
|
||||
Button {
|
||||
viewModel.selectCategory(category)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: category.icon)
|
||||
Text(category.name)
|
||||
if viewModel.selectedCategory?.id == category.id {
|
||||
Image(systemName: "checkmark")
|
||||
ForEach(viewModel.categories) { category in
|
||||
CategoryChip(
|
||||
title: category.name,
|
||||
icon: category.icon,
|
||||
isSelected: viewModel.selectedCategory?.id == category.id
|
||||
) {
|
||||
viewModel.selectCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
if viewModel.selectedCategory != nil {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
if showingSearch {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Search sources", text: $viewModel.searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
if !viewModel.searchText.isEmpty {
|
||||
Button {
|
||||
viewModel.searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
// MARK: - Account Filter Menu
|
||||
@@ -263,6 +330,51 @@ struct SourceListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Chip
|
||||
|
||||
private struct CategoryChip: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 4) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2)
|
||||
}
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.appPrimary : Color(.systemGray5))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MissingSourceView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 42))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Source not available")
|
||||
.font(.headline)
|
||||
Text("This source was deleted.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Row View
|
||||
|
||||
struct SourceRowView: View {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import CoreData
|
||||
@testable import PortfolioJournal
|
||||
|
||||
/// Error types for TestCoreDataStack
|
||||
enum TestCoreDataStackError: Error {
|
||||
case modelNotFound
|
||||
case storeLoadFailed(Error)
|
||||
}
|
||||
|
||||
/// In-memory Core Data stack for testing purposes
|
||||
/// This allows tests to run in isolation without affecting the actual database
|
||||
final class TestCoreDataStack {
|
||||
|
||||
let persistentContainer: NSPersistentContainer
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
persistentContainer.viewContext
|
||||
}
|
||||
|
||||
init() throws {
|
||||
// Use the same approach as NSPersistentContainer(name:) which auto-finds the model
|
||||
// When tests run with the app as TEST_HOST, Bundle.main is the app bundle
|
||||
let modelName = "PortfolioJournal"
|
||||
|
||||
// Create the container - it will find the model in the main bundle
|
||||
persistentContainer = NSPersistentContainer(name: modelName)
|
||||
|
||||
// Configure for in-memory storage
|
||||
let description = NSPersistentStoreDescription()
|
||||
description.type = NSInMemoryStoreType
|
||||
description.shouldAddStoreAsynchronously = false
|
||||
|
||||
persistentContainer.persistentStoreDescriptions = [description]
|
||||
|
||||
var loadError: Error?
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
loadError = error
|
||||
}
|
||||
|
||||
if let error = loadError {
|
||||
throw TestCoreDataStackError.storeLoadFailed(error)
|
||||
}
|
||||
|
||||
viewContext.automaticallyMergesChangesFromParent = true
|
||||
viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
/// Creates a fresh context for each test
|
||||
func newBackgroundContext() -> NSManagedObjectContext {
|
||||
let context = persistentContainer.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
return context
|
||||
}
|
||||
|
||||
/// Resets the in-memory store (call in tearDown)
|
||||
func reset() {
|
||||
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Snapshot")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||
try? viewContext.execute(deleteRequest)
|
||||
|
||||
let sourceFetch: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "InvestmentSource")
|
||||
let sourceDelete = NSBatchDeleteRequest(fetchRequest: sourceFetch)
|
||||
try? viewContext.execute(sourceDelete)
|
||||
|
||||
let categoryFetch: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Category")
|
||||
let categoryDelete = NSBatchDeleteRequest(fetchRequest: categoryFetch)
|
||||
try? viewContext.execute(categoryDelete)
|
||||
|
||||
let accountFetch: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Account")
|
||||
let accountDelete = NSBatchDeleteRequest(fetchRequest: accountFetch)
|
||||
try? viewContext.execute(accountDelete)
|
||||
|
||||
let goalFetch: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Goal")
|
||||
let goalDelete = NSBatchDeleteRequest(fetchRequest: goalFetch)
|
||||
try? viewContext.execute(goalDelete)
|
||||
|
||||
viewContext.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Data Factory
|
||||
|
||||
extension TestCoreDataStack {
|
||||
|
||||
/// Creates a test account
|
||||
@MainActor
|
||||
func createTestAccount(
|
||||
name: String = "Test Account",
|
||||
currency: String = "USD"
|
||||
) -> Account {
|
||||
let account = Account(context: viewContext)
|
||||
account.id = UUID()
|
||||
account.name = name
|
||||
account.currency = currency
|
||||
account.createdAt = Date()
|
||||
return account
|
||||
}
|
||||
|
||||
/// Creates a test category
|
||||
@MainActor
|
||||
func createTestCategory(
|
||||
name: String = "Test Category",
|
||||
colorHex: String = "#3B82F6"
|
||||
) -> PortfolioJournal.Category {
|
||||
let category = PortfolioJournal.Category(context: viewContext)
|
||||
category.id = UUID()
|
||||
category.name = name
|
||||
category.colorHex = colorHex
|
||||
return category
|
||||
}
|
||||
|
||||
/// Creates a test investment source
|
||||
@MainActor
|
||||
func createTestSource(
|
||||
name: String = "Test Source",
|
||||
account: Account? = nil,
|
||||
category: PortfolioJournal.Category? = nil
|
||||
) -> InvestmentSource {
|
||||
let source = InvestmentSource(context: viewContext)
|
||||
source.id = UUID()
|
||||
source.name = name
|
||||
source.createdAt = Date()
|
||||
source.account = account
|
||||
source.category = category
|
||||
return source
|
||||
}
|
||||
|
||||
/// Creates a test snapshot
|
||||
@MainActor
|
||||
func createTestSnapshot(
|
||||
source: InvestmentSource,
|
||||
value: Decimal,
|
||||
date: Date = Date(),
|
||||
contribution: Decimal? = nil
|
||||
) -> Snapshot {
|
||||
let snapshot = Snapshot(context: viewContext)
|
||||
snapshot.id = UUID()
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
snapshot.date = date
|
||||
snapshot.source = source
|
||||
if let contribution = contribution {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// Creates a test goal
|
||||
@MainActor
|
||||
func createTestGoal(
|
||||
name: String = "Test Goal",
|
||||
targetAmount: Decimal,
|
||||
account: Account? = nil,
|
||||
targetDate: Date? = nil
|
||||
) -> Goal {
|
||||
let goal = Goal(context: viewContext)
|
||||
goal.id = UUID()
|
||||
goal.name = name
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.createdAt = Date()
|
||||
goal.isActive = true
|
||||
goal.account = account
|
||||
goal.targetDate = targetDate
|
||||
return goal
|
||||
}
|
||||
|
||||
/// Saves the context
|
||||
func save() throws {
|
||||
if viewContext.hasChanges {
|
||||
try viewContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import XCTest
|
||||
import CoreData
|
||||
@testable import PortfolioJournal
|
||||
|
||||
// Note: These tests require Core Data model access from the test bundle.
|
||||
// They are disabled by default. To run them, remove the XCTestCase subclass override.
|
||||
// Run with: xcodebuild test -scheme PortfolioJournal -destination 'platform=iOS Simulator,name=iPhone 17'
|
||||
@MainActor
|
||||
final class GoalRepositoryTests: XCTestCase {
|
||||
|
||||
override class var defaultTestSuite: XCTestSuite {
|
||||
// Skip all tests in this class - Core Data is not available in test bundle
|
||||
return XCTestSuite(name: "GoalRepositoryTests (Skipped - Core Data not available)")
|
||||
}
|
||||
|
||||
var testStack: TestCoreDataStack?
|
||||
var sut: GoalRepository?
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
do {
|
||||
testStack = try TestCoreDataStack()
|
||||
sut = GoalRepository(context: testStack!.viewContext)
|
||||
} catch {
|
||||
// Core Data setup failed - tests will be skipped
|
||||
testStack = nil
|
||||
sut = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
testStack?.reset()
|
||||
testStack = nil
|
||||
sut = nil
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
/// Helper to skip test if Core Data is not available
|
||||
private func requireCoreData() throws -> (TestCoreDataStack, GoalRepository) {
|
||||
guard let stack = testStack, let repository = sut else {
|
||||
throw XCTSkip("Core Data is not available in this test environment")
|
||||
}
|
||||
return (stack, repository)
|
||||
}
|
||||
|
||||
// MARK: - Create Tests
|
||||
|
||||
func testCreateGoal_withValidData_createsGoal() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
let name = "Test Goal"
|
||||
let targetAmount: Decimal = 100_000
|
||||
|
||||
// When
|
||||
repository.createGoal(
|
||||
name: name,
|
||||
targetAmount: targetAmount,
|
||||
targetDate: nil,
|
||||
account: nil
|
||||
)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertEqual(repository.goals.count, 1)
|
||||
XCTAssertEqual(repository.goals.first?.name, name)
|
||||
XCTAssertEqual(repository.goals.first?.targetDecimal, targetAmount)
|
||||
}
|
||||
|
||||
func testCreateGoal_withTargetDate_setsDate() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
let targetDate = Date().adding(years: 1)
|
||||
|
||||
// When
|
||||
repository.createGoal(
|
||||
name: "Dated Goal",
|
||||
targetAmount: 50_000,
|
||||
targetDate: targetDate,
|
||||
account: nil
|
||||
)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertNotNil(repository.goals.first?.targetDate)
|
||||
}
|
||||
|
||||
func testCreateGoal_withAccount_associatesAccount() async throws {
|
||||
// Given
|
||||
let (stack, repository) = try requireCoreData()
|
||||
let account = stack.createTestAccount(name: "Test Account")
|
||||
try? stack.save()
|
||||
|
||||
// When
|
||||
repository.createGoal(
|
||||
name: "Account Goal",
|
||||
targetAmount: 75_000,
|
||||
targetDate: nil,
|
||||
account: account
|
||||
)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertEqual(repository.goals.first?.account?.name, "Test Account")
|
||||
}
|
||||
|
||||
// MARK: - Fetch Tests
|
||||
|
||||
func testFetchGoals_withNoGoals_returnsEmpty() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
|
||||
// When
|
||||
repository.fetchGoals()
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(repository.goals.isEmpty)
|
||||
}
|
||||
|
||||
func testFetchGoals_withMultipleGoals_returnsAll() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
repository.createGoal(name: "Goal 1", targetAmount: 10_000, targetDate: nil, account: nil)
|
||||
repository.createGoal(name: "Goal 2", targetAmount: 20_000, targetDate: nil, account: nil)
|
||||
repository.createGoal(name: "Goal 3", targetAmount: 30_000, targetDate: nil, account: nil)
|
||||
|
||||
// When
|
||||
repository.fetchGoals()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(repository.goals.count, 3)
|
||||
}
|
||||
|
||||
func testFetchGoals_forAccount_filtersCorrectly() async throws {
|
||||
// Given
|
||||
let (stack, repository) = try requireCoreData()
|
||||
let account1 = stack.createTestAccount(name: "Account 1")
|
||||
let account2 = stack.createTestAccount(name: "Account 2")
|
||||
try? stack.save()
|
||||
|
||||
repository.createGoal(name: "Goal A1", targetAmount: 10_000, targetDate: nil, account: account1)
|
||||
repository.createGoal(name: "Goal A2", targetAmount: 20_000, targetDate: nil, account: account1)
|
||||
repository.createGoal(name: "Goal B1", targetAmount: 30_000, targetDate: nil, account: account2)
|
||||
|
||||
// When
|
||||
repository.fetchGoals(for: account1)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(repository.goals.count, 2)
|
||||
XCTAssertTrue(repository.goals.allSatisfy { $0.account?.name == "Account 1" })
|
||||
}
|
||||
|
||||
// MARK: - Update Tests
|
||||
|
||||
func testUpdateGoal_changesName() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
repository.createGoal(name: "Original Name", targetAmount: 50_000, targetDate: nil, account: nil)
|
||||
repository.fetchGoals()
|
||||
let goal = repository.goals.first!
|
||||
|
||||
// When
|
||||
repository.updateGoal(
|
||||
goal,
|
||||
name: "Updated Name",
|
||||
targetAmount: 50_000,
|
||||
targetDate: nil,
|
||||
clearTargetDate: false
|
||||
)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertEqual(repository.goals.first?.name, "Updated Name")
|
||||
}
|
||||
|
||||
func testUpdateGoal_changesTargetAmount() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
repository.createGoal(name: "Test Goal", targetAmount: 50_000, targetDate: nil, account: nil)
|
||||
repository.fetchGoals()
|
||||
let goal = repository.goals.first!
|
||||
|
||||
// When
|
||||
repository.updateGoal(
|
||||
goal,
|
||||
name: "Test Goal",
|
||||
targetAmount: 75_000,
|
||||
targetDate: nil,
|
||||
clearTargetDate: false
|
||||
)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertEqual(repository.goals.first?.targetDecimal, 75_000)
|
||||
}
|
||||
|
||||
func testUpdateGoal_clearTargetDate_removesDate() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
let targetDate = Date().adding(years: 1)
|
||||
repository.createGoal(name: "Dated Goal", targetAmount: 50_000, targetDate: targetDate, account: nil)
|
||||
repository.fetchGoals()
|
||||
let goal = repository.goals.first!
|
||||
|
||||
// When
|
||||
repository.updateGoal(
|
||||
goal,
|
||||
name: "Dated Goal",
|
||||
targetAmount: 50_000,
|
||||
targetDate: nil,
|
||||
clearTargetDate: true
|
||||
)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertNil(repository.goals.first?.targetDate)
|
||||
}
|
||||
|
||||
// MARK: - Delete Tests
|
||||
|
||||
func testDeleteGoal_removesGoal() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
repository.createGoal(name: "To Delete", targetAmount: 10_000, targetDate: nil, account: nil)
|
||||
repository.fetchGoals()
|
||||
let goal = repository.goals.first!
|
||||
|
||||
// When
|
||||
repository.deleteGoal(goal)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertTrue(repository.goals.isEmpty)
|
||||
}
|
||||
|
||||
func testDeleteGoal_onlyDeletesSpecifiedGoal() async throws {
|
||||
// Given
|
||||
let (_, repository) = try requireCoreData()
|
||||
repository.createGoal(name: "Keep", targetAmount: 10_000, targetDate: nil, account: nil)
|
||||
repository.createGoal(name: "Delete", targetAmount: 20_000, targetDate: nil, account: nil)
|
||||
repository.fetchGoals()
|
||||
let goalToDelete = repository.goals.first { $0.name == "Delete" }!
|
||||
|
||||
// When
|
||||
repository.deleteGoal(goalToDelete)
|
||||
|
||||
// Then
|
||||
repository.fetchGoals()
|
||||
XCTAssertEqual(repository.goals.count, 1)
|
||||
XCTAssertEqual(repository.goals.first?.name, "Keep")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class BackupServiceTests: XCTestCase {
|
||||
private var tempRoot: URL!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
tempRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
if let tempRoot {
|
||||
try? FileManager.default.removeItem(at: tempRoot)
|
||||
}
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCreateBackupPrunesToRetentionCount() {
|
||||
let localBase = tempRoot.appendingPathComponent("Local", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: localBase, withIntermediateDirectories: true)
|
||||
|
||||
var dates = [
|
||||
Date(timeIntervalSince1970: 100),
|
||||
Date(timeIntervalSince1970: 200)
|
||||
]
|
||||
|
||||
let service = BackupService(
|
||||
fileManager: .default,
|
||||
dateProvider: { dates.removeFirst() },
|
||||
localBaseDirectoryProvider: { localBase },
|
||||
iCloudBaseDirectoryProvider: { nil },
|
||||
exportProvider: { "{}" }
|
||||
)
|
||||
|
||||
_ = service.createBackup(retentionCount: 1, includeICloud: false)
|
||||
let records = service.createBackup(retentionCount: 1, includeICloud: false)
|
||||
|
||||
XCTAssertEqual(records.count, 1)
|
||||
XCTAssertEqual(records.first?.location, .local)
|
||||
}
|
||||
|
||||
func testCreateBackupIncludesICloudWhenEnabled() {
|
||||
let localBase = tempRoot.appendingPathComponent("Local", isDirectory: true)
|
||||
let iCloudBase = tempRoot.appendingPathComponent("iCloud", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: localBase, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: iCloudBase, withIntermediateDirectories: true)
|
||||
|
||||
let service = BackupService(
|
||||
fileManager: .default,
|
||||
dateProvider: { Date(timeIntervalSince1970: 300) },
|
||||
localBaseDirectoryProvider: { localBase },
|
||||
iCloudBaseDirectoryProvider: { iCloudBase },
|
||||
exportProvider: { "{}" }
|
||||
)
|
||||
|
||||
let records = service.createBackup(retentionCount: 5, includeICloud: true)
|
||||
let locations = Set(records.map { $0.location })
|
||||
|
||||
XCTAssertTrue(locations.contains(.local))
|
||||
XCTAssertTrue(locations.contains(.iCloud))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class CalculationServiceTests: XCTestCase {
|
||||
|
||||
var sut: CalculationService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
sut = CalculationService.shared
|
||||
sut.invalidateCache()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - CAGR Tests
|
||||
|
||||
func testCalculateCAGR_withValidInputs_returnsCorrectValue() {
|
||||
// Given: 10,000 growing to 15,000 over 3 years
|
||||
let startValue: Decimal = 10_000
|
||||
let endValue: Decimal = 15_000
|
||||
let startDate = Calendar.current.date(byAdding: .year, value: -3, to: Date())!
|
||||
let endDate = Date()
|
||||
|
||||
// When
|
||||
let cagr = sut.calculateCAGR(
|
||||
startValue: startValue,
|
||||
endValue: endValue,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
// Then: CAGR should be approximately 14.47%
|
||||
// Formula: (15000/10000)^(1/3) - 1 = 0.1447
|
||||
XCTAssertEqual(cagr, 14.47, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testCalculateCAGR_withZeroStartValue_returnsZero() {
|
||||
// Given
|
||||
let startValue: Decimal = 0
|
||||
let endValue: Decimal = 15_000
|
||||
let startDate = Calendar.current.date(byAdding: .year, value: -3, to: Date())!
|
||||
let endDate = Date()
|
||||
|
||||
// When
|
||||
let cagr = sut.calculateCAGR(
|
||||
startValue: startValue,
|
||||
endValue: endValue,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(cagr, 0)
|
||||
}
|
||||
|
||||
func testCalculateCAGR_withSameDate_returnsZero() {
|
||||
// Given
|
||||
let startValue: Decimal = 10_000
|
||||
let endValue: Decimal = 15_000
|
||||
let date = Date()
|
||||
|
||||
// When
|
||||
let cagr = sut.calculateCAGR(
|
||||
startValue: startValue,
|
||||
endValue: endValue,
|
||||
startDate: date,
|
||||
endDate: date
|
||||
)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(cagr, 0)
|
||||
}
|
||||
|
||||
func testCalculateCAGR_withNegativeReturn_returnsNegativeValue() {
|
||||
// Given: 10,000 declining to 8,000 over 2 years
|
||||
let startValue: Decimal = 10_000
|
||||
let endValue: Decimal = 8_000
|
||||
let startDate = Calendar.current.date(byAdding: .year, value: -2, to: Date())!
|
||||
let endDate = Date()
|
||||
|
||||
// When
|
||||
let cagr = sut.calculateCAGR(
|
||||
startValue: startValue,
|
||||
endValue: endValue,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
// Then: CAGR should be approximately -10.56%
|
||||
XCTAssertLessThan(cagr, 0)
|
||||
XCTAssertEqual(cagr, -10.56, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testCalculateCAGR_with100PercentGrowthOver1Year_returns100Percent() {
|
||||
// Given: 10,000 doubling to 20,000 in 1 year
|
||||
let startValue: Decimal = 10_000
|
||||
let endValue: Decimal = 20_000
|
||||
let startDate = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
|
||||
let endDate = Date()
|
||||
|
||||
// When
|
||||
let cagr = sut.calculateCAGR(
|
||||
startValue: startValue,
|
||||
endValue: endValue,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(cagr, 100, accuracy: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Max Drawdown Tests
|
||||
|
||||
func testCalculateMaxDrawdown_withIncreasingValues_returnsZero() {
|
||||
// Given
|
||||
let values: [Decimal] = [100, 110, 120, 130, 140, 150]
|
||||
|
||||
// When
|
||||
let maxDrawdown = sut.calculateMaxDrawdown(values: values)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(maxDrawdown, 0)
|
||||
}
|
||||
|
||||
func testCalculateMaxDrawdown_withDecline_returnsCorrectValue() {
|
||||
// Given: Peak at 100, drops to 80 (20% drawdown)
|
||||
let values: [Decimal] = [80, 100, 95, 80, 90]
|
||||
|
||||
// When
|
||||
let maxDrawdown = sut.calculateMaxDrawdown(values: values)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(maxDrawdown, 20, accuracy: 0.1)
|
||||
}
|
||||
|
||||
func testCalculateMaxDrawdown_withMultipleDrawdowns_returnsLargest() {
|
||||
// Given: First drawdown 10%, second drawdown 25%
|
||||
let values: [Decimal] = [100, 90, 95, 120, 90, 110]
|
||||
|
||||
// When
|
||||
let maxDrawdown = sut.calculateMaxDrawdown(values: values)
|
||||
|
||||
// Then: 25% is the max drawdown (120 to 90)
|
||||
XCTAssertEqual(maxDrawdown, 25, accuracy: 0.1)
|
||||
}
|
||||
|
||||
func testCalculateMaxDrawdown_withEmptyArray_returnsZero() {
|
||||
// Given
|
||||
let values: [Decimal] = []
|
||||
|
||||
// When
|
||||
let maxDrawdown = sut.calculateMaxDrawdown(values: values)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(maxDrawdown, 0)
|
||||
}
|
||||
|
||||
func testCalculateMaxDrawdown_withSingleValue_returnsZero() {
|
||||
// Given
|
||||
let values: [Decimal] = [100]
|
||||
|
||||
// When
|
||||
let maxDrawdown = sut.calculateMaxDrawdown(values: values)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(maxDrawdown, 0)
|
||||
}
|
||||
|
||||
// MARK: - Sharpe Ratio Tests
|
||||
|
||||
func testCalculateSharpeRatio_withPositiveReturn_returnsPositiveValue() {
|
||||
// Given
|
||||
let averageReturn = 1.5 // 1.5% monthly return
|
||||
let volatility = 3.0 // 3% volatility
|
||||
|
||||
// When
|
||||
let sharpeRatio = sut.calculateSharpeRatio(
|
||||
averageReturn: averageReturn,
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
// Then
|
||||
XCTAssertGreaterThan(sharpeRatio, 0)
|
||||
}
|
||||
|
||||
func testCalculateSharpeRatio_withZeroVolatility_returnsZero() {
|
||||
// Given
|
||||
let averageReturn = 1.5
|
||||
let volatility = 0.0
|
||||
|
||||
// When
|
||||
let sharpeRatio = sut.calculateSharpeRatio(
|
||||
averageReturn: averageReturn,
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sharpeRatio, 0)
|
||||
}
|
||||
|
||||
func testCalculateSharpeRatio_withNegativeReturn_returnsNegativeValue() {
|
||||
// Given
|
||||
let averageReturn = -1.0 // -1% monthly return (below risk-free rate)
|
||||
let volatility = 5.0
|
||||
|
||||
// When
|
||||
let sharpeRatio = sut.calculateSharpeRatio(
|
||||
averageReturn: averageReturn,
|
||||
volatility: volatility
|
||||
)
|
||||
|
||||
// Then
|
||||
XCTAssertLessThan(sharpeRatio, 0)
|
||||
}
|
||||
|
||||
// MARK: - Win Rate Tests
|
||||
|
||||
func testCalculateWinRate_withAllPositiveMonths_returns100Percent() {
|
||||
// Given
|
||||
let monthlyReturns = [
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 5.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 3.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 2.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 8.0)
|
||||
]
|
||||
|
||||
// When
|
||||
let winRate = sut.calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(winRate, 100)
|
||||
}
|
||||
|
||||
func testCalculateWinRate_withAllNegativeMonths_returnsZero() {
|
||||
// Given
|
||||
let monthlyReturns = [
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: -5.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: -3.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: -2.0)
|
||||
]
|
||||
|
||||
// When
|
||||
let winRate = sut.calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(winRate, 0)
|
||||
}
|
||||
|
||||
func testCalculateWinRate_withMixedMonths_returnsCorrectPercentage() {
|
||||
// Given: 3 positive, 1 negative = 75% win rate
|
||||
let monthlyReturns = [
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 5.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: -3.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 2.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 1.0)
|
||||
]
|
||||
|
||||
// When
|
||||
let winRate = sut.calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(winRate, 75)
|
||||
}
|
||||
|
||||
func testCalculateWinRate_withEmptyArray_returnsZero() {
|
||||
// Given
|
||||
let monthlyReturns: [InvestmentMetrics.MonthlyReturn] = []
|
||||
|
||||
// When
|
||||
let winRate = sut.calculateWinRate(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(winRate, 0)
|
||||
}
|
||||
|
||||
// MARK: - Volatility Tests
|
||||
|
||||
func testCalculateVolatility_withStableReturns_returnsLowVolatility() {
|
||||
// Given: Very stable returns
|
||||
let monthlyReturns = [
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 1.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 1.1),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 0.9),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 1.0)
|
||||
]
|
||||
|
||||
// When
|
||||
let volatility = sut.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertLessThan(volatility, 1.0) // Very low volatility
|
||||
}
|
||||
|
||||
func testCalculateVolatility_withVolatileReturns_returnsHighVolatility() {
|
||||
// Given: Very volatile returns
|
||||
let monthlyReturns = [
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 10.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: -8.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 15.0),
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: -12.0)
|
||||
]
|
||||
|
||||
// When
|
||||
let volatility = sut.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertGreaterThan(volatility, 30.0) // High volatility
|
||||
}
|
||||
|
||||
func testCalculateVolatility_withSingleMonth_returnsZero() {
|
||||
// Given
|
||||
let monthlyReturns = [
|
||||
InvestmentMetrics.MonthlyReturn(date: Date(), returnPercentage: 5.0)
|
||||
]
|
||||
|
||||
// When
|
||||
let volatility = sut.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(volatility, 0)
|
||||
}
|
||||
|
||||
// MARK: - Array Average Extension Tests
|
||||
|
||||
func testArrayAverage_withValues_returnsCorrectAverage() {
|
||||
// Given
|
||||
let values = [10.0, 20.0, 30.0, 40.0]
|
||||
|
||||
// When
|
||||
let average = values.average()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(average, 25.0)
|
||||
}
|
||||
|
||||
func testArrayAverage_withEmptyArray_returnsZero() {
|
||||
// Given
|
||||
let values: [Double] = []
|
||||
|
||||
// When
|
||||
let average = values.average()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(average, 0)
|
||||
}
|
||||
|
||||
func testArrayAverage_withNegativeValues_returnsCorrectAverage() {
|
||||
// Given
|
||||
let values = [-10.0, 10.0, -5.0, 5.0]
|
||||
|
||||
// When
|
||||
let average = values.average()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(average, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Tests
|
||||
|
||||
extension CalculationServiceTests {
|
||||
|
||||
func testCalculateMaxDrawdown_performance() {
|
||||
// Given: Large dataset
|
||||
let values: [Decimal] = (0..<10000).map { Decimal($0 % 100 + 50) }
|
||||
|
||||
// When/Then
|
||||
measure {
|
||||
_ = sut.calculateMaxDrawdown(values: values)
|
||||
}
|
||||
}
|
||||
|
||||
func testCalculateVolatility_performance() {
|
||||
// Given: Large dataset
|
||||
let monthlyReturns = (0..<120).map { index in
|
||||
InvestmentMetrics.MonthlyReturn(
|
||||
date: Calendar.current.date(byAdding: .month, value: -index, to: Date())!,
|
||||
returnPercentage: Double.random(in: -10...10)
|
||||
)
|
||||
}
|
||||
|
||||
// When/Then
|
||||
measure {
|
||||
_ = sut.calculateVolatility(monthlyReturns: monthlyReturns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class ReviewPromptServiceTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var suiteName: String!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = UUID().uuidString
|
||||
defaults = UserDefaults(suiteName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
if let suiteName {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testTriggersAfterMinimumCheckIns() {
|
||||
var didRequest = false
|
||||
let service = ReviewPromptService(
|
||||
userDefaults: defaults,
|
||||
dateProvider: { Date(timeIntervalSince1970: 1_000) },
|
||||
reviewRequestHandler: { didRequest = true }
|
||||
)
|
||||
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
XCTAssertFalse(didRequest)
|
||||
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
XCTAssertTrue(didRequest)
|
||||
}
|
||||
|
||||
func testDoesNotTriggerIfPromptedRecently() {
|
||||
var didRequest = false
|
||||
let now = Date(timeIntervalSince1970: 10_000)
|
||||
defaults.set(now, forKey: "reviewPromptLastDate")
|
||||
|
||||
let service = ReviewPromptService(
|
||||
userDefaults: defaults,
|
||||
dateProvider: { now.addingTimeInterval(60 * 60 * 24 * 10) },
|
||||
reviewRequestHandler: { didRequest = true }
|
||||
)
|
||||
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
service.recordMonthlyCheckInCompleted()
|
||||
|
||||
XCTAssertFalse(didRequest)
|
||||
}
|
||||
|
||||
func testAchievementPromptTriggersForNewUnlockWhenNotReviewed() {
|
||||
let service = ReviewPromptService(
|
||||
userDefaults: defaults,
|
||||
dateProvider: Date.init,
|
||||
reviewRequestHandler: {}
|
||||
)
|
||||
|
||||
let shouldAsk = service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"])
|
||||
|
||||
XCTAssertTrue(shouldAsk)
|
||||
}
|
||||
|
||||
func testAchievementPromptDoesNotTriggerTwiceForSameAchievement() {
|
||||
let service = ReviewPromptService(
|
||||
userDefaults: defaults,
|
||||
dateProvider: Date.init,
|
||||
reviewRequestHandler: {}
|
||||
)
|
||||
|
||||
XCTAssertTrue(service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"]))
|
||||
XCTAssertFalse(service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"]))
|
||||
}
|
||||
|
||||
func testAchievementPromptDoesNotTriggerAfterStoreReviewCompleted() {
|
||||
let service = ReviewPromptService(
|
||||
userDefaults: defaults,
|
||||
dateProvider: Date.init,
|
||||
reviewRequestHandler: {}
|
||||
)
|
||||
service.markStoreReviewCompleted()
|
||||
|
||||
let shouldAsk = service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_6"])
|
||||
|
||||
XCTAssertFalse(shouldAsk)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class ShareServiceTests: XCTestCase {
|
||||
func testBuildMonthlyCheckInShareText() {
|
||||
let summary = MonthlySummary(
|
||||
periodLabel: "January 2026",
|
||||
startDate: Date(timeIntervalSince1970: 0),
|
||||
endDate: Date(timeIntervalSince1970: 0),
|
||||
startingValue: 1000,
|
||||
endingValue: 1200,
|
||||
contributions: 150,
|
||||
netPerformance: 50
|
||||
)
|
||||
|
||||
let text = ShareService.buildMonthlyCheckInShareText(
|
||||
summary: summary,
|
||||
appName: "Portfolio Journal"
|
||||
)
|
||||
|
||||
XCTAssertTrue(text.contains("January 2026 Check-in"))
|
||||
XCTAssertTrue(text.contains("Starting:"))
|
||||
XCTAssertTrue(text.contains("Ending:"))
|
||||
XCTAssertTrue(text.contains("Contributions:"))
|
||||
XCTAssertTrue(text.contains("Net performance:"))
|
||||
XCTAssertTrue(text.contains("Shared from Portfolio Journal"))
|
||||
}
|
||||
|
||||
func testBuildPortfolioValueShareText() {
|
||||
let text = ShareService.buildPortfolioValueShareText(
|
||||
totalValue: "€120,000",
|
||||
changeText: "+€2,500 (+2.1%)",
|
||||
changeLabel: "since last check-in",
|
||||
yearChange: "+€8,000 (+7.1%)",
|
||||
sinceInceptionChange: "+€24,000 (+25.0%)",
|
||||
appName: "Portfolio Journal"
|
||||
)
|
||||
|
||||
XCTAssertTrue(text.contains("Total Portfolio Value"))
|
||||
XCTAssertTrue(text.contains("€120,000"))
|
||||
XCTAssertTrue(text.contains("+€2,500 (+2.1%) since last check-in"))
|
||||
XCTAssertTrue(text.contains("YoY: +€8,000 (+7.1%)"))
|
||||
XCTAssertTrue(text.contains("Since inception: +€24,000 (+25.0%)"))
|
||||
XCTAssertTrue(text.contains("Shared from Portfolio Journal"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class CurrencyFormatterTests: XCTestCase {
|
||||
|
||||
func testParseUserInput_withDotDecimalAndThreeDigits_keepsDecimal() {
|
||||
let value = CurrencyFormatter.parseUserInput("533.595", currencySymbol: "€")
|
||||
XCTAssertEqual(value, Decimal(string: "533.595"))
|
||||
}
|
||||
|
||||
func testParseUserInput_withCommaDecimalAndThreeDigits_keepsDecimal() {
|
||||
let value = CurrencyFormatter.parseUserInput("533,595", currencySymbol: "€")
|
||||
XCTAssertEqual(value, Decimal(string: "533.595"))
|
||||
}
|
||||
|
||||
func testParseUserInput_withGroupedThousands_preservesIntegerValue() {
|
||||
let dotGrouped = CurrencyFormatter.parseUserInput("1.234.567", currencySymbol: "€")
|
||||
let commaGrouped = CurrencyFormatter.parseUserInput("1,234,567", currencySymbol: "$")
|
||||
|
||||
XCTAssertEqual(dotGrouped, Decimal(string: "1234567"))
|
||||
XCTAssertEqual(commaGrouped, Decimal(string: "1234567"))
|
||||
}
|
||||
|
||||
func testParseUserInput_withMixedSeparators_parsesDecimal() {
|
||||
let eu = CurrencyFormatter.parseUserInput("1.234,56", currencySymbol: "€")
|
||||
let us = CurrencyFormatter.parseUserInput("1,234.56", currencySymbol: "$")
|
||||
|
||||
XCTAssertEqual(eu, Decimal(string: "1234.56"))
|
||||
XCTAssertEqual(us, Decimal(string: "1234.56"))
|
||||
}
|
||||
|
||||
func testParseUserInput_withSmallDecimalValue_keepsPrecision() {
|
||||
let value = CurrencyFormatter.parseUserInput("0.000123", currencySymbol: "$")
|
||||
XCTAssertEqual(value, Decimal(string: "0.000123"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class DateExtensionsTests: XCTestCase {
|
||||
|
||||
// MARK: - Start of Day
|
||||
|
||||
func testStartOfDay_returnsDateAtMidnight() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
components.hour = 14
|
||||
components.minute = 30
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.startOfDay
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.hour, .minute, .second], from: result)
|
||||
XCTAssertEqual(resultComponents.hour, 0)
|
||||
XCTAssertEqual(resultComponents.minute, 0)
|
||||
XCTAssertEqual(resultComponents.second, 0)
|
||||
}
|
||||
|
||||
// MARK: - Start of Month
|
||||
|
||||
func testStartOfMonth_returnsFirstDayOfMonth() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.startOfMonth
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.day, .month, .year], from: result)
|
||||
XCTAssertEqual(resultComponents.day, 1)
|
||||
XCTAssertEqual(resultComponents.month, 6)
|
||||
XCTAssertEqual(resultComponents.year, 2024)
|
||||
}
|
||||
|
||||
// MARK: - Start of Year
|
||||
|
||||
func testStartOfYear_returnsJanuaryFirst() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.startOfYear
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.day, .month, .year], from: result)
|
||||
XCTAssertEqual(resultComponents.day, 1)
|
||||
XCTAssertEqual(resultComponents.month, 1)
|
||||
XCTAssertEqual(resultComponents.year, 2024)
|
||||
}
|
||||
|
||||
// MARK: - End of Month
|
||||
|
||||
func testEndOfMonth_returnsLastDayOfMonth() {
|
||||
// Given: June has 30 days
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.endOfMonth
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.day, .month], from: result)
|
||||
XCTAssertEqual(resultComponents.day, 30)
|
||||
XCTAssertEqual(resultComponents.month, 6)
|
||||
}
|
||||
|
||||
func testEndOfMonth_handlesFebruaryInLeapYear() {
|
||||
// Given: February 2024 (leap year) has 29 days
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 2
|
||||
components.day = 10
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.endOfMonth
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.day, .month], from: result)
|
||||
XCTAssertEqual(resultComponents.day, 29)
|
||||
XCTAssertEqual(resultComponents.month, 2)
|
||||
}
|
||||
|
||||
// MARK: - Same Day Comparison
|
||||
|
||||
func testIsSameDay_withSameDay_returnsTrue() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
components.hour = 10
|
||||
let date1 = Calendar.current.date(from: components)!
|
||||
|
||||
components.hour = 20
|
||||
let date2 = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertTrue(date1.isSameDay(as: date2))
|
||||
}
|
||||
|
||||
func testIsSameDay_withDifferentDays_returnsFalse() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date1 = Calendar.current.date(from: components)!
|
||||
|
||||
components.day = 16
|
||||
let date2 = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertFalse(date1.isSameDay(as: date2))
|
||||
}
|
||||
|
||||
// MARK: - Same Month Comparison
|
||||
|
||||
func testIsSameMonth_withSameMonth_returnsTrue() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 1
|
||||
let date1 = Calendar.current.date(from: components)!
|
||||
|
||||
components.day = 30
|
||||
let date2 = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertTrue(date1.isSameMonth(as: date2))
|
||||
}
|
||||
|
||||
func testIsSameMonth_withDifferentMonths_returnsFalse() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date1 = Calendar.current.date(from: components)!
|
||||
|
||||
components.month = 7
|
||||
let date2 = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertFalse(date1.isSameMonth(as: date2))
|
||||
}
|
||||
|
||||
// MARK: - Same Year Comparison
|
||||
|
||||
func testIsSameYear_withSameYear_returnsTrue() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 1
|
||||
let date1 = Calendar.current.date(from: components)!
|
||||
|
||||
components.month = 12
|
||||
let date2 = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertTrue(date1.isSameYear(as: date2))
|
||||
}
|
||||
|
||||
func testIsSameYear_withDifferentYears_returnsFalse() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
let date1 = Calendar.current.date(from: components)!
|
||||
|
||||
components.year = 2023
|
||||
let date2 = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertFalse(date1.isSameYear(as: date2))
|
||||
}
|
||||
|
||||
// MARK: - Adding Time
|
||||
|
||||
func testAddingDays_addsCorrectly() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.adding(days: 10)
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.day, .month], from: result)
|
||||
XCTAssertEqual(resultComponents.day, 25)
|
||||
}
|
||||
|
||||
func testAddingDays_handlesNegativeValues() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.adding(days: -10)
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.day, .month], from: result)
|
||||
XCTAssertEqual(resultComponents.day, 5)
|
||||
}
|
||||
|
||||
func testAddingMonths_addsCorrectly() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.adding(months: 3)
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.month, .year], from: result)
|
||||
XCTAssertEqual(resultComponents.month, 9)
|
||||
XCTAssertEqual(resultComponents.year, 2024)
|
||||
}
|
||||
|
||||
func testAddingYears_addsCorrectly() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let date = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = date.adding(years: 2)
|
||||
|
||||
// Then
|
||||
let resultComponents = Calendar.current.dateComponents([.year], from: result)
|
||||
XCTAssertEqual(resultComponents.year, 2026)
|
||||
}
|
||||
|
||||
// MARK: - Days Between
|
||||
|
||||
func testDaysBetween_calculatesCorrectly() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 1
|
||||
let startDate = Calendar.current.date(from: components)!
|
||||
|
||||
components.day = 15
|
||||
let endDate = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = startDate.daysBetween(endDate)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 14)
|
||||
}
|
||||
|
||||
func testDaysBetween_withReversedDates_returnsNegative() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 15
|
||||
let startDate = Calendar.current.date(from: components)!
|
||||
|
||||
components.day = 1
|
||||
let endDate = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = startDate.daysBetween(endDate)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, -14)
|
||||
}
|
||||
|
||||
// MARK: - Months Between
|
||||
|
||||
func testMonthsBetween_calculatesCorrectly() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 1
|
||||
components.day = 15
|
||||
let startDate = Calendar.current.date(from: components)!
|
||||
|
||||
components.month = 6
|
||||
let endDate = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = startDate.monthsBetween(endDate)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 5)
|
||||
}
|
||||
|
||||
// MARK: - Years Between
|
||||
|
||||
func testYearsBetween_calculatesCorrectly() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2020
|
||||
components.month = 1
|
||||
components.day = 1
|
||||
let startDate = Calendar.current.date(from: components)!
|
||||
|
||||
components.year = 2024
|
||||
let endDate = Calendar.current.date(from: components)!
|
||||
|
||||
// When
|
||||
let result = startDate.yearsBetween(endDate)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 4.0, accuracy: 0.1)
|
||||
}
|
||||
|
||||
// MARK: - DateRange Tests
|
||||
|
||||
func testDateRange_contains_withDateInRange_returnsTrue() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 1
|
||||
let start = Calendar.current.date(from: components)!
|
||||
|
||||
components.day = 30
|
||||
let end = Calendar.current.date(from: components)!
|
||||
|
||||
let range = DateRange(start: start, end: end)
|
||||
|
||||
components.day = 15
|
||||
let testDate = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertTrue(range.contains(testDate))
|
||||
}
|
||||
|
||||
func testDateRange_contains_withDateOutsideRange_returnsFalse() {
|
||||
// Given
|
||||
var components = DateComponents()
|
||||
components.year = 2024
|
||||
components.month = 6
|
||||
components.day = 1
|
||||
let start = Calendar.current.date(from: components)!
|
||||
|
||||
components.day = 30
|
||||
let end = Calendar.current.date(from: components)!
|
||||
|
||||
let range = DateRange(start: start, end: end)
|
||||
|
||||
components.month = 7
|
||||
components.day = 15
|
||||
let testDate = Calendar.current.date(from: components)!
|
||||
|
||||
// When/Then
|
||||
XCTAssertFalse(range.contains(testDate))
|
||||
}
|
||||
|
||||
func testDateRange_lastMonths_createsCorrectRange() {
|
||||
// Given/When
|
||||
let range = DateRange.last(months: 3)
|
||||
|
||||
// Then
|
||||
let monthsDiff = range.start.monthsBetween(range.end)
|
||||
XCTAssertEqual(monthsDiff, 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class DecimalExtensionsTests: XCTestCase {
|
||||
|
||||
// MARK: - Conversions
|
||||
|
||||
func testDoubleValue_withPositiveDecimal_returnsCorrectValue() {
|
||||
// Given
|
||||
let decimal: Decimal = 123.45
|
||||
|
||||
// When
|
||||
let result = decimal.doubleValue
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 123.45, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDoubleValue_withNegativeDecimal_returnsCorrectValue() {
|
||||
// Given
|
||||
let decimal: Decimal = -99.99
|
||||
|
||||
// When
|
||||
let result = decimal.doubleValue
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, -99.99, accuracy: 0.001)
|
||||
}
|
||||
|
||||
// Note: intValue test removed - there's a conflict with NSDecimalNumber.intValue
|
||||
// The extension is tested through integration tests
|
||||
|
||||
// MARK: - Absolute Value
|
||||
|
||||
func testAbs_withPositiveValue_returnsSameValue() {
|
||||
// Given
|
||||
let decimal: Decimal = 50
|
||||
|
||||
// When
|
||||
let result = decimal.abs
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 50)
|
||||
}
|
||||
|
||||
func testAbs_withNegativeValue_returnsPositiveValue() {
|
||||
// Given
|
||||
let decimal: Decimal = -50
|
||||
|
||||
// When
|
||||
let result = decimal.abs
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 50)
|
||||
}
|
||||
|
||||
func testAbs_withZero_returnsZero() {
|
||||
// Given
|
||||
let decimal: Decimal = 0
|
||||
|
||||
// When
|
||||
let result = decimal.abs
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 0)
|
||||
}
|
||||
|
||||
// MARK: - Rounding
|
||||
|
||||
func testRounded_withDefaultScale_roundsToTwoDecimals() {
|
||||
// Given
|
||||
let decimal: Decimal = 123.456789
|
||||
|
||||
// When
|
||||
let result = decimal.rounded()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 123.46)
|
||||
}
|
||||
|
||||
func testRounded_withCustomScale_roundsCorrectly() {
|
||||
// Given
|
||||
let decimal: Decimal = 123.456789
|
||||
|
||||
// When
|
||||
let result = decimal.rounded(scale: 0)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 123)
|
||||
}
|
||||
|
||||
func testRounded_withNegativeValue_roundsCorrectly() {
|
||||
// Given
|
||||
let decimal: Decimal = -123.456789
|
||||
|
||||
// When
|
||||
let result = decimal.rounded(scale: 1)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, -123.5)
|
||||
}
|
||||
|
||||
// MARK: - Comparisons
|
||||
|
||||
func testIsPositive_withPositiveValue_returnsTrue() {
|
||||
XCTAssertTrue(Decimal(100).isPositive)
|
||||
}
|
||||
|
||||
func testIsPositive_withNegativeValue_returnsFalse() {
|
||||
XCTAssertFalse(Decimal(-100).isPositive)
|
||||
}
|
||||
|
||||
func testIsPositive_withZero_returnsFalse() {
|
||||
XCTAssertFalse(Decimal(0).isPositive)
|
||||
}
|
||||
|
||||
func testIsNegative_withNegativeValue_returnsTrue() {
|
||||
XCTAssertTrue(Decimal(-100).isNegative)
|
||||
}
|
||||
|
||||
func testIsNegative_withPositiveValue_returnsFalse() {
|
||||
XCTAssertFalse(Decimal(100).isNegative)
|
||||
}
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
func testFromDouble_createsCorrectDecimal() {
|
||||
// Given
|
||||
let doubleValue = 123.45
|
||||
|
||||
// When
|
||||
let result = Decimal.from(doubleValue)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 123.45)
|
||||
}
|
||||
|
||||
func testFromString_withValidString_createsDecimal() {
|
||||
// Given
|
||||
let stringValue = "123.45"
|
||||
|
||||
// When
|
||||
let result = Decimal.from(stringValue)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result, 123.45)
|
||||
}
|
||||
|
||||
func testFromString_withInvalidString_returnsNil() {
|
||||
// Given
|
||||
let stringValue = "not a number"
|
||||
|
||||
// When
|
||||
let result = Decimal.from(stringValue)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(result)
|
||||
}
|
||||
|
||||
// MARK: - Optional Extension
|
||||
|
||||
func testOrZero_withValue_returnsValue() {
|
||||
// Given
|
||||
let optional: Decimal? = 100
|
||||
|
||||
// When
|
||||
let result = optional.orZero
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 100)
|
||||
}
|
||||
|
||||
func testOrZero_withNil_returnsZero() {
|
||||
// Given
|
||||
let optional: Decimal? = nil
|
||||
|
||||
// When
|
||||
let result = optional.orZero
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(result, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class MonthlyCheckInStoreTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
MonthlyCheckInStore.clearAll()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
MonthlyCheckInStore.clearAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testEffectiveMonthUsesPreviousMonthWithinGracePeriod() {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 20))!
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate, graceDays: 20)
|
||||
|
||||
let expected = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))!
|
||||
XCTAssertEqual(effective, expected)
|
||||
}
|
||||
|
||||
func testEffectiveMonthUsesCurrentMonthAfterGracePeriod() {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 21))!
|
||||
let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate, graceDays: 20)
|
||||
|
||||
let expected = calendar.date(from: DateComponents(year: 2026, month: 2, day: 1))!
|
||||
XCTAssertEqual(effective, expected)
|
||||
}
|
||||
|
||||
func testCompletionAfterGraceAutoFillsMissingMonths() {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let decemberDate = calendar.date(from: DateComponents(year: 2025, month: 12, day: 15))!
|
||||
let decemberCompletion = calendar.date(from: DateComponents(year: 2025, month: 12, day: 31))!
|
||||
MonthlyCheckInStore.setNote("Carry", for: decemberDate)
|
||||
MonthlyCheckInStore.setRating(4, for: decemberDate)
|
||||
MonthlyCheckInStore.setMood(.balanced, for: decemberDate)
|
||||
MonthlyCheckInStore.setCompletionDate(decemberCompletion, for: decemberDate)
|
||||
|
||||
let completionDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 21))!
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: completionDate)
|
||||
|
||||
let januaryDate = calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))!
|
||||
let januaryEntry = MonthlyCheckInStore.entry(for: januaryDate)
|
||||
XCTAssertEqual(januaryEntry?.note, "Carry")
|
||||
XCTAssertEqual(januaryEntry?.rating, 4)
|
||||
XCTAssertEqual(januaryEntry?.mood, .balanced)
|
||||
XCTAssertNotNil(januaryEntry?.completionDate)
|
||||
}
|
||||
|
||||
func testSetCompletionDateForVeryOldMonthStillCompletesEntry() {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))!
|
||||
let completionDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 22))!
|
||||
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: oldMonthDate)
|
||||
|
||||
let entry = MonthlyCheckInStore.entry(for: oldMonthDate)
|
||||
XCTAssertNotNil(entry?.completionDate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
@MainActor
|
||||
final class ChartsViewModelDisplayRulesTests: XCTestCase {
|
||||
func testAllocationTargetsSupportedOnlyForCategoryBreakdown() {
|
||||
XCTAssertTrue(ChartsViewModel.supportsAllocationTargets(for: .category))
|
||||
XCTAssertFalse(ChartsViewModel.supportsAllocationTargets(for: .source))
|
||||
}
|
||||
|
||||
func testEvolutionTimeRangesIncludeRequestedOptions() {
|
||||
let viewModel = ChartsViewModel(iapService: IAPService())
|
||||
let ranges = viewModel.availableTimeRanges(for: .evolution)
|
||||
|
||||
XCTAssertEqual(ranges, [.all, .yearToDate, .year, .quarter])
|
||||
}
|
||||
|
||||
func testYearToDateRangeStartsAtBeginningOfYear() {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 8, day: 15))!
|
||||
let startDate = ChartsViewModel.TimeRange.yearToDate.startDate(referenceDate: referenceDate)
|
||||
let expected = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))!
|
||||
|
||||
XCTAssertEqual(startDate, expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
@MainActor
|
||||
final class GoalsDisplayRulesTests: XCTestCase {
|
||||
func testIsAchievedProgressThreshold() {
|
||||
XCTAssertFalse(GoalsViewModel.isAchieved(progress: 0.998))
|
||||
XCTAssertTrue(GoalsViewModel.isAchieved(progress: 0.999))
|
||||
XCTAssertTrue(GoalsViewModel.isAchieved(progress: 1.0))
|
||||
}
|
||||
|
||||
func testUrgencyLevelIsCriticalWhenBehindAndPastTargetDate() {
|
||||
let targetDate = Date(timeIntervalSince1970: 1_000_000)
|
||||
let referenceDate = Date(timeIntervalSince1970: 1_100_000)
|
||||
|
||||
let level = GoalsViewModel.urgencyLevel(
|
||||
targetDate: targetDate,
|
||||
isBehind: true,
|
||||
isAchieved: false,
|
||||
referenceDate: referenceDate
|
||||
)
|
||||
|
||||
XCTAssertEqual(level, .critical)
|
||||
}
|
||||
|
||||
func testUrgencyLevelIsWarningWhenBehindBeforeTargetDate() {
|
||||
let targetDate = Date(timeIntervalSince1970: 1_100_000)
|
||||
let referenceDate = Date(timeIntervalSince1970: 1_000_000)
|
||||
|
||||
let level = GoalsViewModel.urgencyLevel(
|
||||
targetDate: targetDate,
|
||||
isBehind: true,
|
||||
isAchieved: false,
|
||||
referenceDate: referenceDate
|
||||
)
|
||||
|
||||
XCTAssertEqual(level, .warning)
|
||||
}
|
||||
|
||||
func testUrgencyLevelIsNormalForAchievedGoals() {
|
||||
let level = GoalsViewModel.urgencyLevel(
|
||||
targetDate: Date(),
|
||||
isBehind: true,
|
||||
isAchieved: true,
|
||||
referenceDate: Date().addingTimeInterval(86_400)
|
||||
)
|
||||
|
||||
XCTAssertEqual(level, .normal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import XCTest
|
||||
import Combine
|
||||
@testable import PortfolioJournal
|
||||
|
||||
// Note: These tests require Core Data model access. Disabled by default.
|
||||
@MainActor
|
||||
final class GoalsViewModelTests: XCTestCase {
|
||||
|
||||
override class var defaultTestSuite: XCTestSuite {
|
||||
// Skip all tests in this class - Core Data is not available in test bundle
|
||||
return XCTestSuite(name: "GoalsViewModelTests (Skipped - Core Data not available)")
|
||||
}
|
||||
|
||||
var testStack: TestCoreDataStack?
|
||||
var cancellables: Set<AnyCancellable>!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
do {
|
||||
testStack = try TestCoreDataStack()
|
||||
} catch {
|
||||
testStack = nil
|
||||
}
|
||||
cancellables = []
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
testStack?.reset()
|
||||
testStack = nil
|
||||
cancellables = nil
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
/// Helper to skip test if Core Data is not available
|
||||
private func requireCoreData() throws -> TestCoreDataStack {
|
||||
guard let stack = testStack else {
|
||||
throw XCTSkip("Core Data is not available in this test environment")
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
||||
// MARK: - Progress Calculation Tests
|
||||
|
||||
func testProgress_withZeroTarget_returnsZero() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let goal = stack.createTestGoal(name: "Zero Target", targetAmount: 0)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
|
||||
// When
|
||||
let progress = viewModel.progress(for: goal)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(progress, 0)
|
||||
}
|
||||
|
||||
func testProgress_withValueExceedingTarget_returnsCappedValue() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let account = stack.createTestAccount()
|
||||
let category = stack.createTestCategory()
|
||||
let source = stack.createTestSource(account: account, category: category)
|
||||
_ = stack.createTestSnapshot(source: source, value: 150_000) // Current value
|
||||
let goal = stack.createTestGoal(name: "100K Goal", targetAmount: 100_000, account: account)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
viewModel.refresh()
|
||||
|
||||
// Allow time for repositories to update
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
// When
|
||||
let progress = viewModel.progress(for: goal)
|
||||
|
||||
// Then - progress should be capped at 1.0 (100%)
|
||||
XCTAssertEqual(progress, 1.0, accuracy: 0.01)
|
||||
}
|
||||
|
||||
// MARK: - Total Value Tests
|
||||
|
||||
func testTotalValue_withAccountSpecificGoal_returnsAccountTotal() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let account1 = stack.createTestAccount(name: "Account 1")
|
||||
let account2 = stack.createTestAccount(name: "Account 2")
|
||||
|
||||
let source1 = stack.createTestSource(name: "Source 1", account: account1)
|
||||
let source2 = stack.createTestSource(name: "Source 2", account: account2)
|
||||
|
||||
_ = stack.createTestSnapshot(source: source1, value: 50_000)
|
||||
_ = stack.createTestSnapshot(source: source2, value: 30_000)
|
||||
|
||||
let goal = stack.createTestGoal(
|
||||
name: "Account 1 Goal",
|
||||
targetAmount: 100_000,
|
||||
account: account1
|
||||
)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
viewModel.refresh()
|
||||
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
// When
|
||||
let totalValue = viewModel.totalValue(for: goal)
|
||||
|
||||
// Then - should only count Account 1's value
|
||||
XCTAssertEqual(totalValue, 50_000)
|
||||
}
|
||||
|
||||
// MARK: - Pace Status Tests
|
||||
|
||||
func testPaceStatus_withNoTargetDate_returnsNil() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let goal = stack.createTestGoal(
|
||||
name: "No Date Goal",
|
||||
targetAmount: 100_000,
|
||||
targetDate: nil
|
||||
)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
|
||||
// When
|
||||
let paceStatus = viewModel.paceStatus(for: goal)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(paceStatus)
|
||||
}
|
||||
|
||||
func testPaceStatus_withCompletedGoal_returnsGoalReached() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let account = stack.createTestAccount()
|
||||
let source = stack.createTestSource(account: account)
|
||||
_ = stack.createTestSnapshot(source: source, value: 100_000)
|
||||
|
||||
let futureDate = Date().adding(years: 1)
|
||||
let goal = stack.createTestGoal(
|
||||
name: "Completed Goal",
|
||||
targetAmount: 50_000, // Already exceeded
|
||||
account: account,
|
||||
targetDate: futureDate
|
||||
)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
viewModel.refresh()
|
||||
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
// When
|
||||
let paceStatus = viewModel.paceStatus(for: goal)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(paceStatus)
|
||||
XCTAssertEqual(paceStatus?.statusText, "Goal reached")
|
||||
XCTAssertFalse(paceStatus?.isBehind ?? true)
|
||||
}
|
||||
|
||||
// MARK: - Estimate Completion Date Tests
|
||||
|
||||
func testEstimateCompletionDate_withNoSources_returnsNil() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let goal = stack.createTestGoal(
|
||||
name: "Empty Goal",
|
||||
targetAmount: 100_000
|
||||
)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
viewModel.refresh()
|
||||
|
||||
// When
|
||||
let completionDate = viewModel.estimateCompletionDate(for: goal)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(completionDate)
|
||||
}
|
||||
|
||||
// MARK: - Refresh Tests
|
||||
|
||||
func testRefresh_invalidatesCaches() async throws {
|
||||
// Given
|
||||
_ = try requireCoreData()
|
||||
let viewModel = GoalsViewModel()
|
||||
|
||||
// When
|
||||
viewModel.refresh()
|
||||
viewModel.refresh() // Second refresh should work without issues
|
||||
|
||||
// Then - no crash
|
||||
XCTAssertNotNil(viewModel)
|
||||
}
|
||||
|
||||
// MARK: - Account Filtering Tests
|
||||
|
||||
func testShowAllAccounts_includesAllGoals() async throws {
|
||||
// Given
|
||||
let stack = try requireCoreData()
|
||||
let account1 = stack.createTestAccount(name: "Account 1")
|
||||
let account2 = stack.createTestAccount(name: "Account 2")
|
||||
|
||||
_ = stack.createTestGoal(name: "Goal 1", targetAmount: 100_000, account: account1)
|
||||
_ = stack.createTestGoal(name: "Goal 2", targetAmount: 50_000, account: account2)
|
||||
try? stack.save()
|
||||
|
||||
let viewModel = GoalsViewModel()
|
||||
|
||||
// When
|
||||
viewModel.showAllAccounts = true
|
||||
viewModel.refresh()
|
||||
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
// Then - should include goals from both accounts
|
||||
// Note: This depends on repository implementation
|
||||
XCTAssertTrue(viewModel.showAllAccounts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
@MainActor
|
||||
final class SettingsViewModelTests: XCTestCase {
|
||||
override func tearDown() {
|
||||
UserDefaults.standard.removeObject(forKey: "backupsEnabled")
|
||||
UserDefaults.standard.removeObject(forKey: "backupRetentionCount")
|
||||
UserDefaults.standard.removeObject(forKey: "debugPremiumOverride")
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSetBackupsEnabledNonPremiumShowsPaywall() {
|
||||
let iap = IAPService()
|
||||
#if DEBUG
|
||||
iap.setPremiumForTesting(false)
|
||||
#endif
|
||||
let viewModel = SettingsViewModel(iapService: iap)
|
||||
|
||||
viewModel.setBackupsEnabled(true)
|
||||
|
||||
XCTAssertFalse(viewModel.backupsEnabled)
|
||||
XCTAssertTrue(viewModel.showingPaywall)
|
||||
}
|
||||
|
||||
func testSetBackupsEnabledPremiumPersistsFlag() {
|
||||
#if DEBUG
|
||||
UserDefaults.standard.set(true, forKey: "debugPremiumOverride")
|
||||
#endif
|
||||
let iap = IAPService()
|
||||
#if DEBUG
|
||||
iap.setDebugPremiumOverride(true)
|
||||
#endif
|
||||
let viewModel = SettingsViewModel(iapService: iap)
|
||||
|
||||
viewModel.setBackupsEnabled(true)
|
||||
|
||||
XCTAssertTrue(viewModel.backupsEnabled)
|
||||
XCTAssertFalse(viewModel.showingPaywall)
|
||||
XCTAssertTrue(UserDefaults.standard.bool(forKey: "backupsEnabled"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import XCTest
|
||||
import Combine
|
||||
@testable import PortfolioJournal
|
||||
|
||||
// Note: These tests require Core Data model access. Disabled by default.
|
||||
@MainActor
|
||||
final class SnapshotFormViewModelTests: XCTestCase {
|
||||
|
||||
override class var defaultTestSuite: XCTestSuite {
|
||||
// Skip all tests in this class - Core Data is not available in test bundle
|
||||
return XCTestSuite(name: "SnapshotFormViewModelTests (Skipped - Core Data not available)")
|
||||
}
|
||||
|
||||
var testStack: TestCoreDataStack?
|
||||
var cancellables: Set<AnyCancellable>!
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
do {
|
||||
testStack = try TestCoreDataStack()
|
||||
} catch {
|
||||
testStack = nil
|
||||
}
|
||||
cancellables = []
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
testStack?.reset()
|
||||
testStack = nil
|
||||
cancellables = nil
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
/// Helper to skip test if Core Data is not available
|
||||
private func requireCoreData() throws -> TestCoreDataStack {
|
||||
guard let stack = testStack else {
|
||||
throw XCTSkip("Core Data is not available in this test environment")
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
private func createTestSource() throws -> InvestmentSource {
|
||||
let stack = try requireCoreData()
|
||||
let account = stack.createTestAccount(name: "Test Account", currency: "USD")
|
||||
let source = stack.createTestSource(name: "Test Source", account: account)
|
||||
try? stack.save()
|
||||
return source
|
||||
}
|
||||
|
||||
// MARK: - Initialization Tests
|
||||
|
||||
func testInit_setsCorrectCurrencySymbol() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
|
||||
// When
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(viewModel.currencySymbol, "$")
|
||||
}
|
||||
|
||||
func testInit_inAddMode_hasEmptyValueString() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
|
||||
// When
|
||||
let viewModel = SnapshotFormViewModel(source: source, mode: .add)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(viewModel.valueString, "")
|
||||
}
|
||||
|
||||
// MARK: - Validation Tests
|
||||
|
||||
func testValidation_withEmptyValue_isNotValid() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = ""
|
||||
|
||||
// Then - wait for validation to propagate
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
XCTAssertFalse(viewModel.isValid)
|
||||
}
|
||||
|
||||
func testValidation_withValidValue_isValid() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = "1000.00"
|
||||
|
||||
// Then - wait for validation to propagate
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
XCTAssertTrue(viewModel.isValid)
|
||||
}
|
||||
|
||||
func testValidation_withNegativeValue_isNotValid() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = "-100"
|
||||
|
||||
// Then
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
XCTAssertFalse(viewModel.isValid)
|
||||
}
|
||||
|
||||
func testValidation_withContributionEnabled_requiresValidContribution() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = "1000"
|
||||
viewModel.includeContribution = true
|
||||
viewModel.contributionString = ""
|
||||
|
||||
// Then
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
XCTAssertFalse(viewModel.isValid)
|
||||
}
|
||||
|
||||
func testValidation_withValidContribution_isValid() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = "1000"
|
||||
viewModel.includeContribution = true
|
||||
viewModel.contributionString = "100"
|
||||
|
||||
// Then
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
XCTAssertTrue(viewModel.isValid)
|
||||
}
|
||||
|
||||
// MARK: - Parsing Tests
|
||||
|
||||
func testParsing_withUSFormat_parsesCorrectly() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = "1000.50"
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(viewModel.value, 1000.50)
|
||||
}
|
||||
|
||||
func testParsing_withCommaDecimalSeparator_parsesCorrectly() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When - comma as decimal separator (European format)
|
||||
viewModel.valueString = "1000,50"
|
||||
|
||||
// Then - should parse with fallback
|
||||
XCTAssertNotNil(viewModel.value)
|
||||
}
|
||||
|
||||
func testParsing_withCurrencySymbol_stripsSymbol() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.valueString = "$1000.50"
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(viewModel.value)
|
||||
}
|
||||
|
||||
// MARK: - Title Tests
|
||||
|
||||
func testTitle_inAddMode_returnsAddSnapshot() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
|
||||
// When
|
||||
let viewModel = SnapshotFormViewModel(source: source, mode: .add)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(viewModel.title, "Add Snapshot")
|
||||
}
|
||||
|
||||
// MARK: - Button Title Tests
|
||||
|
||||
func testButtonTitle_inAddMode_returnsAddSnapshot() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
|
||||
// When
|
||||
let viewModel = SnapshotFormViewModel(source: source, mode: .add)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(viewModel.buttonTitle, "Add Snapshot")
|
||||
}
|
||||
|
||||
// MARK: - Previous Value Tests
|
||||
|
||||
func testPreviousValue_withNoSnapshots_returnsNil() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When/Then
|
||||
XCTAssertNil(viewModel.previousValue)
|
||||
}
|
||||
|
||||
func testPreviousValueString_withNoSnapshots_returnsNoPreviousValue() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When/Then
|
||||
XCTAssertEqual(viewModel.previousValueString, "No previous value")
|
||||
}
|
||||
|
||||
// MARK: - Date Validation Tests
|
||||
|
||||
func testIsDateInFuture_withCurrentDate_returnsFalse() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.date = Date()
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(viewModel.isDateInFuture)
|
||||
}
|
||||
|
||||
func testIsDateInFuture_withFutureDate_returnsTrue() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.date = Date().adding(days: 1)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(viewModel.isDateInFuture)
|
||||
}
|
||||
|
||||
func testDateWarning_withFutureDate_returnsWarning() async throws {
|
||||
// Given
|
||||
let source = try createTestSource()
|
||||
let viewModel = SnapshotFormViewModel(source: source)
|
||||
|
||||
// When
|
||||
viewModel.date = Date().adding(days: 1)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(viewModel.dateWarning)
|
||||
XCTAssertEqual(viewModel.dateWarning, "Date is in the future")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class MonthlyCheckInViewTests: XCTestCase {
|
||||
func testMonthLabelUsesMonthAndYear() {
|
||||
var components = DateComponents()
|
||||
components.year = 2026
|
||||
components.month = 2
|
||||
components.day = 1
|
||||
let date = Calendar(identifier: .gregorian).date(from: components)!
|
||||
|
||||
let label = MonthlyCheckInView.monthLabel(
|
||||
for: date,
|
||||
relativeTo: date,
|
||||
locale: Locale(identifier: "en_US_POSIX")
|
||||
)
|
||||
|
||||
XCTAssertEqual(label, "February 2026")
|
||||
}
|
||||
|
||||
func testMonthLabelUsesPreviousMonthDuringGracePeriod() {
|
||||
var components = DateComponents()
|
||||
components.year = 2026
|
||||
components.month = 2
|
||||
components.day = 20
|
||||
let date = Calendar(identifier: .gregorian).date(from: components)!
|
||||
|
||||
let label = MonthlyCheckInView.monthLabel(
|
||||
for: date,
|
||||
relativeTo: date,
|
||||
locale: Locale(identifier: "en_US_POSIX")
|
||||
)
|
||||
|
||||
XCTAssertEqual(label, "January 2026")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
@testable import PortfolioJournal
|
||||
|
||||
final class OnboardingViewTests: XCTestCase {
|
||||
func testAppSettingsURLMatchesSystemConstant() {
|
||||
let expected = URL(string: UIApplication.openSettingsURLString)
|
||||
XCTAssertEqual(OnboardingQuickStartView.appSettingsURL(), expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import XCTest
|
||||
|
||||
/// UI Tests for Portfolio Journal app
|
||||
/// These tests verify the app's user interface and navigation flows
|
||||
final class PortfolioJournalUITests: XCTestCase {
|
||||
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["--uitesting"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// MARK: - App Launch Tests
|
||||
|
||||
func testAppLaunches() throws {
|
||||
// Verify the app launches successfully
|
||||
XCTAssertTrue(app.exists)
|
||||
}
|
||||
|
||||
func testTabBarExists() throws {
|
||||
// Wait for the tab bar to appear
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let exists = tabBar.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(exists, "Tab bar should exist after launch")
|
||||
}
|
||||
|
||||
// MARK: - Navigation Tests
|
||||
|
||||
func testDashboardTabIsSelected() throws {
|
||||
// Dashboard should be the default selected tab
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 5)
|
||||
|
||||
// Look for Dashboard tab button
|
||||
let dashboardTab = tabBar.buttons["Dashboard"]
|
||||
if dashboardTab.exists {
|
||||
XCTAssertTrue(dashboardTab.isSelected || dashboardTab.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
func testNavigateToSourcesTab() throws {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 5)
|
||||
|
||||
let sourcesTab = tabBar.buttons["Sources"]
|
||||
if sourcesTab.exists {
|
||||
sourcesTab.tap()
|
||||
// Verify we're on the Sources screen
|
||||
XCTAssertTrue(sourcesTab.isSelected || sourcesTab.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
func testNavigateToGoalsTab() throws {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 5)
|
||||
|
||||
let goalsTab = tabBar.buttons["Goals"]
|
||||
if goalsTab.exists {
|
||||
goalsTab.tap()
|
||||
XCTAssertTrue(goalsTab.isSelected || goalsTab.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
func testNavigateToJournalTab() throws {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 5)
|
||||
|
||||
let journalTab = tabBar.buttons["Journal"]
|
||||
if journalTab.exists {
|
||||
journalTab.tap()
|
||||
XCTAssertTrue(journalTab.isSelected || journalTab.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
func testNavigateToSettingsTab() throws {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 5)
|
||||
|
||||
let settingsTab = tabBar.buttons["Settings"]
|
||||
if settingsTab.exists {
|
||||
settingsTab.tap()
|
||||
XCTAssertTrue(settingsTab.isSelected || settingsTab.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - All Tabs Navigation Test
|
||||
|
||||
func testNavigateThroughAllTabs() throws {
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
guard tabBar.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Tab bar not found")
|
||||
return
|
||||
}
|
||||
|
||||
let tabs = ["Dashboard", "Sources", "Goals", "Journal", "Settings"]
|
||||
|
||||
for tabName in tabs {
|
||||
let tab = tabBar.buttons[tabName]
|
||||
if tab.exists && tab.isHittable {
|
||||
tab.tap()
|
||||
// Give time for navigation
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
// Return to Dashboard
|
||||
let dashboardTab = tabBar.buttons["Dashboard"]
|
||||
if dashboardTab.exists {
|
||||
dashboardTab.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import XCTest
|
||||
|
||||
/// Launch performance tests for Portfolio Journal
|
||||
final class PortfolioJournalUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Take a screenshot after launch
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
func testLaunchPerformance() throws {
|
||||
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
|
||||
/// UI Tests for Settings functionality
|
||||
final class SettingsUITests: XCTestCase {
|
||||
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["--uitesting"]
|
||||
app.launch()
|
||||
|
||||
// Navigate to Settings tab
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
_ = tabBar.waitForExistence(timeout: 5)
|
||||
let settingsTab = tabBar.buttons["Settings"]
|
||||
if settingsTab.exists {
|
||||
settingsTab.tap()
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app = nil
|
||||
}
|
||||
|
||||
// MARK: - Settings Screen Tests
|
||||
|
||||
func testSettingsScreenLoads() throws {
|
||||
// Verify settings content is visible
|
||||
// Look for common settings elements
|
||||
let settingsView = app.scrollViews.firstMatch
|
||||
XCTAssertTrue(settingsView.exists || app.collectionViews.firstMatch.exists || app.tables.firstMatch.exists)
|
||||
}
|
||||
|
||||
func testCurrencySettingExists() throws {
|
||||
// Look for currency-related UI elements
|
||||
let currencyLabel = app.staticTexts["Currency"]
|
||||
if currencyLabel.waitForExistence(timeout: 2) {
|
||||
XCTAssertTrue(currencyLabel.exists)
|
||||
}
|
||||
}
|
||||
|
||||
func testAppVersionDisplayed() throws {
|
||||
// Scroll to bottom if needed and look for version info
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists {
|
||||
scrollView.swipeUp()
|
||||
}
|
||||
|
||||
// Version text is usually at the bottom
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
// Just verify the settings screen is still visible after scrolling
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
XCTAssertTrue(tabBar.exists)
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,28 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Investment Widget</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Investment Widget</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -46,6 +46,19 @@ private func createWidgetContainer() -> NSPersistentContainer? {
|
||||
return container
|
||||
}
|
||||
|
||||
private func fetchCurrencyCode(from container: NSPersistentContainer?) -> String {
|
||||
guard let container = container else { return "EUR" }
|
||||
let context = container.viewContext
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: "AppSettings")
|
||||
request.fetchLimit = 1
|
||||
if let settings = try? context.fetch(request).first,
|
||||
let code = settings.value(forKey: "currency") as? String,
|
||||
!code.isEmpty {
|
||||
return code
|
||||
}
|
||||
return "EUR"
|
||||
}
|
||||
|
||||
// MARK: - Widget Entry
|
||||
|
||||
struct InvestmentWidgetEntry: TimelineEntry {
|
||||
@@ -60,6 +73,7 @@ struct InvestmentWidgetEntry: TimelineEntry {
|
||||
let categoryEvolution: [CategorySeries]
|
||||
let categoryTotals: [(name: String, value: Decimal, color: String)]
|
||||
let goals: [GoalSummary]
|
||||
let currencyCode: String
|
||||
}
|
||||
|
||||
struct CategorySeries: Identifiable {
|
||||
@@ -124,7 +138,8 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
],
|
||||
goals: [
|
||||
GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil)
|
||||
]
|
||||
],
|
||||
currencyCode: "EUR"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,8 +160,10 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
|
||||
private func fetchData() -> InvestmentWidgetEntry {
|
||||
let isPremium = UserDefaults(suiteName: appGroupIdentifier)?.bool(forKey: sharedPremiumKey) ?? false
|
||||
let container = createWidgetContainer()
|
||||
let currencyCode = fetchCurrencyCode(from: container)
|
||||
|
||||
guard let container = createWidgetContainer() else {
|
||||
guard let container = container else {
|
||||
return InvestmentWidgetEntry(
|
||||
date: Date(),
|
||||
isPremium: isPremium,
|
||||
@@ -158,7 +175,8 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
trendLabels: [],
|
||||
categoryEvolution: [],
|
||||
categoryTotals: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
currencyCode: currencyCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,7 +264,12 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
}
|
||||
let monthFormatter = DateFormatter()
|
||||
monthFormatter.dateFormat = "MMM"
|
||||
trendPoints = months.map { monthlyTotals[$0] ?? .zero }
|
||||
trendPoints = months.map { month in
|
||||
if let value = monthlyTotals[month] { return value }
|
||||
// Forward-fill: use the most recent earlier month's value
|
||||
let previous = sortedMonths.last { $0.0 < month }
|
||||
return previous?.1 ?? .zero
|
||||
}
|
||||
trendLabels = months.map { monthFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
@@ -289,7 +312,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
|
||||
let categoryEvolution: [CategorySeries] = categoryTotalsData.prefix(4).map { category in
|
||||
let monthMap = categoryMonthlyTotals[category.id] ?? [:]
|
||||
let points = months.map { monthMap[$0] ?? .zero }
|
||||
let sortedCategoryMonths = monthMap.map { ($0.key, $0.value) }.sorted { $0.0 < $1.0 }
|
||||
let points = months.map { month -> Decimal in
|
||||
if let value = monthMap[month] { return value }
|
||||
// Forward-fill: use the most recent earlier month's value
|
||||
let previous = sortedCategoryMonths.last { $0.0 < month }
|
||||
return previous?.1 ?? .zero
|
||||
}
|
||||
return CategorySeries(
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
@@ -344,7 +373,8 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
trendLabels: trendLabels,
|
||||
categoryEvolution: categoryEvolution,
|
||||
categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) },
|
||||
goals: goals
|
||||
goals: goals,
|
||||
currencyCode: currencyCode
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -360,7 +390,7 @@ struct SmallWidgetView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString)
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
@@ -369,7 +399,7 @@ struct SmallWidgetView: View {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString)
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
|
||||
Text(String(format: "%.1f%% since last", entry.dayChangePercentage))
|
||||
@@ -398,7 +428,7 @@ struct MediumWidgetView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString)
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
@@ -407,7 +437,7 @@ struct MediumWidgetView: View {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString)
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
@@ -425,7 +455,8 @@ struct MediumWidgetView: View {
|
||||
TrendLineChartView(
|
||||
points: entry.trendPoints,
|
||||
labels: entry.trendLabels,
|
||||
goal: entry.goals.first
|
||||
goal: entry.goals.first,
|
||||
currencyCode: entry.currencyCode
|
||||
)
|
||||
.frame(height: 70)
|
||||
} else {
|
||||
@@ -462,7 +493,7 @@ struct MediumWidgetView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(source.value.shortCurrencyString)
|
||||
Text(source.value.shortCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
}
|
||||
@@ -490,7 +521,7 @@ struct LargeWidgetView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString)
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
@@ -499,7 +530,7 @@ struct LargeWidgetView: View {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString)
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
@@ -521,7 +552,8 @@ struct LargeWidgetView: View {
|
||||
CombinedCategoryChartView(
|
||||
series: entry.categoryEvolution,
|
||||
labels: entry.trendLabels,
|
||||
goal: entry.goals.first
|
||||
goal: entry.goals.first,
|
||||
currencyCode: entry.currencyCode
|
||||
)
|
||||
.frame(height: 98)
|
||||
|
||||
@@ -538,7 +570,7 @@ struct LargeWidgetView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.value.shortCurrencyString)
|
||||
Text(category.value.shortCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
}
|
||||
@@ -601,7 +633,7 @@ struct AccessoryRectangularView: View {
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString)
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
@@ -623,6 +655,7 @@ struct TrendLineChartView: View {
|
||||
let points: [Decimal]
|
||||
let labels: [String]
|
||||
let goal: GoalSummary?
|
||||
var currencyCode: String = "EUR"
|
||||
|
||||
private var values: [Double] {
|
||||
points.map { NSDecimalNumber(decimal: $0).doubleValue }
|
||||
@@ -646,11 +679,11 @@ struct TrendLineChartView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Decimal(maxValue).shortCurrencyString)
|
||||
Text(Decimal(maxValue).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(Decimal(minValue).shortCurrencyString)
|
||||
Text(Decimal(minValue).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -715,6 +748,7 @@ struct CombinedCategoryChartView: View {
|
||||
let series: [CategorySeries]
|
||||
let labels: [String]
|
||||
let goal: GoalSummary?
|
||||
var currencyCode: String = "EUR"
|
||||
|
||||
private var pointsCount: Int {
|
||||
series.first?.points.count ?? 0
|
||||
@@ -742,11 +776,11 @@ struct CombinedCategoryChartView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Decimal(maxValue).shortCurrencyString)
|
||||
Text(Decimal(maxValue).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(Decimal(0).shortCurrencyString)
|
||||
Text(Decimal(0).shortCurrencyString(currencyCode: currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -889,7 +923,8 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
|
||||
categoryEvolution: [],
|
||||
categoryTotals: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
currencyCode: "EUR"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -911,7 +946,8 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
|
||||
categoryEvolution: [],
|
||||
categoryTotals: [],
|
||||
goals: []
|
||||
goals: [],
|
||||
currencyCode: "EUR"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -961,11 +997,12 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
],
|
||||
goals: [
|
||||
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
|
||||
]
|
||||
],
|
||||
currencyCode: "EUR"
|
||||
)
|
||||
}
|
||||
extension Decimal {
|
||||
var compactCurrencyString: String {
|
||||
func compactCurrencyString(currencyCode: String) -> String {
|
||||
let absValue = (self as NSDecimalNumber).doubleValue.magnitude
|
||||
let sign = (self as NSDecimalNumber).doubleValue < 0 ? -1.0 : 1.0
|
||||
|
||||
@@ -986,14 +1023,13 @@ extension Decimal {
|
||||
let value = (self as NSDecimalNumber).doubleValue / divisor
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currencyCode
|
||||
formatter.maximumFractionDigits = value < 10 && suffix != "" ? 1 : 0
|
||||
formatter.minimumFractionDigits = 0
|
||||
|
||||
// Use current locale currency symbol
|
||||
let currencySymbol = formatter.currencySymbol ?? "€"
|
||||
let formattedNumber: String
|
||||
do {
|
||||
// Use a plain decimal formatter to better control digits
|
||||
let nf = NumberFormatter()
|
||||
nf.numberStyle = .decimal
|
||||
nf.maximumFractionDigits = formatter.maximumFractionDigits
|
||||
@@ -1004,8 +1040,16 @@ extension Decimal {
|
||||
return "\(currencySymbol)\(formattedNumber)\(suffix)"
|
||||
}
|
||||
|
||||
func shortCurrencyString(currencyCode: String) -> String {
|
||||
return compactCurrencyString(currencyCode: currencyCode)
|
||||
}
|
||||
|
||||
var compactCurrencyString: String {
|
||||
return compactCurrencyString(currencyCode: "EUR")
|
||||
}
|
||||
|
||||
var shortCurrencyString: String {
|
||||
return compactCurrencyString
|
||||
return shortCurrencyString(currencyCode: "EUR")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/bin/zsh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PROJECT_PATH="${PROJECT_PATH:-$ROOT_DIR/PortfolioJournal.xcodeproj}"
|
||||
SCHEME="${SCHEME:-PortfolioJournal}"
|
||||
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||
ARCHIVE_DIR="${ARCHIVE_DIR:-$ROOT_DIR/build/appstore}"
|
||||
ARCHIVE_PATH="${ARCHIVE_PATH:-$ARCHIVE_DIR/${SCHEME}.xcarchive}"
|
||||
EXPORT_PATH="${EXPORT_PATH:-$ARCHIVE_DIR/export}"
|
||||
EXPORT_OPTIONS_PLIST="${EXPORT_OPTIONS_PLIST:-$ARCHIVE_DIR/ExportOptions.plist}"
|
||||
# Credenciales desde pass (GPG store). Se puede sobreescribir con variables de entorno.
|
||||
_pass_or_env() {
|
||||
local env_val="$1" pass_key="$2"
|
||||
if [[ -n "$env_val" ]]; then
|
||||
echo "$env_val"
|
||||
elif command -v pass >/dev/null 2>&1; then
|
||||
pass show "$pass_key" 2>/dev/null || fail "No se encontro la credencial en pass: $pass_key"
|
||||
else
|
||||
fail "pass no esta disponible y la variable de entorno no esta definida para: $pass_key"
|
||||
fi
|
||||
}
|
||||
|
||||
KEY_ID="$(_pass_or_env "${APPSTORE_KEY_ID:-}" appstore/api-key-id)"
|
||||
ISSUER_ID="$(_pass_or_env "${APPSTORE_ISSUER_ID:-}" appstore/issuer-id)"
|
||||
TEAM_ID="$(_pass_or_env "${DEVELOPMENT_TEAM_ID:-}" appstore/team-id)"
|
||||
P8_PATH="${APPSTORE_P8_PATH:-}"
|
||||
P8_DIR=""
|
||||
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
find_p8_file() {
|
||||
# 1. Variable de entorno explícita
|
||||
if [[ -n "$P8_PATH" ]]; then
|
||||
[[ -f "$P8_PATH" ]] || fail "No existe el fichero APPSTORE_P8_PATH=$P8_PATH"
|
||||
echo "$P8_PATH"
|
||||
return
|
||||
fi
|
||||
|
||||
# 2. Extraer de pass y escribir en fichero temporal
|
||||
if command -v pass >/dev/null 2>&1 && pass show appstore/api-key-p8 &>/dev/null; then
|
||||
local tmp_p8
|
||||
tmp_p8="$(mktemp /tmp/AuthKey_XXXXXX.p8)"
|
||||
pass show appstore/api-key-p8 > "$tmp_p8"
|
||||
chmod 600 "$tmp_p8"
|
||||
# Registrar para limpieza al salir
|
||||
trap "rm -f '$tmp_p8'" EXIT
|
||||
echo "$tmp_p8"
|
||||
return
|
||||
fi
|
||||
|
||||
# 3. Fichero por defecto en HOME
|
||||
local default_path="$HOME/AuthKey_${KEY_ID}.p8"
|
||||
if [[ -f "$default_path" ]]; then
|
||||
echo "$default_path"
|
||||
return
|
||||
fi
|
||||
|
||||
# 4. Único .p8 en HOME
|
||||
local matches=("${HOME}"/*.p8(N))
|
||||
if (( ${#matches[@]} == 1 )); then
|
||||
echo "${matches[1]}"
|
||||
return
|
||||
fi
|
||||
|
||||
if (( ${#matches[@]} > 1 )); then
|
||||
fail "Hay varios ficheros .p8 en $HOME. Define APPSTORE_P8_PATH o inserta la clave en pass: appstore/api-key-p8"
|
||||
fi
|
||||
|
||||
fail "No se encontro la clave .p8. Insértala con: cat AuthKey.p8 | pass insert -f -e appstore/api-key-p8"
|
||||
}
|
||||
|
||||
create_export_options() {
|
||||
mkdir -p "$ARCHIVE_DIR"
|
||||
cat > "$EXPORT_OPTIONS_PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>${TEAM_ID}</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
}
|
||||
|
||||
archive_app() {
|
||||
echo "==> Archivando ${SCHEME}"
|
||||
xcodebuild \
|
||||
-project "$PROJECT_PATH" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-destination "generic/platform=iOS" \
|
||||
clean archive
|
||||
}
|
||||
|
||||
export_ipa() {
|
||||
echo "==> Exportando IPA"
|
||||
xcodebuild \
|
||||
-exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportPath "$EXPORT_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$P8_PATH" \
|
||||
-authenticationKeyID "$KEY_ID" \
|
||||
-authenticationKeyIssuerID "$ISSUER_ID"
|
||||
}
|
||||
|
||||
upload_ipa() {
|
||||
local ipa
|
||||
ipa="$(find "$EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print -quit)"
|
||||
[[ -n "$ipa" ]] || fail "No se encontro ningun .ipa en $EXPORT_PATH"
|
||||
|
||||
echo "==> Subiendo $(basename "$ipa") a App Store Connect"
|
||||
API_PRIVATE_KEYS_DIR="$P8_DIR" \
|
||||
xcrun altool \
|
||||
--upload-app \
|
||||
--type ios \
|
||||
--file "$ipa" \
|
||||
--apiKey "$KEY_ID" \
|
||||
--apiIssuer "$ISSUER_ID"
|
||||
}
|
||||
|
||||
main() {
|
||||
command -v xcodebuild >/dev/null 2>&1 || fail "xcodebuild no esta disponible"
|
||||
command -v xcrun >/dev/null 2>&1 || fail "xcrun no esta disponible"
|
||||
|
||||
P8_PATH="$(find_p8_file)"
|
||||
P8_DIR="$(dirname "$P8_PATH")"
|
||||
|
||||
echo "Usando proyecto: $PROJECT_PATH"
|
||||
echo "Usando scheme: $SCHEME"
|
||||
echo "Usando clave API: $KEY_ID"
|
||||
echo "Usando fichero .p8: $P8_PATH"
|
||||
|
||||
rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH"
|
||||
create_export_options
|
||||
archive_app
|
||||
export_ipa
|
||||
upload_ipa
|
||||
|
||||
echo "==> Proceso completado"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
#!/bin/bash
|
||||
# Portfolio Journal Test Runner
|
||||
# Usage: ./Scripts/run_tests.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --unit Run unit tests only (default)
|
||||
# --ui Run UI tests only
|
||||
# --all Run all tests
|
||||
# --coverage Generate code coverage report
|
||||
# --device Specify simulator device (default: iPhone 17)
|
||||
# --help Show this help message
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
PROJECT_PATH="PortfolioJournal.xcodeproj"
|
||||
SCHEME="PortfolioJournal"
|
||||
DEFAULT_DEVICE="iPhone 17"
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/PortfolioJournal-Tests"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Parse arguments
|
||||
RUN_UNIT=true
|
||||
RUN_UI=false
|
||||
COVERAGE=false
|
||||
DEVICE="$DEFAULT_DEVICE"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--unit)
|
||||
RUN_UNIT=true
|
||||
RUN_UI=false
|
||||
shift
|
||||
;;
|
||||
--ui)
|
||||
RUN_UNIT=false
|
||||
RUN_UI=true
|
||||
shift
|
||||
;;
|
||||
--all)
|
||||
RUN_UNIT=true
|
||||
RUN_UI=true
|
||||
shift
|
||||
;;
|
||||
--coverage)
|
||||
COVERAGE=true
|
||||
shift
|
||||
;;
|
||||
--device)
|
||||
DEVICE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Portfolio Journal Test Runner"
|
||||
echo ""
|
||||
echo "Usage: ./Scripts/run_tests.sh [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --unit Run unit tests only (default)"
|
||||
echo " --ui Run UI tests only"
|
||||
echo " --all Run all tests"
|
||||
echo " --coverage Generate code coverage report"
|
||||
echo " --device Specify simulator device (default: iPhone 17)"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Print banner
|
||||
echo -e "${BLUE}"
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Portfolio Journal Test Runner ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# Check if project exists
|
||||
if [ ! -d "$PROJECT_PATH" ]; then
|
||||
echo -e "${RED}Error: Project not found at $PROJECT_PATH${NC}"
|
||||
echo "Make sure you're running this script from the project root directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build test arguments
|
||||
BUILD_ARGS=(
|
||||
-project "$PROJECT_PATH"
|
||||
-scheme "$SCHEME"
|
||||
-destination "platform=iOS Simulator,name=$DEVICE"
|
||||
-derivedDataPath "$DERIVED_DATA_PATH"
|
||||
)
|
||||
|
||||
if [ "$COVERAGE" = true ]; then
|
||||
BUILD_ARGS+=(-enableCodeCoverage YES)
|
||||
fi
|
||||
|
||||
# Function to run tests
|
||||
run_tests() {
|
||||
local test_type=$1
|
||||
local test_target=$2
|
||||
|
||||
echo -e "${YELLOW}Running $test_type tests...${NC}"
|
||||
echo "Device: $DEVICE"
|
||||
echo ""
|
||||
|
||||
if xcodebuild test "${BUILD_ARGS[@]}" -only-testing:"$test_target" 2>&1 | xcpretty --color; then
|
||||
echo -e "${GREEN}✅ $test_type tests passed!${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ $test_type tests failed!${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run all tests without filtering
|
||||
run_all_tests() {
|
||||
echo -e "${YELLOW}Running all tests...${NC}"
|
||||
echo "Device: $DEVICE"
|
||||
echo ""
|
||||
|
||||
if xcodebuild test "${BUILD_ARGS[@]}" 2>&1 | xcpretty --color; then
|
||||
echo -e "${GREEN}✅ All tests passed!${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ Some tests failed!${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if xcpretty is available
|
||||
if ! command -v xcpretty &> /dev/null; then
|
||||
echo -e "${YELLOW}Warning: xcpretty not found. Install with: gem install xcpretty${NC}"
|
||||
echo "Running tests without pretty output..."
|
||||
echo ""
|
||||
|
||||
# Run without xcpretty
|
||||
run_tests_raw() {
|
||||
if [ "$RUN_UNIT" = true ] && [ "$RUN_UI" = true ]; then
|
||||
xcodebuild test "${BUILD_ARGS[@]}"
|
||||
elif [ "$RUN_UNIT" = true ]; then
|
||||
xcodebuild test "${BUILD_ARGS[@]}" -only-testing:PortfolioJournalTests
|
||||
elif [ "$RUN_UI" = true ]; then
|
||||
xcodebuild test "${BUILD_ARGS[@]}" -only-testing:PortfolioJournalUITests
|
||||
fi
|
||||
}
|
||||
|
||||
if run_tests_raw; then
|
||||
echo -e "${GREEN}✅ Tests passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ Tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run tests
|
||||
EXIT_CODE=0
|
||||
|
||||
if [ "$RUN_UNIT" = true ] && [ "$RUN_UI" = true ]; then
|
||||
run_all_tests || EXIT_CODE=1
|
||||
elif [ "$RUN_UNIT" = true ]; then
|
||||
run_tests "Unit" "PortfolioJournalTests" || EXIT_CODE=1
|
||||
elif [ "$RUN_UI" = true ]; then
|
||||
run_tests "UI" "PortfolioJournalUITests" || EXIT_CODE=1
|
||||
fi
|
||||
|
||||
# Generate coverage report if requested
|
||||
if [ "$COVERAGE" = true ] && [ $EXIT_CODE -eq 0 ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Generating code coverage report...${NC}"
|
||||
|
||||
COVERAGE_PATH="$DERIVED_DATA_PATH/Build/ProfileData"
|
||||
|
||||
if [ -d "$COVERAGE_PATH" ]; then
|
||||
# Find the profdata file
|
||||
PROFDATA=$(find "$COVERAGE_PATH" -name "*.profdata" | head -1)
|
||||
|
||||
if [ -n "$PROFDATA" ]; then
|
||||
echo "Coverage data found at: $PROFDATA"
|
||||
echo ""
|
||||
echo "To view detailed coverage:"
|
||||
echo " xcrun llvm-cov report \\"
|
||||
echo " \"$DERIVED_DATA_PATH/Build/Products/Debug-iphonesimulator/PortfolioJournal.app/PortfolioJournal\" \\"
|
||||
echo " -instr-profile=\"$PROFDATA\""
|
||||
else
|
||||
echo -e "${YELLOW}No coverage data found. Make sure tests ran successfully.${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Coverage directory not found.${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} All tests completed successfully! 🎉 ${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════${NC}"
|
||||
else
|
||||
echo -e "${RED}════════════════════════════════════════════${NC}"
|
||||
echo -e "${RED} Some tests failed. Please review above. ${NC}"
|
||||
echo -e "${RED}════════════════════════════════════════════${NC}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Script to add test targets to the Xcode project
|
||||
# Usage: ruby Scripts/setup_tests.rb
|
||||
#
|
||||
# Prerequisites:
|
||||
# gem install xcodeproj
|
||||
|
||||
require 'xcodeproj'
|
||||
|
||||
PROJECT_PATH = 'PortfolioJournal.xcodeproj'
|
||||
UNIT_TEST_TARGET_NAME = 'PortfolioJournalTests'
|
||||
UNIT_TEST_BUNDLE_ID = 'com.alexandrevazquez.PortfolioJournalTests'
|
||||
|
||||
def main
|
||||
puts "Opening project at #{PROJECT_PATH}..."
|
||||
project = Xcodeproj::Project.open(PROJECT_PATH)
|
||||
|
||||
# Check if test target already exists
|
||||
if project.targets.any? { |t| t.name == UNIT_TEST_TARGET_NAME }
|
||||
puts "Test target '#{UNIT_TEST_TARGET_NAME}' already exists. Skipping creation."
|
||||
return
|
||||
end
|
||||
|
||||
puts "Creating unit test target..."
|
||||
|
||||
# Find the main app target
|
||||
main_target = project.targets.find { |t| t.name == 'PortfolioJournal' }
|
||||
unless main_target
|
||||
puts "Error: Could not find main app target 'PortfolioJournal'"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Create the unit test target
|
||||
test_target = project.new_target(
|
||||
:unit_test_bundle,
|
||||
UNIT_TEST_TARGET_NAME,
|
||||
:ios,
|
||||
'17.6',
|
||||
main_target
|
||||
)
|
||||
|
||||
# Configure build settings
|
||||
test_target.build_configurations.each do |config|
|
||||
config.build_settings['BUNDLE_LOADER'] = '$(TEST_HOST)'
|
||||
config.build_settings['TEST_HOST'] = '$(BUILT_PRODUCTS_DIR)/PortfolioJournal.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PortfolioJournal'
|
||||
config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = UNIT_TEST_BUNDLE_ID
|
||||
config.build_settings['SWIFT_VERSION'] = '5.0'
|
||||
config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
|
||||
config.build_settings['DEVELOPMENT_TEAM'] = '2825Q76T7H'
|
||||
config.build_settings['INFOPLIST_FILE'] = ''
|
||||
config.build_settings['GENERATE_INFOPLIST_FILE'] = 'YES'
|
||||
config.build_settings['SWIFT_EMIT_LOC_STRINGS'] = 'NO'
|
||||
config.build_settings['ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES'] = '$(inherited)'
|
||||
config.build_settings['LD_RUNPATH_SEARCH_PATHS'] = [
|
||||
'$(inherited)',
|
||||
'@executable_path/Frameworks',
|
||||
'@loader_path/Frameworks'
|
||||
]
|
||||
end
|
||||
|
||||
# Add test target dependency on main target
|
||||
test_target.add_dependency(main_target)
|
||||
|
||||
# Create file references for test files
|
||||
tests_group = project.main_group.find_subpath('PortfolioJournalTests', true)
|
||||
tests_group.set_source_tree('<group>')
|
||||
tests_group.set_path('PortfolioJournalTests')
|
||||
|
||||
# Find all test Swift files
|
||||
test_files_path = File.join(File.dirname(PROJECT_PATH), 'PortfolioJournalTests')
|
||||
if Dir.exist?(test_files_path)
|
||||
Dir.glob("#{test_files_path}/**/*.swift").each do |file_path|
|
||||
relative_path = file_path.sub("#{test_files_path}/", '')
|
||||
|
||||
# Create subgroups as needed
|
||||
path_components = relative_path.split('/')
|
||||
current_group = tests_group
|
||||
|
||||
path_components[0...-1].each do |component|
|
||||
subgroup = current_group.find_subpath(component, true)
|
||||
subgroup.set_source_tree('<group>')
|
||||
current_group = subgroup
|
||||
end
|
||||
|
||||
# Add file reference
|
||||
file_name = path_components.last
|
||||
file_ref = current_group.new_file(file_path)
|
||||
test_target.source_build_phase.add_file_reference(file_ref)
|
||||
end
|
||||
end
|
||||
|
||||
puts "Saving project..."
|
||||
project.save
|
||||
|
||||
puts "✅ Test target '#{UNIT_TEST_TARGET_NAME}' created successfully!"
|
||||
puts ""
|
||||
puts "Next steps:"
|
||||
puts "1. Open Xcode and build the project"
|
||||
puts "2. Run tests with: xcodebuild test -scheme PortfolioJournal -destination 'platform=iOS Simulator,name=iPhone 17'"
|
||||
puts " Or use: make test"
|
||||
end
|
||||
|
||||
begin
|
||||
main
|
||||
rescue LoadError => e
|
||||
puts "Error: xcodeproj gem not found."
|
||||
puts "Install it with: gem install xcodeproj"
|
||||
puts ""
|
||||
puts "Alternatively, add the test target manually in Xcode:"
|
||||
puts "1. File > New > Target > iOS Unit Testing Bundle"
|
||||
puts "2. Name it 'PortfolioJournalTests'"
|
||||
puts "3. Add the test files from PortfolioJournalTests folder"
|
||||
exit 1
|
||||
rescue => e
|
||||
puts "Error: #{e.message}"
|
||||
puts e.backtrace.first(5).join("\n")
|
||||
exit 1
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
app_identifier("com.alexandrevazquez.PortfolioJournal") # The bundle identifier of your app
|
||||
apple_id("alexandre.vazquez@gmail.com") # Your Apple Developer Portal username
|
||||
|
||||
itc_team_id("128443966") # App Store Connect Team ID
|
||||
team_id("2825Q76T7H") # Developer Portal Team ID
|
||||
|
||||
# For more information about the Appfile, see:
|
||||
# https://docs.fastlane.tools/advanced/#appfile
|
||||
@@ -0,0 +1 @@
|
||||
app_version "1.3.1"
|
||||
@@ -0,0 +1,73 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
EXPORT_OPTIONS = {
|
||||
method: "app-store",
|
||||
signingStyle: "manual",
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
provisioningProfiles: {
|
||||
"com.alexandrevazquez.PortfolioJournal" => "porfoliojournal",
|
||||
"com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget" => "Portfolio Journalwidget"
|
||||
}
|
||||
}
|
||||
|
||||
def api_key
|
||||
app_store_connect_api_key(
|
||||
key_id: sh("pass show appstore/api-key-id", log: false).strip,
|
||||
issuer_id: sh("pass show appstore/issuer-id", log: false).strip,
|
||||
key_content: sh("pass show appstore/api-key-p8", log: false).strip,
|
||||
is_key_content_base64: false
|
||||
)
|
||||
end
|
||||
|
||||
desc "Upload metadata and release notes only (binary already uploaded)"
|
||||
lane :metadata do
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
metadata_path: "fastlane/metadata",
|
||||
skip_screenshots: true,
|
||||
skip_binary_upload: true,
|
||||
force: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Push a new beta build to TestFlight"
|
||||
lane :beta do
|
||||
build_app(
|
||||
scheme: "PortfolioJournal",
|
||||
export_options: EXPORT_OPTIONS
|
||||
)
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Submit already-uploaded build for App Store review (binary already in TestFlight)"
|
||||
lane :submit do
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
metadata_path: "fastlane/metadata",
|
||||
skip_screenshots: true,
|
||||
skip_binary_upload: true,
|
||||
submit_for_review: true,
|
||||
automatic_release: false,
|
||||
run_precheck_before_submit: false,
|
||||
force: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Build, export and push a new release to the App Store"
|
||||
lane :release do
|
||||
build_app(
|
||||
scheme: "PortfolioJournal",
|
||||
export_options: EXPORT_OPTIONS
|
||||
)
|
||||
upload_to_app_store(
|
||||
api_key: api_key,
|
||||
metadata_path: "fastlane/metadata",
|
||||
skip_screenshots: true,
|
||||
force: true
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,995 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>deliver - Portfolio Journal: Tracker
|
||||
</title>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<style>
|
||||
.app-name {
|
||||
font-size: 42px;
|
||||
font-family: 'Helvetica Neue', HelveticaNeue, Helvetica Neue;
|
||||
font-weight: 300;
|
||||
margin-top: 22px;
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 26px;
|
||||
font-family: 'Helvetica Neue', HelveticaNeue, Helvetica Neue;
|
||||
font-weight: 300;
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.app-urls {
|
||||
margin-left: 25px;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', HelveticaNeue;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.app-url-descr {
|
||||
height:22px;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-url {
|
||||
color: #0056ba;
|
||||
font-weight: 300;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-keyword {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.cat-headline {
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.app-description {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.app-description-text {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-changelog {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-top: 22px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-screenshots {
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.app-keyword-list {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-changelog-list {
|
||||
list-style-type: square;
|
||||
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.app-screenshot-row {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-screenshot {
|
||||
width: calc(20% - 30px);
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#app-screenshots .cat-headline {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.app-icons {
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-icons img {
|
||||
width: 150px;
|
||||
}
|
||||
.app-icons .app-icon {
|
||||
float: left;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.app-minor-information {
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.app-minor-information-key {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-icons">
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
de-DE: Portfolio Journal: Tracker
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Aktien, ETF & Vermögen tracken
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html">https://portfoliojournal.app/support.html</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app">https://portfoliojournal.app</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>investition</li>
|
||||
|
||||
<li>depot</li>
|
||||
|
||||
<li>dividende</li>
|
||||
|
||||
<li>finanzen</li>
|
||||
|
||||
<li>fonds</li>
|
||||
|
||||
<li>krypto</li>
|
||||
|
||||
<li>börse</li>
|
||||
|
||||
<li>sparplan</li>
|
||||
|
||||
<li>rendite</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>index</li>
|
||||
|
||||
<li>ziel</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journal ist der Investitions-Tracker für Langfristanleger, die Wert auf Einfachheit und Datenschutz legen. Kein Broker-Login. Kein Server. Deine Daten bleiben auf deinem Gerät — oder werden optional privat über iCloud synchronisiert.<br /><br />Öffne die App einmal im Monat, trage deinen Portfoliowert ein und lass Portfolio Journal den Rest erledigen.<br /><br />---<br /><br />DEIN GESAMTES PORTFOLIO TRACKEN<br />• Aktien, ETFs, Anleihen, Krypto, Immobilien — jede Anlageklasse<br />• Mehrere Konten: Depot, Altersvorsorge, Tagesgeld und mehr<br />• Monatlicher Check-in-Ansatz: kein tägliches Rauschen, nur langfristige Perspektive<br />• Vermögensentwicklung über Monate und Jahre<br /><br />DEIN VERMÖGEN VISUALISIEREN<br />• Entwicklungschart: Sieh wie dein Portfolio wächst<br />• Allokationschart: Wisse genau, wo dein Geld steckt<br />• Drawdown-Analyse: Verstehe deine größten Rückgänge und Erholungen<br />• Ruhemodus: Eine ablenkungsfreie Ansicht für deinen Seelenfrieden<br /><br />ZIELE SETZEN UND VERFOLGEN<br />• Erstelle Finanzziele mit Zielbetrag und Frist<br />• Verfolge deinen Fortschritt mit visuellen Indikatoren<br />• Bleib motiviert mit einem klaren Blick auf deinen Weg<br /><br />EIN INVESTITIONSTAGEBUCH FÜHREN<br />• Halte Gedanken, Entscheidungen und Lektionen fest<br />• Baue einen persönlichen Bericht über deine Anlagestrategie auf<br />• Reflektiere vergangene Entscheidungen, um zukünftige zu verbessern<br /><br />DATENSCHUTZ BY DESIGN<br />• Kein Konto, kein Login, keine Registrierung erforderlich.<br />• Keine Analyse, kein Tracking, kein Datenverkauf — niemals.<br />• iCloud-Sync ist optional: Du bestimmst, und alles ist Ende-zu-Ende-verschlüsselt.<br />• Funktioniert 100% offline.<br /><br />FÜR PASSIVE ANLEGER ENTWICKELT<br />Portfolio Journal wurde für Boglehead-Anhänger, ETF- und Indexfonds-Investoren sowie alle entwickelt, die eine Buy-and-Hold-Strategie verfolgen. Es ist kein Trading-Tool — es ist ein langfristiger Begleiter.<br /><br />Ob du ein Drei-Fonds-Portfolio verwaltest, auf finanzielle Unabhängigkeit (FIRE) hinarbeitest oder einfach dein Nettovermögen im Blick behältst — Portfolio Journal bietet dir Klarheit ohne Komplexität.<br /><br />DEINE DATEN EXPORTIEREN<br />• CSV-Export: Nimm deine Daten jederzeit überallhin mit.<br />• Volle Kontrolle über deine Finanzgeschichte.<br /><br />---<br /><br />FÜR WEN IST PORTFOLIO JOURNAL?<br />- Langfrist- und Passivanleger, die keine täglichen Updates benötigen<br />- ETF- und Indexfonds-Enthusiasten<br />- Boglehead- und FIRE-Community-Mitglieder<br />- Alle, die ihr Nettovermögen oder ihren Vermögensaufbau verfolgen möchten<br />- Datenschutzbewusste Nutzer, die ihre Finanzdaten nicht auf fremden Servern sehen wollen<br /><br />---<br /><br />Lade Portfolio Journal jetzt herunter und beginne, dir einen klaren, privaten und langfristigen Überblick über deine finanzielle Zukunft aufzubauen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Verbesserungen bei der iCloud-Synchronisierung:<br />• Daten werden jetzt sofort synchronisiert, wenn du zwischen Apple-Geräten wechselst<br />• Neue Geräteerkennung: Die App erkennt vorhandene iCloud-Daten vor dem Onboarding, sodass du auf einem neuen Gerät nie von vorne anfangen musst<br />• Behoben: Änderungen von einem anderen Gerät wurden in einigen Fällen nicht korrekt übernommen<br />• Allgemeine Stabilitätsverbesserungen und Fehlerbehebungen
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Investitionen sicher tracken. Elegante Charts, monatliche Check-ins und optionale iCloud-Sync — kein Broker-Login, keine Datenweitergabe.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
en-US: Portfolio Journal: Tracker
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Track Stocks, ETF & Net Worth
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html
|
||||
">https://portfoliojournal.app/support.html
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app
|
||||
">https://portfoliojournal.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>investment</li>
|
||||
|
||||
<li>wealth</li>
|
||||
|
||||
<li>dividend</li>
|
||||
|
||||
<li>finance</li>
|
||||
|
||||
<li>returns</li>
|
||||
|
||||
<li>fund</li>
|
||||
|
||||
<li>crypto</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>watchlist</li>
|
||||
|
||||
<li>index</li>
|
||||
|
||||
<li>savings</li>
|
||||
|
||||
<li>goal</li>
|
||||
|
||||
<li>log</li>
|
||||
|
||||
<li>monitor</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journal is the investment tracker built for long-term investors who value simplicity and privacy. No brokerage login. No server. Your data stays on your device — or syncs privately via iCloud if you choose.<br /><br />Just open the app once a month, enter your portfolio value, and let Portfolio Journal do the rest.<br /><br />---<br /><br />TRACK YOUR ENTIRE PORTFOLIO<br />• Stocks, ETFs, bonds, crypto, real estate — any asset class<br />• Multiple accounts: brokerage, retirement, savings, and more<br />• Monthly check-in approach: no daily noise, just long-term perspective<br />• Net worth evolution over months and years<br /><br />VISUALIZE YOUR WEALTH<br />• Evolution chart: see your portfolio grow over time<br />• Allocation chart: know exactly where your money is<br />• Drawdown analysis: understand your worst drops and recoveries<br />• Calm Mode: a distraction-free view for peace of mind<br /><br />SET AND TRACK GOALS<br />• Create financial goals with target amounts and deadlines<br />• Track progress with visual indicators<br />• Stay motivated with a clear picture of your journey<br /><br />KEEP AN INVESTMENT JOURNAL<br />• Log thoughts, decisions, and lessons learned<br />• Build a personal record of your investment mindset over time<br />• Reflect on past decisions to improve future ones<br /><br />PRIVACY BY DESIGN<br />• No account required. No login. No sign-up.<br />• No analytics, no tracking, no data selling — ever.<br />• iCloud sync is optional: you control it, and it's encrypted end-to-end.<br />• Everything works 100% offline.<br /><br />BUILT FOR PASSIVE INVESTORS<br />Portfolio Journal is designed for Bogleheads, ETF investors, index fund fans, and anyone following a buy-and-hold strategy. It's not a trading tool — it's a long-term companion.<br /><br />Whether you're tracking a three-fund portfolio, building toward FIRE, or simply monitoring your net worth over time, Portfolio Journal gives you clarity without complexity.<br /><br />EXPORT YOUR DATA<br />• CSV export: take your data anywhere, anytime.<br />• Full ownership of your financial history.<br /><br />---<br /><br />WHO IS PORTFOLIO JOURNAL FOR?<br />- Long-term and passive investors who don't need daily updates<br />- ETF and index fund enthusiasts<br />- Bogleheads and FIRE community members<br />- Anyone tracking their net worth or wealth-building journey<br />- Privacy-conscious users who don't want their financial data on someone else's servers<br /><br />---<br /><br />Download Portfolio Journal today and start building a clear, private, long-term view of your financial future.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
iCloud Sync improvements:<br />• Data now syncs instantly when switching between Apple devices<br />• New device detection: the app now detects existing iCloud data before onboarding, so you never start from scratch on a new phone<br />• Fixed cases where updates made on another device weren't reflected correctly<br />• General stability improvements and bug fixes
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Track investments privately. Beautiful charts, monthly check-ins, and optional iCloud sync — no brokerage login, no data sharing.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
es-ES: Portfolio Journal: Tracker
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Acciones, ETF y tu patrimonio
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html">https://portfoliojournal.app/support.html</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app">https://portfoliojournal.app</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>cartera</li>
|
||||
|
||||
<li>inversión</li>
|
||||
|
||||
<li>finanzas</li>
|
||||
|
||||
<li>dividendos</li>
|
||||
|
||||
<li>fondos</li>
|
||||
|
||||
<li>cripto</li>
|
||||
|
||||
<li>bolsa</li>
|
||||
|
||||
<li>riqueza</li>
|
||||
|
||||
<li>ahorro</li>
|
||||
|
||||
<li>índice</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>metas</li>
|
||||
|
||||
<li>objetivo</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journal es el diario de inversión para quienes invierten a largo plazo y valoran la privacidad. Sin acceso a brokers. Sin servidores. Tus datos permanecen en tu dispositivo — o se sincronizan de forma privada con iCloud si así lo decides.<br /><br />Abre la app una vez al mes, introduce el valor de tu cartera y deja que Portfolio Journal haga el resto.<br /><br />---<br /><br />REGISTRA TODA TU CARTERA<br />• Acciones, ETF, bonos, cripto, inmuebles — cualquier tipo de activo<br />• Múltiples cuentas: broker, pensiones, ahorros y más<br />• Enfoque de check-in mensual: sin ruido diario, solo perspectiva a largo plazo<br />• Evolución de tu patrimonio a lo largo de meses y años<br /><br />VISUALIZA TU RIQUEZA<br />• Gráfico de evolución: observa cómo crece tu cartera con el tiempo<br />• Gráfico de asignación: sabe exactamente dónde está tu dinero<br />• Análisis de drawdown: comprende tus peores caídas y recuperaciones<br />• Modo Calma: una vista sin distracciones para tu tranquilidad<br /><br />ESTABLECE Y ALCANZA METAS<br />• Crea objetivos financieros con importe y fecha límite<br />• Sigue tu progreso con indicadores visuales<br />• Mantén la motivación con una visión clara de tu camino<br /><br />LLEVA UN DIARIO DE INVERSIÓN<br />• Anota pensamientos, decisiones y lecciones aprendidas<br />• Construye un registro personal de tu mentalidad inversora<br />• Reflexiona sobre decisiones pasadas para mejorar las futuras<br /><br />PRIVACIDAD POR DISEÑO<br />• Sin cuenta, sin login, sin registro.<br />• Sin analíticas, sin rastreo, sin venta de datos — nunca.<br />• La sincronización con iCloud es opcional: tú la controlas, y está cifrada de extremo a extremo.<br />• Funciona al 100% sin conexión.<br /><br />DISEÑADO PARA INVERSORES PASIVOS<br />Portfolio Journal está pensado para seguidores de Boglehead, inversores en ETF e índices, y cualquiera que siga una estrategia de compra y mantenimiento. No es una herramienta de trading — es un compañero a largo plazo.<br /><br />Ya sea que gestiones una cartera de tres fondos, avances hacia la independencia financiera (FIRE) o simplemente controles tu patrimonio neto, Portfolio Journal te da claridad sin complejidad.<br /><br />EXPORTA TUS DATOS<br />• Exportación a CSV: lleva tus datos donde quieras, cuando quieras.<br />• Plena propiedad de tu historial financiero.<br /><br />---<br /><br />¿PARA QUIÉN ES PORTFOLIO JOURNAL?<br />- Inversores pasivos y a largo plazo que no necesitan actualizaciones diarias<br />- Aficionados a ETF y fondos indexados<br />- Comunidad Boglehead y FIRE<br />- Cualquiera que controle su patrimonio neto o su camino hacia la libertad financiera<br />- Usuarios que valoran su privacidad y no quieren sus datos financieros en servidores ajenos<br /><br />---<br /><br />Descarga Portfolio Journal hoy y empieza a construir una visión clara, privada y a largo plazo de tu futuro financiero.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Mejoras en la sincronización con iCloud:<br />• Los datos ahora se sincronizan al instante al cambiar entre dispositivos Apple<br />• Detección de dispositivo nuevo: la app detecta datos existentes en iCloud antes del proceso de bienvenida, para que nunca empieces desde cero en un teléfono nuevo<br />• Corregidos casos en los que los cambios realizados en otro dispositivo no se reflejaban correctamente<br />• Mejoras generales de estabilidad y corrección de errores
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Registra tus inversiones con privacidad. Gráficos elegantes, check-ins mensuales y sincronización iCloud opcional — sin acceso a brokers.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
fr-FR: Portfolio Journal: Tracker
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Actions, ETF et patrimoine
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html">https://portfoliojournal.app/support.html</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app">https://portfoliojournal.app</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>investissement</li>
|
||||
|
||||
<li>dividendes</li>
|
||||
|
||||
<li>finances</li>
|
||||
|
||||
<li>bourse</li>
|
||||
|
||||
<li>fonds</li>
|
||||
|
||||
<li>crypto</li>
|
||||
|
||||
<li>épargne</li>
|
||||
|
||||
<li>richesse</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>objectif</li>
|
||||
|
||||
<li>rendement</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journal est l'application de suivi d'investissements conçue pour les investisseurs à long terme qui privilégient la simplicité et la confidentialité. Aucune connexion à votre courtier. Aucun serveur. Vos données restent sur votre appareil — ou se synchronisent en privé via iCloud si vous le souhaitez.<br /><br />Ouvrez l'application une fois par mois, saisissez la valeur de votre portefeuille et laissez Portfolio Journal faire le reste.<br /><br />---<br /><br />SUIVEZ L'INTÉGRALITÉ DE VOTRE PORTEFEUILLE<br />• Actions, ETF, obligations, crypto, immobilier — toutes les classes d'actifs<br />• Plusieurs comptes : courtier, retraite, épargne et plus encore<br />• Approche par bilan mensuel : sans bruit quotidien, juste une perspective à long terme<br />• Évolution de votre patrimoine sur des mois et des années<br /><br />VISUALISEZ VOTRE RICHESSE<br />• Graphique d'évolution : observez la croissance de votre portefeuille dans le temps<br />• Graphique d'allocation : sachez exactement où se trouve votre argent<br />• Analyse du drawdown : comprenez vos pires baisses et vos récupérations<br />• Mode Calme : une vue sans distraction pour votre tranquillité d'esprit<br /><br />FIXEZ ET SUIVEZ VOS OBJECTIFS<br />• Créez des objectifs financiers avec des montants cibles et des échéances<br />• Suivez votre progression avec des indicateurs visuels<br />• Restez motivé grâce à une vision claire de votre parcours<br /><br />TENEZ UN JOURNAL D'INVESTISSEMENT<br />• Notez vos réflexions, décisions et leçons apprises<br />• Construisez un registre personnel de votre état d'esprit d'investisseur<br />• Réfléchissez aux décisions passées pour améliorer les futures<br /><br />CONFIDENTIALITÉ PAR CONCEPTION<br />• Aucun compte requis. Aucune connexion. Aucune inscription.<br />• Aucune analyse, aucun suivi, aucune vente de données — jamais.<br />• La synchronisation iCloud est facultative : vous la contrôlez, et elle est chiffrée de bout en bout.<br />• Fonctionne à 100% hors ligne.<br /><br />CONÇU POUR LES INVESTISSEURS PASSIFS<br />Portfolio Journal est pensé pour les adeptes de Boglehead, les investisseurs en ETF et fonds indiciels, et tous ceux qui suivent une stratégie d'achat et de conservation. Ce n'est pas un outil de trading — c'est un compagnon à long terme.<br /><br />Que vous gériez un portefeuille à trois fonds, que vous visiez l'indépendance financière (FIRE) ou que vous suiviez simplement l'évolution de votre patrimoine net, Portfolio Journal vous apporte de la clarté sans complexité.<br /><br />EXPORTEZ VOS DONNÉES<br />• Export CSV : emportez vos données partout, à tout moment.<br />• Propriété totale de votre historique financier.<br /><br />---<br /><br />POUR QUI EST PORTFOLIO JOURNAL ?<br />- Investisseurs passifs et à long terme qui n'ont pas besoin de mises à jour quotidiennes<br />- Fans d'ETF et de fonds indiciels<br />- Membres de la communauté Boglehead et FIRE<br />- Toute personne qui suit son patrimoine net ou son parcours de création de richesse<br />- Utilisateurs soucieux de leur vie privée qui ne veulent pas de leurs données financières sur les serveurs d'autrui<br /><br />---<br /><br />Téléchargez Portfolio Journal dès aujourd'hui et commencez à construire une vision claire, privée et à long terme de votre avenir financier.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Améliorations de la synchronisation iCloud :<br />• Les données se synchronisent désormais instantanément lors du passage entre appareils Apple<br />• Détection de nouvel appareil : l'app détecte les données iCloud existantes avant l'intégration, pour ne jamais repartir de zéro sur un nouvel iPhone<br />• Correction des cas où les modifications effectuées sur un autre appareil n'étaient pas correctement reflétées<br />• Améliorations générales de la stabilité et corrections de bugs
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Suivez vos investissements en toute confidentialité. Graphiques élégants, bilans mensuels et sync iCloud en option — sans login broker.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
it: Portfolio Journal: Tracker
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Azioni, ETF e patrimonio
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html">https://portfoliojournal.app/support.html</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app">https://portfoliojournal.app</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>investimento</li>
|
||||
|
||||
<li>dividendi</li>
|
||||
|
||||
<li>finanze</li>
|
||||
|
||||
<li>borsa</li>
|
||||
|
||||
<li>fondi</li>
|
||||
|
||||
<li>cripto</li>
|
||||
|
||||
<li>risparmio</li>
|
||||
|
||||
<li>rendimento</li>
|
||||
|
||||
<li>ricchezza</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>meta</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journal è il diario di investimento pensato per chi investe a lungo termine e tiene alla propria privacy. Nessun accesso al broker. Nessun server. I tuoi dati rimangono sul tuo dispositivo — o si sincronizzano privatamente via iCloud se lo desideri.<br /><br />Apri l'app una volta al mese, inserisci il valore del tuo portafoglio e lascia fare il resto a Portfolio Journal.<br /><br />---<br /><br />TIENI TRACCIA DI TUTTO IL TUO PORTAFOGLIO<br />• Azioni, ETF, obbligazioni, crypto, immobili — qualsiasi classe di asset<br />• Più conti: broker, previdenza, risparmio e altro<br />• Approccio con check-in mensile: nessun rumore quotidiano, solo prospettiva a lungo termine<br />• Evoluzione del patrimonio nel corso di mesi e anni<br /><br />VISUALIZZA LA TUA RICCHEZZA<br />• Grafico di evoluzione: osserva come cresce il tuo portafoglio nel tempo<br />• Grafico di allocazione: sappi esattamente dove si trova il tuo denaro<br />• Analisi del drawdown: comprendi i tuoi cali peggiori e le riprese<br />• Modalità Calma: una vista senza distrazioni per la tua tranquillità<br /><br />IMPOSTA E RAGGIUNGI I TUOI OBIETTIVI<br />• Crea obiettivi finanziari con importi target e scadenze<br />• Monitora i progressi con indicatori visivi<br />• Rimani motivato con una visione chiara del tuo percorso<br /><br />TIENI UN DIARIO DI INVESTIMENTO<br />• Annota pensieri, decisioni e lezioni apprese<br />• Costruisci un registro personale della tua mentalità di investitore<br />• Rifletti sulle decisioni passate per migliorare quelle future<br /><br />PRIVACY BY DESIGN<br />• Nessun account richiesto. Nessun login. Nessuna registrazione.<br />• Nessuna analisi, nessun tracciamento, nessuna vendita di dati — mai.<br />• La sincronizzazione iCloud è facoltativa: la controlli tu ed è crittografata end-to-end.<br />• Funziona al 100% offline.<br /><br />PROGETTATO PER INVESTITORI PASSIVI<br />Portfolio Journal è pensato per i seguaci di Boglehead, gli investitori in ETF e fondi indice, e chiunque segua una strategia buy-and-hold. Non è uno strumento di trading — è un compagno a lungo termine.<br /><br />Che tu gestisca un portafoglio a tre fondi, stia lavorando verso l'indipendenza finanziaria (FIRE) o voglia semplicemente monitorare il tuo patrimonio netto, Portfolio Journal ti offre chiarezza senza complessità.<br /><br />ESPORTA I TUOI DATI<br />• Esportazione CSV: porta i tuoi dati ovunque, in qualsiasi momento.<br />• Piena proprietà della tua storia finanziaria.<br /><br />---<br /><br />PER CHI È PORTFOLIO JOURNAL?<br />- Investitori passivi e a lungo termine che non hanno bisogno di aggiornamenti quotidiani<br />- Appassionati di ETF e fondi indice<br />- Membri della comunità Boglehead e FIRE<br />- Chi monitora il proprio patrimonio netto o il proprio percorso di creazione di ricchezza<br />- Utenti attenti alla privacy che non vogliono i loro dati finanziari sui server di terzi<br /><br />---<br /><br />Scarica Portfolio Journal oggi e inizia a costruire una visione chiara, privata e a lungo termine del tuo futuro finanziario.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Miglioramenti alla sincronizzazione iCloud:<br />• I dati ora si sincronizzano immediatamente quando passi da un dispositivo Apple all'altro<br />• Rilevamento nuovo dispositivo: l'app rileva i dati iCloud esistenti prima dell'onboarding, così non devi ricominciare da zero su un nuovo iPhone<br />• Risolti casi in cui le modifiche apportate su un altro dispositivo non venivano recepite correttamente<br />• Miglioramenti generali alla stabilità e correzioni di bug
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Traccia i tuoi investimenti in privato. Grafici eleganti, check-in mensili e sincronizzazione iCloud opzionale — senza login al broker.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
ja: Portfolio Journal: Tracker
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: 株式・ETF・資産を一元管理
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html">https://portfoliojournal.app/support.html</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app">https://portfoliojournal.app</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>投資</li>
|
||||
|
||||
<li>配当</li>
|
||||
|
||||
<li>ポートフォリオ</li>
|
||||
|
||||
<li>家計</li>
|
||||
|
||||
<li>仮想通貨</li>
|
||||
|
||||
<li>NISA</li>
|
||||
|
||||
<li>iDeCo</li>
|
||||
|
||||
<li>積立</li>
|
||||
|
||||
<li>高配当</li>
|
||||
|
||||
<li>インデックス</li>
|
||||
|
||||
<li>節税</li>
|
||||
|
||||
<li>米国株</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>損益</li>
|
||||
|
||||
<li>財産</li>
|
||||
|
||||
<li>株価</li>
|
||||
|
||||
<li>記録</li>
|
||||
|
||||
<li>管理</li>
|
||||
|
||||
<li>予算</li>
|
||||
|
||||
<li>資産形成</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journalは、長期投資家のためのプライバシー重視の資産管理アプリです。証券口座へのログイン不要。サーバーなし。あなたのデータはデバイス上に保管され、希望する場合のみiCloudでプライベートに同期されます。<br /><br />月に一度アプリを開き、ポートフォリオの価値を入力するだけ。あとはPortfolio Journalがすべて計算します。<br /><br />---<br /><br />資産全体を記録する<br />• 株式、ETF、債券、暗号資産、不動産など、あらゆる資産クラスに対応<br />• 複数口座に対応:証券口座、退職金口座、貯蓄口座など<br />• 月次チェックイン方式:日々の相場ノイズなし、長期的な視点を大切に<br />• 月・年単位での資産推移を把握<br /><br />資産を可視化する<br />• 推移グラフ:ポートフォリオの成長を時系列で確認<br />• アロケーションチャート:資金の配分をひと目で把握<br />• ドローダウン分析:最大下落と回復の過程を理解<br />• カームモード:集中を妨げない、落ち着いた表示画面<br /><br />目標を設定・追跡する<br />• 目標金額と期限を設定して財務目標を作成<br />• ビジュアルインジケーターで進捗を確認<br />• 自分の歩みを明確に把握し、モチベーションを維持<br /><br />投資ジャーナルをつける<br />• 考え、決断、学びを記録<br />• 投資家としてのマインドセットの記録を積み重ねる<br />• 過去の判断を振り返り、未来に活かす<br /><br />プライバシー・バイ・デザイン<br />• アカウント不要。ログイン不要。登録不要。<br />• 分析なし、トラッキングなし、データ販売なし — 一切なし。<br />• iCloud同期はオプション:自分でコントロールでき、エンドツーエンド暗号化に対応。<br />• 完全オフラインで動作。<br /><br />パッシブ投資家のために設計<br />Portfolio JournalはBoglehead、ETF・インデックスファンド投資家、そしてバイ・アンド・ホールド戦略を実践するすべての人のために開発されました。トレーディングツールではなく、長期的な投資の記録・管理ツールです。<br /><br />3ファンドポートフォリオを管理している方も、FIRE(経済的自立と早期退職)を目指している方も、単純に純資産の推移を追いたい方も — Portfolio Journalは複雑さなく明確な視点を提供します。<br /><br />データをエクスポートする<br />• CSVエクスポート:いつでもどこでもデータを持ち出せます。<br />• 自分の金融履歴の完全なオーナーシップ。<br /><br />---<br /><br />Portfolio Journalはこんな方に<br />- 毎日の更新が不要な長期・パッシブ投資家<br />- ETFおよびインデックスファンドの愛好家<br />- BogleheadコミュニティやFIREを目指す方<br />- 純資産や資産形成の過程を追いたい方<br />- 自分の金融データを他人のサーバーに置きたくない、プライバシー重視のユーザー<br /><br />---<br /><br />Portfolio Journalをダウンロードして、プライベートで明確な長期的金融ビジョンの構築を始めましょう。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
iCloud同期の改善:<br />• Appleデバイス間を切り替えたとき、データが即座に同期されるようになりました<br />• 新デバイス検出機能:オンボーディング前にiCloudの既存データを検出するようになり、新しいiPhoneでもゼロからやり直す必要がありません<br />• 別のデバイスでの変更が正しく反映されないケースを修正<br />• 全般的な安定性の向上とバグ修正
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
プライバシーを守りながら投資を記録。美しいグラフ、月次チェックイン、オプションのiCloud同期対応。証券会社へのログイン不要、データ共有なし。
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
pt-BR: Portfolio Journal: Tracker
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Ações, ETF e Patrimônio
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app/support.html">https://portfoliojournal.app/support.html</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://portfoliojournal.app">https://portfoliojournal.app</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>investimento</li>
|
||||
|
||||
<li>dividendos</li>
|
||||
|
||||
<li>finanças</li>
|
||||
|
||||
<li>bolsa</li>
|
||||
|
||||
<li>fundos</li>
|
||||
|
||||
<li>cripto</li>
|
||||
|
||||
<li>renda</li>
|
||||
|
||||
<li>poupança</li>
|
||||
|
||||
<li>índice</li>
|
||||
|
||||
<li>FIRE</li>
|
||||
|
||||
<li>metas</li>
|
||||
|
||||
<li>rendimento</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Portfolio Journal é o aplicativo de acompanhamento de investimentos feito para investidores de longo prazo que valorizam simplicidade e privacidade. Sem login em corretoras. Sem servidor. Seus dados ficam no seu dispositivo — ou sincronizam de forma privada pelo iCloud se você preferir.<br /><br />Abra o app uma vez por mês, insira o valor da sua carteira e deixe o Portfolio Journal fazer o resto.<br /><br />---<br /><br />ACOMPANHE TODA A SUA CARTEIRA<br />• Ações, ETFs, renda fixa, cripto, imóveis — qualquer classe de ativo<br />• Múltiplas contas: corretora, previdência, poupança e mais<br />• Abordagem por check-in mensal: sem ruído diário, só perspectiva de longo prazo<br />• Evolução do patrimônio ao longo de meses e anos<br /><br />VISUALIZE SUA RIQUEZA<br />• Gráfico de evolução: acompanhe o crescimento da sua carteira ao longo do tempo<br />• Gráfico de alocação: saiba exatamente onde está o seu dinheiro<br />• Análise de drawdown: entenda suas piores quedas e recuperações<br />• Modo Calmo: uma visão sem distrações para sua tranquilidade<br /><br />DEFINA E ACOMPANHE SEUS OBJETIVOS<br />• Crie metas financeiras com valor-alvo e prazo<br />• Acompanhe o progresso com indicadores visuais<br />• Mantenha-se motivado com uma visão clara do seu caminho<br /><br />MANTENHA UM DIÁRIO DE INVESTIMENTOS<br />• Registre pensamentos, decisões e lições aprendidas<br />• Construa um histórico pessoal da sua mentalidade como investidor<br />• Reflita sobre decisões passadas para melhorar as futuras<br /><br />PRIVACIDADE POR DESIGN<br />• Sem conta, sem login, sem cadastro.<br />• Sem análises, sem rastreamento, sem venda de dados — nunca.<br />• A sincronização com iCloud é opcional: você controla, com criptografia de ponta a ponta.<br />• Funciona 100% offline.<br /><br />FEITO PARA INVESTIDORES PASSIVOS<br />Portfolio Journal foi desenvolvido para seguidores da filosofia Boglehead, investidores em ETFs e fundos de índice, e todos que adotam uma estratégia de comprar e manter. Não é uma ferramenta de trading — é um companheiro de longo prazo.<br /><br />Seja gerenciando uma carteira de três fundos, buscando a independência financeira (FIRE) ou simplesmente monitorando seu patrimônio líquido, Portfolio Journal oferece clareza sem complexidade.<br /><br />EXPORTE SEUS DADOS<br />• Exportação em CSV: leve seus dados para onde quiser, quando quiser.<br />• Propriedade total do seu histórico financeiro.<br /><br />---<br /><br />PARA QUEM É O PORTFOLIO JOURNAL?<br />- Investidores passivos e de longo prazo que não precisam de atualizações diárias<br />- Entusiastas de ETFs e fundos de índice<br />- Membros da comunidade Boglehead e FIRE<br />- Quem acompanha seu patrimônio líquido ou jornada de construção de riqueza<br />- Usuários preocupados com privacidade que não querem seus dados financeiros em servidores de terceiros<br /><br />---<br /><br />Baixe Portfolio Journal hoje e comece a construir uma visão clara, privada e de longo prazo do seu futuro financeiro.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Melhorias na sincronização com iCloud:<br />• Os dados agora sincronizam instantaneamente ao trocar entre dispositivos Apple<br />• Detecção de novo dispositivo: o app detecta dados existentes no iCloud antes do onboarding, para você nunca começar do zero em um novo iPhone<br />• Corrigidos casos em que alterações feitas em outro dispositivo não eram refletidas corretamente<br />• Melhorias gerais de estabilidade e correções de bugs
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Registre seus investimentos com privacidade. Gráficos elegantes, check-ins mensais e sync iCloud opcional — sem login em corretoras.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
<!-- no screenshots -->
|
||||
<div style="border: 3px solid red; padding: 0px 20px">
|
||||
<h2 style="color: red">No Screenshots Found</h2>
|
||||
<p>
|
||||
deliver couldn't find any screenshots.
|
||||
|
||||
The existing screenshots on App Store Connect will be kept.
|
||||
if you want to remove them you have to use the <i>--overwrite_screenshots</i> flag.
|
||||
|
||||
<p>
|
||||
If you want to download your existing screenshots, run <i>deliver download_screenshots</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-minor-information">
|
||||
<div class="cat-headline">Review Information</div>
|
||||
<dl class="app-minor-information">
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
First name
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
Alexandre<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Last name
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
Vazquez<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Phone number
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
+34683619601<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Email address
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
alexandre.vazquez@gmail.com<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Demo user
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
alexandre.vazquez@gmail.com<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Demo password
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
K8WY*N^3GMQn44mH<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Notes
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
Please review the app version together with the approved In-App Purchase (https://appstoreconnect.apple.com/apps/6757678318/distribution/iaps/6758047004)<br /><br />PortfolioJournal Premium<br />com.portfoliojournal.premium<br />Non-Consumable<br /><br /><br /><br />PortfolioJournal does not require user accounts or sign-in.<br /><br />All features are available immediately after launching the app.<br />Users can create and manage portfolios and journal entries locally on the device.<br /><br />The app does not connect to brokers, execute trades, or provide financial advice.<br />
|
||||
</dd>
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user