diff --git a/.claude/knowledge/gotchas.yaml b/.claude/knowledge/gotchas.yaml
index e72785a..f1a22c1 100644
--- a/.claude/knowledge/gotchas.yaml
+++ b/.claude/knowledge/gotchas.yaml
@@ -19,7 +19,7 @@
how-to-apply: Free to use iOS 26 APIs. Don't add #available(iOS 17, *) checks. Don't fall back to ObservableObject for new view models. If you ever bump the project default, bump every per-target override too — mismatches break @testable import with "minimum deployment target" errors.
- id: secrets-are-gitignored
- summary: Savely/Config.plist contains the OpenAI API key and must never be committed.
+ summary: Savely/Config.plist holds a local OpenAI API key and must never be committed.
why: Leaking it on a public repo means key rotation + potential abuse charges. (GoogleService-Info.plist used to be in this list; it died with the Firebase removal, 2026-08 — the historical copy in public git history is neutralized by deleting the Firebase project, not by scrubbing.)
how-to-apply: Before any commit, grep the staged diff for this path. If present, stop and tell the user.
@@ -28,10 +28,10 @@
why: The Xcode UI test template ships SavelyUITestsLaunchTests with XCTApplicationLaunchMetric which runs the app launch ~8 times measuring performance, taking ~5–10 min and flaking when one iteration exceeds the std-dev threshold. The template tests assert nothing meaningful — they only measure launch time, which is noisy on shared runners. Burning that many minutes for no signal is wasteful.
how-to-apply: Leave -skip-testing:SavelyUITests in .github/workflows/ci.yml's Test step until qa-tester writes real UI tests that exercise actual user paths. When real UI tests exist, drop the flag (or narrow it to skip only the template's launch-perf tests). Build step still compiles the bundle, so breaking changes to UI test code are still caught.
-- id: ci-needs-placeholder-secrets
- summary: CI generates a placeholder Config.plist before xcodebuild because it is a gitignored required build input.
- why: Config.plist is listed as a required build input in project.pbxproj (Resources phase). On a fresh checkout (CI runners) it is absent and xcodebuild fails with "Build input file cannot be found" before compilation. The real key is not needed — CI never calls OpenAI. A placeholder with the right plist shape is sufficient. (GoogleService-Info.plist was also generated here until the Firebase removal, 2026-08.)
- how-to-apply: The "Generate placeholder secrets" step in .github/workflows/ci.yml writes the file. Keep the placeholder's plist shape (OPENAI_API_KEY key) in sync with what OpenAIClient reads at runtime.
+- id: config-plist-never-bundled
+ summary: Savely/Config.plist is NOT a Resources build input and must never become one again.
+ why: Anything in Resources ships inside the IPA and can be extracted, so a bundled key is a published key. 1.0 shipped it that way (key revoked 2026-08-19); 1.1 removed the build-phase entry, which also retired the CI placeholder steps in ci.yml and ci_post_clone.sh.
+ how-to-apply: If Xcode re-adds Config.plist to "Copy Bundle Resources" (e.g. dragging the file into the project with target membership checked), untick it. When Tips returns, serve the key from a proxy you control (docs/plans/drop-bundled-config-plist.md step 3) and relax the archive gate in ci_scripts/ci_post_clone.sh at the same time.
- id: force-unwrap-ratchet-active
summary: force_unwrapping is severity error in .swiftlint.yml — any new value! fails CI and the local pre-commit hook.
@@ -57,3 +57,8 @@
summary: The receipt scanner is Vision + a pure ReceiptParser; it never calls the network and its rules only change together with a fixture in SavelyTests/Fixtures/Receipts.
why: PRODUCT.md promises "money data lives on the phone and never leaves it" and onboarding says so. The 2026-08 spike (docs/spikes/receipt-scan-spike.md) found the old scanner recorded EFECTIVO instead of TOTAL on Mexican receipts, dropped leading digits ("1234.56" -> 234.56), rejected totals under 10, and lost the image orientation before OCR — all untested. The rewrite made extraction deterministic and fixture-driven so regressions fail CI without a camera.
how-to-apply: OCR config lives in Utilities/ReceiptOCR.swift (always pass CGImagePropertyOrientation; recognitionLanguages resolved at runtime to es+en). Extraction lives in Utilities/Receipt/ (vocabulary, amount grammar, dates, parser); to change a rule, add or edit a JSON fixture first (hand-authored fixtures say so in "source"; device dumps must be anonymized — no RFC/phone/card/name lines, no images). The DEBUG-only "Export OCR dump" button on the review screen writes a fixture-shaped JSON. FeatureFlags.useSystemDocumentCamera swaps the capture surface for VNDocumentCameraViewController; the Simulator supports neither camera, so use "Choose from Photos" there.
+
+- id: persisted-identifiers-never-localized
+ summary: Expense categories, income sources, the payday auto-move note, achievement ids and notification ids are stored as stable English strings; only their display text is localized.
+ why: 1.1 added es-419. The quick-add chips used to store `cat.label` (the text on screen), so a Spanish UI would have written "Café" next to older "Coffee" rows and split every filter, chart and group by language.
+ how-to-apply: Write `ExpenseCategory.storageKey` / `IncomeSource.storageKey` (raw values, Savely/Utilities/MoneyCategories.swift) and `GoalDeposits.autoMoveNote`; show `displayName`. Never persist a `String(localized:)` value as an identifier. CategoryPersistenceTests guards the round trip.
diff --git a/.claude/knowledge/localization.md b/.claude/knowledge/localization.md
index 1bb29df..7380492 100644
--- a/.claude/knowledge/localization.md
+++ b/.claude/knowledge/localization.md
@@ -67,6 +67,12 @@ Image(systemName: "trash")
.accessibilityLabel(L10n.Common.delete)
```
+## Supported languages & the CI gate
+
+English (source) and **es-419** (Latin-American Spanish, tú, sentence case, no exclamation marks — see PRODUCT.md voice). `scripts/check-l10n.py` runs in CI ("Localization completeness"): every live key needs a translated es-419 value with the same format specifiers. Run it locally before committing catalog changes. When editing `Localizable.xcstrings` by script, keep Xcode's formatting (`" : "` separators, keys ordered by `localizedStandardCompare`) so diffs stay reviewable.
+
+Persisted identifiers (categories, income sources, notes, ids) are never localized — see `gotchas.yaml#persisted-identifiers-never-localized`.
+
## Catalog hygiene
- Keep keys grouped by feature in `Strings.swift` so they're easy to find.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a015f0a..31d73c6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -68,23 +68,10 @@ jobs:
# how to tighten this over time.
run: swiftlint lint --reporter github-actions-logging
- - name: Generate placeholder secrets
- # Config.plist (OpenAI) is gitignored locally, but the Xcode project
- # lists it as a required build input. xcodebuild fails on a fresh
- # checkout if it's absent. CI never calls OpenAI, so a placeholder
- # with the right shape is sufficient. (GoogleService-Info.plist died
- # with the Firebase removal — see docs/plans/remove-auth-firebase.md.)
- run: |
- cat > Savely/Config.plist <<'PLIST'
-
-
-
-
- OPENAI_API_KEY
- ci-placeholder-not-a-real-key
-
-
- PLIST
+ - name: Localization completeness
+ # Every live key in Localizable.xcstrings needs a translated es-419
+ # value with the same format specifiers as the English source.
+ run: python3 scripts/check-l10n.py
- name: Build
run: |
diff --git a/.gitignore b/.gitignore
index 8fdd2b7..1d8f269 100755
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
fastlane/README.md
+
+# /brag launch-video renders (large, regenerable)
+brag-output*/
diff --git a/CLAUDE.md b/CLAUDE.md
index 55fde84..a5e857a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -72,7 +72,7 @@ Savely/
├── Resources/ # Strings.swift, Localizable.xcstrings, Color+Warm.swift, UIConstants.swift
├── Assets.xcassets/ # Images + ColorPalette
├── Savely.entitlements # Sign in with Apple
-├── Config.plist # OpenAI API key — GITIGNORED
+├── Config.plist # local OpenAI key — GITIGNORED, not bundled
SavelyTests/ # XCTest unit tests (skeleton only today)
SavelyUITests/ # XCTest UI tests (skeleton only today)
@@ -92,7 +92,7 @@ These are invariants that future changes must respect. They were discovered duri
1. **Signing is automatic, team `ZHLD96SP29`.** Don't switch to manual signing. Don't edit signing build settings.
2. **No `.xcconfig` files exist** — all settings live in `project.pbxproj`. Touching pbxproj is risky; prefer Xcode UI edits and review the diff carefully.
-3. **Secrets are gitignored:** `Savely/Config.plist` (OpenAI), anything matching `.env*`. Never commit these. Never paste their contents.
+3. **Secrets are gitignored:** `Savely/Config.plist` (OpenAI), anything matching `.env*`. Never commit these. Never paste their contents. `Config.plist` is also never a Resources build input — a bundled key ships in the IPA (`gotchas.yaml#config-plist-never-bundled`).
4. **iOS 26.0 is the minimum deployment target** for the app target. Use modern APIs freely (`@Observable`, `NavigationStack`, Swift Testing, etc.).
5. **Only `Savely.xcscheme` is shared and tracked** in `Savely.xcodeproj/xcshareddata/xcschemes/`. Test commands must use `-scheme Savely`, never `-scheme SavelyTests`.
6. **Localization rule:** all user-facing strings go through `Resources/Strings.swift` constants and are registered in `Localizable.xcstrings`. No hardcoded literals in views.
diff --git a/Savely.xcodeproj/project.pbxproj b/Savely.xcodeproj/project.pbxproj
index 4d4d7f8..4a29ef1 100644
--- a/Savely.xcodeproj/project.pbxproj
+++ b/Savely.xcodeproj/project.pbxproj
@@ -7,46 +7,9 @@
objects = {
/* Begin PBXBuildFile section */
+ 098D870F358FFD061FE47F1A /* ReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740B73ADB53C98937307DBAA /* ReceiptParser.swift */; };
120AE56C41CBD54B070A8436 /* ScreenshotSeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */; };
- A11Y0AUD17BUILDFILE00000 /* AccessibilityAuditUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */; };
- F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */; };
- C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */; };
- 7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */; };
- 31A69355DA4D561A928D09D7 /* DocumentCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */; };
15C9F87752F242860A062A9C /* ReceiptScanFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A17E22922859025B4DEE1D /* ReceiptScanFlowView.swift */; };
- 6BEBD87A257D196FC2B5F572 /* ReceiptReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA5F5E26408477F8E6C59BC /* ReceiptReviewView.swift */; };
- 5A923D851E1F0A9DE0182E2A /* ReceiptScanModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */; };
- CEBCEA30F32B686EB71A2866 /* ReceiptOCR.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */; };
- 46C940AC7FC071D52F7037C9 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */; };
- EB84DBEF8B09F9EB1E7AAFF6 /* ReceiptFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */; };
- 9AB16553AE439FAB12574217 /* ReceiptParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A2405B0A3780703DFC27C9 /* ReceiptParserTests.swift */; };
- 098D870F358FFD061FE47F1A /* ReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740B73ADB53C98937307DBAA /* ReceiptParser.swift */; };
- 5522F0A81CBF724D40756A20 /* ReceiptDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */; };
- 5C26C81FFC3D8C3867044BB4 /* ReceiptAmountGrammar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */; };
- 6A21AE5F9EDE58BDDB5A1514 /* ReceiptVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */; };
- 21A73EF62C20FDA050059206 /* OCRLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA2F094D50821E2DA0497A4 /* OCRLine.swift */; };
- AA28B8C8B38F312A7623F2D2 /* AutoMoveSuggestionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */; };
- DE826483AC71FF3EC2A603B7 /* AutoMoveSuggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */; };
- A11A0002C0DE5AFE10000002 /* AmountParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0001C0DE5AFE10000001 /* AmountParsing.swift */; };
- A11A0006C0DE5AFE10000006 /* ReminderSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0005C0DE5AFE10000005 /* ReminderSettings.swift */; };
- A11A000CC0DE5AFE1000000C /* MoneyCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A000BC0DE5AFE1000000B /* MoneyCategories.swift */; };
- A11A0010C0DE5AFE10000010 /* DepositModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A000FC0DE5AFE1000000F /* DepositModel.swift */; };
- A11A0012C0DE5AFE10000012 /* GoalDeposits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0011C0DE5AFE10000011 /* GoalDeposits.swift */; };
- A11A0016C0DE5AFE10000016 /* GoalEditSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0015C0DE5AFE10000015 /* GoalEditSheet.swift */; };
- A11A0018C0DE5AFE10000018 /* GoalEditValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0017C0DE5AFE10000017 /* GoalEditValidation.swift */; };
- A11A001CC0DE5AFE1000001C /* GoalPace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001BC0DE5AFE1000001B /* GoalPace.swift */; };
- A11A0024C0DE5AFE10000024 /* KeyboardDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0023C0DE5AFE10000023 /* KeyboardDismisser.swift */; };
- A11A0008C0DE5AFE10000008 /* ReminderSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0007C0DE5AFE10000007 /* ReminderSettingsTests.swift */; };
- A11A000EC0DE5AFE1000000E /* CategoryPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A000DC0DE5AFE1000000D /* CategoryPersistenceTests.swift */; };
- A11A0014C0DE5AFE10000014 /* GoalDepositsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0013C0DE5AFE10000013 /* GoalDepositsTests.swift */; };
- A11A001AC0DE5AFE1000001A /* GoalEditValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0019C0DE5AFE10000019 /* GoalEditValidationTests.swift */; };
- A11A001EC0DE5AFE1000001E /* GoalPaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001DC0DE5AFE1000001D /* GoalPaceTests.swift */; };
- A11A0004C0DE5AFE10000004 /* AmountParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0003C0DE5AFE10000003 /* AmountParsingTests.swift */; };
- 510740529955B0B1E6EA730D /* AchievementEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FA742D1E24F010F3273E30 /* AchievementEngineTests.swift */; };
- F764EBD092BA4435466DD9AA /* AchievementEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12789AD27FF1A7114156F945 /* AchievementEngine.swift */; };
- ED04D2F84390584EB7EC0615 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5BE2C1ED73D49DC33599EF2 /* WelcomeStepView.swift */; };
- 699FCB7F699B1E9C9C644AF3 /* SproutMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DAD27B5231BDC080F36DA /* SproutMark.swift */; };
- 8DA6B54FCD7F5EA515DBC1CC /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B815A380D566280140319 /* FeatureFlags.swift */; };
1D3669872CC62F8200E84FF9 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3669862CC62F8200E84FF9 /* Strings.swift */; };
1D3669892CC62F9100E84FF9 /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3669882CC62F9100E84FF9 /* UIConstants.swift */; };
1D36698B2CC62FD700E84FF9 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1D36698A2CC62FD700E84FF9 /* Localizable.xcstrings */; };
@@ -61,7 +24,6 @@
1DAF5D1B2CEDC118001C0AF3 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D1A2CEDC118001C0AF3 /* GoalsViewModel.swift */; };
1DAF5D1E2CEE9695001C0AF3 /* TipsAndSuggestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D1D2CEE9695001C0AF3 /* TipsAndSuggestionViewModel.swift */; };
1DAF5D202CEE9767001C0AF3 /* TipHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D1F2CEE9767001C0AF3 /* TipHistoryView.swift */; };
- 1DAF5D242CEE9AF4001C0AF3 /* Config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1DAF5D232CEE9AF4001C0AF3 /* Config.plist */; };
1DAF5D262CEE9C13001C0AF3 /* OpenAIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D252CEE9C13001C0AF3 /* OpenAIClient.swift */; };
1DAF5D282CEE9DCF001C0AF3 /* OpenAIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D272CEE9DCF001C0AF3 /* OpenAIModels.swift */; };
1DAF5D332CEEE7AA001C0AF3 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAF5D322CEEE7AA001C0AF3 /* Notifications.swift */; };
@@ -72,7 +34,6 @@
1DC014132CE5B0C5009A46E1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC014122CE5B0C5009A46E1 /* ContentView.swift */; };
1DC258082CC07DCC0002DDE3 /* SavelyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC258072CC07DCC0002DDE3 /* SavelyApp.swift */; };
1DC2580C2CC07DCF0002DDE3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1DC2580B2CC07DCF0002DDE3 /* Assets.xcassets */; };
- A11A000AC0DE5AFE1000000A /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A11A0009C0DE5AFE10000009 /* AppIcon.icon */; };
1DC2580F2CC07DCF0002DDE3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1DC2580E2CC07DCF0002DDE3 /* Preview Assets.xcassets */; };
1DC258192CC07DCF0002DDE3 /* SavelyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC258182CC07DCF0002DDE3 /* SavelyTests.swift */; };
1DC258232CC07DCF0002DDE3 /* SavelyUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC258222CC07DCF0002DDE3 /* SavelyUITests.swift */; };
@@ -97,12 +58,50 @@
1DE9FE172D066EE20069AA64 /* ReportsPDFGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */; };
1DEACCDE2D0BE6340084CC39 /* NetworkErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DEACCDD2D0BE6340084CC39 /* NetworkErrorView.swift */; };
1DF7228E2F9C182300320B38 /* Color+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7228D2F9C182300320B38 /* Color+Warm.swift */; };
- A11A0020C0DE5AFE10000020 /* Font+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001FC0DE5AFE1000001F /* Font+Warm.swift */; };
- A11A0022C0DE5AFE10000022 /* WarmTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */; };
1DF722902F9C18FE00320B38 /* GoalDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7228F2F9C18FE00320B38 /* GoalDetailView.swift */; };
1DF722922F9C19CE00320B38 /* MoneyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722912F9C19CE00320B38 /* MoneyView.swift */; };
1DF722942F9C19E600320B38 /* AchievementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722932F9C19E600320B38 /* AchievementsView.swift */; };
1DF722962F9C2FA400320B38 /* AddGoalFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722952F9C2FA400320B38 /* AddGoalFlow.swift */; };
+ 21A73EF62C20FDA050059206 /* OCRLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA2F094D50821E2DA0497A4 /* OCRLine.swift */; };
+ 31A69355DA4D561A928D09D7 /* DocumentCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */; };
+ 46C940AC7FC071D52F7037C9 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */; };
+ 510740529955B0B1E6EA730D /* AchievementEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FA742D1E24F010F3273E30 /* AchievementEngineTests.swift */; };
+ 5522F0A81CBF724D40756A20 /* ReceiptDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */; };
+ 5A923D851E1F0A9DE0182E2A /* ReceiptScanModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */; };
+ 5C26C81FFC3D8C3867044BB4 /* ReceiptAmountGrammar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */; };
+ 699FCB7F699B1E9C9C644AF3 /* SproutMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DAD27B5231BDC080F36DA /* SproutMark.swift */; };
+ 6A21AE5F9EDE58BDDB5A1514 /* ReceiptVocabulary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */; };
+ 6BEBD87A257D196FC2B5F572 /* ReceiptReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA5F5E26408477F8E6C59BC /* ReceiptReviewView.swift */; };
+ 7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */; };
+ 8DA6B54FCD7F5EA515DBC1CC /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B815A380D566280140319 /* FeatureFlags.swift */; };
+ 9AB16553AE439FAB12574217 /* ReceiptParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A2405B0A3780703DFC27C9 /* ReceiptParserTests.swift */; };
+ A11A0002C0DE5AFE10000002 /* AmountParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0001C0DE5AFE10000001 /* AmountParsing.swift */; };
+ A11A0004C0DE5AFE10000004 /* AmountParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0003C0DE5AFE10000003 /* AmountParsingTests.swift */; };
+ A11A0006C0DE5AFE10000006 /* ReminderSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0005C0DE5AFE10000005 /* ReminderSettings.swift */; };
+ A11A0008C0DE5AFE10000008 /* ReminderSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0007C0DE5AFE10000007 /* ReminderSettingsTests.swift */; };
+ A11A000AC0DE5AFE1000000A /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A11A0009C0DE5AFE10000009 /* AppIcon.icon */; };
+ A11A000CC0DE5AFE1000000C /* MoneyCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A000BC0DE5AFE1000000B /* MoneyCategories.swift */; };
+ A11A000EC0DE5AFE1000000E /* CategoryPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A000DC0DE5AFE1000000D /* CategoryPersistenceTests.swift */; };
+ A11A0010C0DE5AFE10000010 /* DepositModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A000FC0DE5AFE1000000F /* DepositModel.swift */; };
+ A11A0012C0DE5AFE10000012 /* GoalDeposits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0011C0DE5AFE10000011 /* GoalDeposits.swift */; };
+ A11A0014C0DE5AFE10000014 /* GoalDepositsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0013C0DE5AFE10000013 /* GoalDepositsTests.swift */; };
+ A11A0016C0DE5AFE10000016 /* GoalEditSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0015C0DE5AFE10000015 /* GoalEditSheet.swift */; };
+ A11A0018C0DE5AFE10000018 /* GoalEditValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0017C0DE5AFE10000017 /* GoalEditValidation.swift */; };
+ A11A001AC0DE5AFE1000001A /* GoalEditValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0019C0DE5AFE10000019 /* GoalEditValidationTests.swift */; };
+ A11A001CC0DE5AFE1000001C /* GoalPace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001BC0DE5AFE1000001B /* GoalPace.swift */; };
+ A11A001EC0DE5AFE1000001E /* GoalPaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001DC0DE5AFE1000001D /* GoalPaceTests.swift */; };
+ A11A0020C0DE5AFE10000020 /* Font+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001FC0DE5AFE1000001F /* Font+Warm.swift */; };
+ A11A0022C0DE5AFE10000022 /* WarmTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */; };
+ A11A0024C0DE5AFE10000024 /* KeyboardDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0023C0DE5AFE10000023 /* KeyboardDismisser.swift */; };
+ A11Y0AUD17BUILDFILE00000 /* AccessibilityAuditUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */; };
+ AA28B8C8B38F312A7623F2D2 /* AutoMoveSuggestionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */; };
+ C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */; };
+ CEBCEA30F32B686EB71A2866 /* ReceiptOCR.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */; };
+ DE826483AC71FF3EC2A603B7 /* AutoMoveSuggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */; };
+ EB84DBEF8B09F9EB1E7AAFF6 /* ReceiptFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */; };
+ ED04D2F84390584EB7EC0615 /* WelcomeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5BE2C1ED73D49DC33599EF2 /* WelcomeStepView.swift */; };
+ F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */; };
+ F764EBD092BA4435466DD9AA /* AchievementEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12789AD27FF1A7114156F945 /* AchievementEngine.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -123,46 +122,10 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
- F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSeed.swift; sourceTree = ""; };
- A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditUITests.swift; sourceTree = ""; };
- 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreScreenshotTourUITests.swift; sourceTree = ""; };
- C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanUITests.swift; sourceTree = ""; };
- 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCRIntegrationTests.swift; sourceTree = ""; };
- D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCameraView.swift; sourceTree = ""; };
06A17E22922859025B4DEE1D /* ReceiptScanFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanFlowView.swift; sourceTree = ""; };
0CA5F5E26408477F8E6C59BC /* ReceiptReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptReviewView.swift; sourceTree = ""; };
- 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanModel.swift; sourceTree = ""; };
- D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCR.swift; sourceTree = ""; };
- 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; };
- 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFixtureTests.swift; sourceTree = ""; };
10A2405B0A3780703DFC27C9 /* ReceiptParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParserTests.swift; sourceTree = ""; };
- 740B73ADB53C98937307DBAA /* ReceiptParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParser.swift; sourceTree = ""; };
- 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptDateParser.swift; sourceTree = ""; };
- 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptAmountGrammar.swift; sourceTree = ""; };
- AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptVocabulary.swift; sourceTree = ""; };
- CFA2F094D50821E2DA0497A4 /* OCRLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRLine.swift; sourceTree = ""; };
- 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoMoveSuggestionTests.swift; sourceTree = ""; };
- F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoMoveSuggestion.swift; sourceTree = ""; };
- A11A0001C0DE5AFE10000001 /* AmountParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountParsing.swift; sourceTree = ""; };
- A11A0005C0DE5AFE10000005 /* ReminderSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderSettings.swift; sourceTree = ""; };
- A11A000BC0DE5AFE1000000B /* MoneyCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyCategories.swift; sourceTree = ""; };
- A11A000FC0DE5AFE1000000F /* DepositModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepositModel.swift; sourceTree = ""; };
- A11A0011C0DE5AFE10000011 /* GoalDeposits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDeposits.swift; sourceTree = ""; };
- A11A0015C0DE5AFE10000015 /* GoalEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalEditSheet.swift; sourceTree = ""; };
- A11A0017C0DE5AFE10000017 /* GoalEditValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalEditValidation.swift; sourceTree = ""; };
- A11A001BC0DE5AFE1000001B /* GoalPace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalPace.swift; sourceTree = ""; };
- A11A0023C0DE5AFE10000023 /* KeyboardDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardDismisser.swift; sourceTree = ""; };
- A11A0007C0DE5AFE10000007 /* ReminderSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderSettingsTests.swift; sourceTree = ""; };
- A11A000DC0DE5AFE1000000D /* CategoryPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPersistenceTests.swift; sourceTree = ""; };
- A11A0013C0DE5AFE10000013 /* GoalDepositsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDepositsTests.swift; sourceTree = ""; };
- A11A0019C0DE5AFE10000019 /* GoalEditValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalEditValidationTests.swift; sourceTree = ""; };
- A11A001DC0DE5AFE1000001D /* GoalPaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalPaceTests.swift; sourceTree = ""; };
- A11A0003C0DE5AFE10000003 /* AmountParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountParsingTests.swift; sourceTree = ""; };
- 62FA742D1E24F010F3273E30 /* AchievementEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementEngineTests.swift; sourceTree = ""; };
12789AD27FF1A7114156F945 /* AchievementEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementEngine.swift; sourceTree = ""; };
- E5BE2C1ED73D49DC33599EF2 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; };
- F38DAD27B5231BDC080F36DA /* SproutMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SproutMark.swift; sourceTree = ""; };
- 693B815A380D566280140319 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; };
1D3669862CC62F8200E84FF9 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; };
1D3669882CC62F9100E84FF9 /* UIConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIConstants.swift; sourceTree = ""; };
1D36698A2CC62FD700E84FF9 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
@@ -190,7 +153,6 @@
1DC258042CC07DCC0002DDE3 /* Savely.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Savely.app; sourceTree = BUILT_PRODUCTS_DIR; };
1DC258072CC07DCC0002DDE3 /* SavelyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavelyApp.swift; sourceTree = ""; };
1DC2580B2CC07DCF0002DDE3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
- A11A0009C0DE5AFE10000009 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; };
1DC2580E2CC07DCF0002DDE3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
1DC258142CC07DCF0002DDE3 /* SavelyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SavelyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1DC258182CC07DCF0002DDE3 /* SavelyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavelyTests.swift; sourceTree = ""; };
@@ -218,12 +180,49 @@
1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportsPDFGenerator.swift; sourceTree = ""; };
1DEACCDD2D0BE6340084CC39 /* NetworkErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkErrorView.swift; sourceTree = ""; };
1DF7228D2F9C182300320B38 /* Color+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Warm.swift"; sourceTree = ""; };
- A11A001FC0DE5AFE1000001F /* Font+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Warm.swift"; sourceTree = ""; };
- A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarmTypeTests.swift; sourceTree = ""; };
1DF7228F2F9C18FE00320B38 /* GoalDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDetailView.swift; sourceTree = ""; };
1DF722912F9C19CE00320B38 /* MoneyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyView.swift; sourceTree = ""; };
1DF722932F9C19E600320B38 /* AchievementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementsView.swift; sourceTree = ""; };
1DF722952F9C2FA400320B38 /* AddGoalFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGoalFlow.swift; sourceTree = ""; };
+ 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptDateParser.swift; sourceTree = ""; };
+ 410C0D1F40524DF8CFE25021 /* ReceiptScanModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanModel.swift; sourceTree = ""; };
+ 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreScreenshotTourUITests.swift; sourceTree = ""; };
+ 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCRIntegrationTests.swift; sourceTree = ""; };
+ 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptAmountGrammar.swift; sourceTree = ""; };
+ 62FA742D1E24F010F3273E30 /* AchievementEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementEngineTests.swift; sourceTree = ""; };
+ 693B815A380D566280140319 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; };
+ 740B73ADB53C98937307DBAA /* ReceiptParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParser.swift; sourceTree = ""; };
+ 9AFC2151C01A7FBD2947EB51 /* AutoMoveSuggestionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoMoveSuggestionTests.swift; sourceTree = ""; };
+ 9C6F50EC75E1A4FF9BE4992A /* ReceiptFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFixtureTests.swift; sourceTree = ""; };
+ 9FDC7BEFB3FA01CDB951DBBA /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; };
+ A11A0001C0DE5AFE10000001 /* AmountParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountParsing.swift; sourceTree = ""; };
+ A11A0003C0DE5AFE10000003 /* AmountParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountParsingTests.swift; sourceTree = ""; };
+ A11A0005C0DE5AFE10000005 /* ReminderSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderSettings.swift; sourceTree = ""; };
+ A11A0007C0DE5AFE10000007 /* ReminderSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderSettingsTests.swift; sourceTree = ""; };
+ A11A0009C0DE5AFE10000009 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; };
+ A11A000BC0DE5AFE1000000B /* MoneyCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyCategories.swift; sourceTree = ""; };
+ A11A000DC0DE5AFE1000000D /* CategoryPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPersistenceTests.swift; sourceTree = ""; };
+ A11A000FC0DE5AFE1000000F /* DepositModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepositModel.swift; sourceTree = ""; };
+ A11A0011C0DE5AFE10000011 /* GoalDeposits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDeposits.swift; sourceTree = ""; };
+ A11A0013C0DE5AFE10000013 /* GoalDepositsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDepositsTests.swift; sourceTree = ""; };
+ A11A0015C0DE5AFE10000015 /* GoalEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalEditSheet.swift; sourceTree = ""; };
+ A11A0017C0DE5AFE10000017 /* GoalEditValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalEditValidation.swift; sourceTree = ""; };
+ A11A0019C0DE5AFE10000019 /* GoalEditValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalEditValidationTests.swift; sourceTree = ""; };
+ A11A001BC0DE5AFE1000001B /* GoalPace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalPace.swift; sourceTree = ""; };
+ A11A001DC0DE5AFE1000001D /* GoalPaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalPaceTests.swift; sourceTree = ""; };
+ A11A001FC0DE5AFE1000001F /* Font+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Warm.swift"; sourceTree = ""; };
+ A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarmTypeTests.swift; sourceTree = ""; };
+ A11A0023C0DE5AFE10000023 /* KeyboardDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardDismisser.swift; sourceTree = ""; };
+ A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditUITests.swift; sourceTree = ""; };
+ AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptVocabulary.swift; sourceTree = ""; };
+ C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanUITests.swift; sourceTree = ""; };
+ CFA2F094D50821E2DA0497A4 /* OCRLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRLine.swift; sourceTree = ""; };
+ D2C90599A224DF05C1C7BC95 /* ReceiptOCR.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCR.swift; sourceTree = ""; };
+ D2DA345E04E3ACA1DEDFCDD2 /* DocumentCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCameraView.swift; sourceTree = ""; };
+ E5BE2C1ED73D49DC33599EF2 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; };
+ F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSeed.swift; sourceTree = ""; };
+ F38DAD27B5231BDC080F36DA /* SproutMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SproutMark.swift; sourceTree = ""; };
+ F774124079B5BEC3DD37C743 /* AutoMoveSuggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoMoveSuggestion.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -251,18 +250,6 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
- 5A440101E954A364F752379E /* Receipt */ = {
- isa = PBXGroup;
- children = (
- 740B73ADB53C98937307DBAA /* ReceiptParser.swift */,
- 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */,
- 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */,
- AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */,
- CFA2F094D50821E2DA0497A4 /* OCRLine.swift */,
- );
- path = Receipt;
- sourceTree = "";
- };
1D3669842CC62EDD00E84FF9 /* ViewModels */ = {
isa = PBXGroup;
children = (
@@ -606,6 +593,18 @@
path = Dashboard;
sourceTree = "";
};
+ 5A440101E954A364F752379E /* Receipt */ = {
+ isa = PBXGroup;
+ children = (
+ 740B73ADB53C98937307DBAA /* ReceiptParser.swift */,
+ 20B90A9EEB228FE2A1132DFD /* ReceiptDateParser.swift */,
+ 505D129592F5E1D668ECF4C3 /* ReceiptAmountGrammar.swift */,
+ AEA22FF0B24D8017909BA914 /* ReceiptVocabulary.swift */,
+ CFA2F094D50821E2DA0497A4 /* OCRLine.swift */,
+ );
+ path = Receipt;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -718,7 +717,6 @@
1DC2580F2CC07DCF0002DDE3 /* Preview Assets.xcassets in Resources */,
1DC2580C2CC07DCF0002DDE3 /* Assets.xcassets in Resources */,
A11A000AC0DE5AFE1000000A /* AppIcon.icon in Resources */,
- 1DAF5D242CEE9AF4001C0AF3 /* Config.plist in Resources */,
1D36698B2CC62FD700E84FF9 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1032,8 +1030,10 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = IvanLB.Savely;
PRODUCT_NAME = "$(TARGET_NAME)";
- SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
- SUPPORTS_MACCATALYST = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -1068,8 +1068,10 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = IvanLB.Savely;
PRODUCT_NAME = "$(TARGET_NAME)";
- SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
- SUPPORTS_MACCATALYST = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -1090,8 +1092,10 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = IvanLB.SavelyTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
- SUPPORTS_MACCATALYST = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -1113,8 +1117,10 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = IvanLB.SavelyTests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
- SUPPORTS_MACCATALYST = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -1134,8 +1140,10 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = IvanLB.SavelyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
- SUPPORTS_MACCATALYST = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -1155,8 +1163,10 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = IvanLB.SavelyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
- SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
- SUPPORTS_MACCATALYST = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -1204,8 +1214,6 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
-
-
};
rootObject = 1DC257FC2CC07DCC0002DDE3 /* Project object */;
}
diff --git a/Savely/Info.plist b/Savely/Info.plist
index 17bc9a4..cbb14da 100644
--- a/Savely/Info.plist
+++ b/Savely/Info.plist
@@ -2,27 +2,10 @@
-
+
ITSAppUsesNonExemptEncryption
- NSAppTransportSecurity
-
- NSExceptionDomains
-
- openai.com
-
- NSExceptionAllowsInsecureHTTPLoads
-
- NSIncludesSubdomains
-
- NSTemporaryExceptionAllowsInsecureHTTPLoads
-
- NSTemporaryExceptionMinimumTLSVersion
- TLSv1.2
-
-
-
diff --git a/Savely/Resources/Localizable.xcstrings b/Savely/Resources/Localizable.xcstrings
index 4159ab8..9608d39 100755
--- a/Savely/Resources/Localizable.xcstrings
+++ b/Savely/Resources/Localizable.xcstrings
@@ -2,16 +2,44 @@
"sourceLanguage" : "en",
"strings" : {
"" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : ""
+ }
+ }
+ }
},
" for 18 months" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : " por 18 meses"
+ }
+ }
+ }
},
" of %lld" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : " de %lld"
+ }
+ }
+ }
},
"—" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "—"
+ }
+ }
+ }
},
", favorite" : {
"localizations" : {
@@ -30,13 +58,64 @@
}
},
".00" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : ".00"
+ }
+ }
+ }
+ },
+ "**Auto-move $%lld** to %@ from this income?" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿**Mover $%1$lld** a %2$@ de este ingreso?"
+ }
+ }
+ }
+ },
+ "**Auto-move $%lld** to %@ from this paycheck?" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿**Mover $%1$lld** a %2$@ de este sueldo?"
+ }
+ }
+ }
+ },
+ "**Moving $%lld** to %@ when you save." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "**Moviendo $%1$lld** a %2$@ al guardar."
+ }
+ }
+ }
},
"/wk" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "/sem"
+ }
+ }
+ }
},
"%" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%"
+ }
+ }
+ }
},
"%@ · %@" : {
"localizations" : {
@@ -45,6 +124,12 @@
"state" : "new",
"value" : "%1$@ · %2$@"
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%1$@ · %2$@"
+ }
}
}
},
@@ -81,7 +166,24 @@
}
},
"%@ is on your home screen.\nAdd the first dollar?" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ ya aparece en Inicio.\n¿Agregas el primer depósito?"
+ }
+ }
+ }
+ },
+ "%@ of %@" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%1$@ de %2$@"
+ }
+ }
+ }
},
"%@, %@" : {
"localizations" : {
@@ -122,6 +224,12 @@
"state" : "new",
"value" : "%1$@, %2$@%3$@"
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%1$@, %2$@%3$@"
+ }
}
}
},
@@ -142,7 +250,14 @@
}
},
"%lld" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld"
+ }
+ }
+ }
},
"%lld active · %@ saved of %@" : {
"localizations" : {
@@ -151,6 +266,34 @@
"state" : "new",
"value" : "%1$lld active · %2$@ saved of %3$@"
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%#@active@ · %2$@ ahorrado de %3$@"
+ },
+ "substitutions" : {
+ "active" : {
+ "argNum" : 1,
+ "formatSpecifier" : "lld",
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%arg activa"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%arg activas"
+ }
+ }
+ }
+ }
+ }
+ }
}
}
},
@@ -161,6 +304,34 @@
"state" : "new",
"value" : "%1$lld earned,\n%2$lld to go."
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%#@earned@,\n%2$lld por obtener."
+ },
+ "substitutions" : {
+ "earned" : {
+ "argNum" : 1,
+ "formatSpecifier" : "lld",
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%arg obtenido"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%arg obtenidos"
+ }
+ }
+ }
+ }
+ }
+ }
}
}
},
@@ -191,177 +362,344 @@
}
},
"%lld%%" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld%%"
+ }
+ }
+ }
},
"+%@" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "+%@"
+ }
+ }
+ }
},
"+$" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "+$"
+ }
+ }
+ }
},
"~$%lld/mo" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "~$%lld/mes"
+ }
+ }
+ }
},
"−%@" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "−%@"
+ }
+ }
+ }
},
"−$" : {
-
- },
- "$" : {
-
- },
- "$%.2f / $%.2f" : {
- "extractionState" : "stale",
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "$%1$.2f / $%2$.2f"
+ "state" : "translated",
+ "value" : "−$"
}
- },
+ }
+ }
+ },
+ "$" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "$%1$.2f / $%2$.2f"
+ "value" : "$"
}
}
}
},
"$%@" : {
-
- },
- "$%lld" : {
-
- },
- "$%lld/week" : {
-
- },
- "$0" : {
-
- },
- "0" : {
-
- },
- "0.00" : {
-
- },
- "7-month trend" : {
-
- },
- "A goal is a name, a number, and a date.\nSavely takes care of the rest." : {
-
- },
- "About " : {
-
- },
- "Achievements" : {
-
- },
- "achievements_title" : {
- "comment" : "Achievements Title",
- "extractionState" : "stale",
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Achievements and Medals"
+ "state" : "translated",
+ "value" : "$%@"
}
- },
+ }
+ }
+ },
+ "$%lld" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Logros y Medallas"
+ "value" : "$%lld"
}
}
}
},
- "Add" : {
+ "$%lld/week" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Add"
+ "state" : "translated",
+ "value" : "$%lld/semana"
}
- },
+ }
+ }
+ },
+ "$0" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Agregar"
+ "value" : "$0"
}
}
}
},
- "Add %@ to %@" : {
+ "0" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Add %1$@ to %2$@"
+ "state" : "translated",
+ "value" : "0"
}
}
}
},
- "Add $%@ to %@" : {
+ "0.00" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Add $%1$@ to %2$@"
+ "state" : "translated",
+ "value" : "0.00"
}
}
}
},
- "Add $%lld to %@" : {
+ "1 yr" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Add $%1$lld to %2$@"
+ "state" : "translated",
+ "value" : "1 año"
}
}
}
},
- "Add a deposit" : {
-
- },
- "Add deposit" : {
-
- },
- "Add expense" : {
+ "2 yr" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Add expense"
+ "state" : "translated",
+ "value" : "2 años"
}
- },
+ }
+ }
+ },
+ "6 mo" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Agregar gasto"
+ "value" : "6 meses"
}
}
}
},
- "Add income" : {
+ "7-month trend" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Add income"
+ "state" : "translated",
+ "value" : "Tendencia de 7 meses"
}
- },
+ }
+ }
+ },
+ "18 mo" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Agregar ingreso"
+ "value" : "18 meses"
+ }
+ }
+ }
+ },
+ "A goal is a name, a number, and a date.\nSavely takes care of the rest." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Una meta es un nombre, una cifra y una fecha.\nSavely se encarga del resto."
+ }
+ }
+ }
+ },
+ "A trip" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un viaje"
+ }
+ }
+ }
+ },
+ "About" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Acerca de"
+ }
+ }
+ }
+ },
+ "About " : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unos "
+ }
+ }
+ }
+ },
+ "Achievements" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Logros"
+ }
+ }
+ }
+ },
+ "Add %@ to %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add %1$@ to %2$@"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar %1$@ a %2$@"
+ }
+ }
+ }
+ },
+ "Add $%@ to %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add $%1$@ to %2$@"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar $%1$@ a %2$@"
+ }
+ }
+ }
+ },
+ "Add $%lld to %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add $%1$lld to %2$@"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar $%1$lld a %2$@"
+ }
+ }
+ }
+ },
+ "Add a deposit" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar un depósito"
+ }
+ }
+ }
+ },
+ "Add deposit" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar depósito"
+ }
+ }
+ }
+ },
+ "Add expense" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add expense"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar gasto"
+ }
+ }
+ }
+ },
+ "Add income" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add income"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar ingreso"
}
}
}
},
"Add your first expense above" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agrega tu primer gasto arriba"
+ }
+ }
+ }
},
"add_expense_button" : {
"comment" : "Add Expense Button",
@@ -376,2361 +714,3670 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Agregar Gasto"
+ "value" : "Agregar gasto"
+ }
+ }
+ }
+ },
+ "add_income_button" : {
+ "comment" : "Add Income Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add Income"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar ingreso"
+ }
+ }
+ }
+ },
+ "add_income_label" : {
+ "comment" : "Add Income Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add Income"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar ingreso"
+ }
+ }
+ }
+ },
+ "all_achievements_unlocked_label" : {
+ "comment" : "Shown when nothing is left to unlock",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Every achievement unlocked"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Todos los logros desbloqueados"
+ }
+ }
+ }
+ },
+ "ALMOST THERE" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "YA CASI"
+ }
+ }
+ }
+ },
+ "Amount" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Monto"
+ }
+ }
+ }
+ },
+ "Amount each payday" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Monto cada día de pago"
+ }
+ }
+ }
+ },
+ "amount_placeholder_label" : {
+ "comment" : "Amount Placeholder Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Amount"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Monto"
+ }
+ }
+ }
+ },
+ "app_preferences_title" : {
+ "comment" : "App Preferences Title",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "App Preferences"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preferencias de la app"
+ }
+ }
+ }
+ },
+ "Are you sure you want to delete this expense?" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Seguro que quieres eliminar este gasto?"
+ }
+ }
+ }
+ },
+ "Are you sure you want to delete this goal?" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Seguro que quieres eliminar esta meta?"
+ }
+ }
+ }
+ },
+ "Are you sure you want to delete this income?" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Seguro que quieres eliminar este ingreso?"
+ }
+ }
+ }
+ },
+ "AUTO" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "AUTO"
+ }
+ }
+ }
+ },
+ "Auto-move" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mover al ahorro"
+ }
+ }
+ }
+ },
+ "Auto-move on payday" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mover al ahorro en día de pago"
+ }
+ }
+ }
+ },
+ "Back" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Back"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Atrás"
+ }
+ }
+ }
+ },
+ "Back to home" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Volver a Inicio"
+ }
+ }
+ }
+ },
+ "Badges unlock as you save — no pressure." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Los logros se desbloquean mientras ahorras — sin presión."
+ }
+ }
+ }
+ },
+ "Big saver" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gran ahorro"
+ }
+ }
+ }
+ },
+ "blue_color" : {
+ "comment" : "Blue Color",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Blue"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Azul"
+ }
+ }
+ }
+ },
+ "By" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fecha"
+ }
+ }
+ }
+ },
+ "Can't save yet" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aún no se puede guardar"
+ }
+ }
+ }
+ },
+ "Cancel" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancelar"
+ }
+ }
+ }
+ },
+ "cancel_button" : {
+ "comment" : "Cancel Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cancel"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancelar"
+ }
+ }
+ }
+ },
+ "Category" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Categoría"
+ }
+ }
+ }
+ },
+ "category_placeholder" : {
+ "comment" : "Category Placeholder",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Category"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Categoría"
+ }
+ }
+ }
+ },
+ "category.coffee" : {
+ "comment" : "Expense category chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Coffee"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Café"
+ }
+ }
+ }
+ },
+ "category.food" : {
+ "comment" : "Expense category chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Food"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Comida"
+ }
+ }
+ }
+ },
+ "category.other" : {
+ "comment" : "Expense category / income source chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Other"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Otro"
+ }
+ }
+ }
+ },
+ "category.shopping" : {
+ "comment" : "Expense category chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Shopping"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compras"
+ }
+ }
+ }
+ },
+ "category.transit" : {
+ "comment" : "Expense category chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Transit"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Transporte"
+ }
+ }
+ }
+ },
+ "Century club" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Club de los 100"
+ }
+ }
+ }
+ },
+ "Clear name" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Clear name"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar nombre"
+ }
+ }
+ }
+ },
+ "Close" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Close"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar"
+ }
+ }
+ }
+ },
+ "Color" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Color"
+ }
+ }
+ }
+ },
+ "COLOR" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "COLOR"
+ }
+ }
+ }
+ },
+ "color_label" : {
+ "comment" : "Color Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Color"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Color"
+ }
+ }
+ }
+ },
+ "common.add" : {
+ "comment" : "Accessibility label of the + tab bar button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Add"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar"
+ }
+ }
+ }
+ },
+ "common.back" : {
+ "comment" : "Accessibility label of a back chevron",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Back"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Atrás"
+ }
+ }
+ }
+ },
+ "common.close" : {
+ "comment" : "Accessibility label of a close button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Close"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar"
+ }
+ }
+ }
+ },
+ "common.keypad.decimal" : {
+ "comment" : "Accessibility label of the keypad decimal key",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Decimal point"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Punto decimal"
+ }
+ }
+ }
+ },
+ "common.keypad.delete" : {
+ "comment" : "Accessibility label of the keypad backspace key",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Delete"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Borrar"
+ }
+ }
+ }
+ },
+ "common.noDate" : {
+ "comment" : "Shown where a goal has no target date",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "No date"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sin fecha"
+ }
+ }
+ }
+ },
+ "common.today" : {
+ "comment" : "Relative day label in lists",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Today"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hoy"
+ }
+ }
+ }
+ },
+ "common.yesterday" : {
+ "comment" : "Relative day label in lists",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Yesterday"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ayer"
+ }
+ }
+ }
+ },
+ "Complete a full goal" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Completa una meta"
+ }
+ }
+ }
+ },
+ "Completed" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Completadas"
+ }
+ }
+ }
+ },
+ "Continue" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuar"
+ }
+ }
+ }
+ },
+ "continue_button" : {
+ "comment" : "Continue Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Continue"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuar"
+ }
+ }
+ }
+ },
+ "Couldn't choose a new favorite goal. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo elegir una nueva meta favorita. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't delete the expense. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo eliminar el gasto. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't delete the goal. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo eliminar la meta. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't delete the income. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo eliminar el ingreso. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't get a tip right now. Please try again later." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo obtener un consejo ahora. Inténtalo más tarde."
+ }
+ }
+ }
+ },
+ "Couldn't load your expenses. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudieron cargar tus gastos. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't load your goals. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudieron cargar tus metas. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't load your income. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudieron cargar tus ingresos. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't save the deposit" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo guardar el depósito"
+ }
+ }
+ }
+ },
+ "Couldn't save the goal" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo guardar la meta"
+ }
+ }
+ }
+ },
+ "Couldn't save the goal. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo guardar la meta. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't save the income. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo guardar el ingreso. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Couldn't update your favorite goal. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo actualizar tu meta favorita. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Create your first goal" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crea tu primera meta"
+ }
+ }
+ }
+ },
+ "create_account_button" : {
+ "comment" : "Create Account Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Create Account"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crear cuenta"
+ }
+ }
+ }
+ },
+ "dark_mode_label" : {
+ "comment" : "Dark Mode Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Dark Mode"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Modo oscuro"
+ }
+ }
+ }
+ },
+ "dashboard_tab_string" : {
+ "comment" : "Dashboard Tab String",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Dashboard"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Panel"
+ }
+ }
+ }
+ },
+ "dashboard.greeting.afternoon" : {
+ "comment" : "Large serif greeting, 12:00–16:59",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Afternoon."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buenas tardes."
+ }
+ }
+ }
+ },
+ "dashboard.greeting.afternoon.spoken" : {
+ "comment" : "VoiceOver form of the afternoon greeting",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Good afternoon."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buenas tardes."
+ }
+ }
+ }
+ },
+ "dashboard.greeting.evening" : {
+ "comment" : "Large serif greeting, 17:00–4:59",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Evening."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buenas noches."
+ }
+ }
+ }
+ },
+ "dashboard.greeting.evening.spoken" : {
+ "comment" : "VoiceOver form of the evening greeting",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Good evening."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buenas noches."
+ }
+ }
+ }
+ },
+ "dashboard.greeting.morning" : {
+ "comment" : "Large serif greeting, 5:00–11:59",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Morning."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buenos días."
+ }
+ }
+ }
+ },
+ "dashboard.greeting.morning.spoken" : {
+ "comment" : "VoiceOver form of the morning greeting",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Good morning."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buenos días."
+ }
+ }
+ }
+ },
+ "dashboard.month.in" : {
+ "comment" : "Monthly income cell label; %@ is the abbreviated month in caps",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "In · %@"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingresos · %@"
+ }
+ }
+ }
+ },
+ "dashboard.month.out" : {
+ "comment" : "Monthly expenses cell label; %@ is the abbreviated month in caps",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Out · %@"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gastos · %@"
+ }
+ }
+ }
+ },
+ "data_privacy_title" : {
+ "comment" : "Section header",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Data & privacy"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Datos y privacidad"
+ }
+ }
+ }
+ },
+ "data_stays_local_label" : {
+ "comment" : "One-line intro of the Data & privacy section",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your data never leaves this iPhone."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tus datos nunca salen de este iPhone."
+ }
+ }
+ }
+ },
+ "Day one" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Día uno"
+ }
+ }
+ }
+ },
+ "Delete" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar"
+ }
+ }
+ }
+ },
+ "Delete Expense" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar gasto"
+ }
+ }
+ }
+ },
+ "Delete Goal" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar meta"
+ }
+ }
+ }
+ },
+ "Delete Income" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar ingreso"
+ }
+ }
+ }
+ },
+ "delete_all_data_confirm_button" : {
+ "comment" : "Destructive confirm button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Delete everything"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar todo"
+ }
+ }
+ }
+ },
+ "delete_all_data_confirm_message" : {
+ "comment" : "Confirmation dialog message",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Every income, expense and goal will be removed from this iPhone. Your name and reminder settings stay. This cannot be undone."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se eliminarán todos los ingresos, gastos y metas de este iPhone. Tu nombre y tus recordatorios se conservan. Esto no se puede deshacer."
+ }
+ }
+ }
+ },
+ "delete_all_data_confirm_title" : {
+ "comment" : "Confirmation dialog title",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Delete all data?"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Eliminar todos los datos?"
+ }
+ }
+ }
+ },
+ "delete_all_data_label" : {
+ "comment" : "Destructive row",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Delete all data"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar todos los datos"
+ }
+ }
+ }
+ },
+ "delete_data_failed_message" : {
+ "comment" : "Alert when deletion fails",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Couldn't delete your data. Please try again."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudieron eliminar tus datos. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "description_placeholder_label" : {
+ "comment" : "Description Placeholder Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Description"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Descripción"
+ }
+ }
+ }
+ },
+ "device_not_connected_to_internet_body" : {
+ "comment" : "Device not connected to internet Body",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your device is offline. Features like Tips may not work, but the app is fully functional offline."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tu dispositivo no tiene conexión. Funciones como Consejos podrían no funcionar, pero la app funciona por completo sin conexión."
+ }
+ }
+ }
+ },
+ "down" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "down"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "abajo"
+ }
+ }
+ }
+ },
+ "Down payment" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enganche"
+ }
+ }
+ }
+ },
+ "download_weekly_report_button" : {
+ "comment" : "Download Weekly Report Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Download Weekly Report"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Descargar reporte semanal"
+ }
+ }
+ }
+ },
+ "e.g. Kyoto, autumn '26" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "p. ej. Kioto, otoño '26"
+ }
+ }
+ }
+ },
+ "Edit anything" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambiar algo"
+ }
+ }
+ }
+ },
+ "Edit goal" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Editar meta"
+ }
+ }
+ }
+ },
+ "email_placeholder_title" : {
+ "comment" : "Email Placeholder Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Email"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Correo electrónico"
+ }
+ }
+ }
+ },
+ "Emergency fund" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fondo de emergencia"
+ }
+ }
+ }
+ },
+ "end_date_label" : {
+ "comment" : "End Date Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "End Date"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fecha de fin"
+ }
+ }
+ }
+ },
+ "Enter an amount greater than zero." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escribe un monto mayor que cero."
+ }
+ }
+ }
+ },
+ "Error saving the PDF file." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo guardar el PDF."
+ }
+ }
+ }
+ },
+ "error_invalid_amount" : {
+ "comment" : "Shown when an amount is empty, not a number, or not positive",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Enter a valid amount greater than zero."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escribe un monto válido mayor que cero."
+ }
+ }
+ }
+ },
+ "error_label" : {
+ "comment" : "Error Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Error"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Error"
+ }
+ }
+ }
+ },
+ "error_save_expense_failed" : {
+ "comment" : "Shown when writing an expense to the local store fails",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "We couldn't save the expense. Please try again."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No pudimos guardar el gasto. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Expense" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expense"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gasto"
+ }
+ }
+ }
+ },
+ "Expense amount" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expense amount"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Monto del gasto"
+ }
+ }
+ }
+ },
+ "Expense Breakdown" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Desglose de gastos"
+ }
+ }
+ }
+ },
+ "Expense Overview" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Resumen de gastos"
+ }
+ }
+ }
+ },
+ "expense_distribution_label" : {
+ "comment" : "Expense Distribution Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expense Distribution"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Distribución de gastos"
+ }
+ }
+ }
+ },
+ "expense_reminder_body" : {
+ "comment" : "Expense Reminder Body",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Don't forget to log your expenses today!"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No olvides registrar tus gastos de hoy."
+ }
+ }
+ }
+ },
+ "expense_reminder_picker_title" : {
+ "comment" : "Select Expense Reminder Time Title",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Select Expense Reminder Time"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige la hora del recordatorio de gastos"
+ }
+ }
+ }
+ },
+ "expense_reminder_title" : {
+ "comment" : "Expense Reminder Title",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expense Reminder"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recordatorio de gastos"
+ }
+ }
+ }
+ },
+ "expense_reminders_label" : {
+ "comment" : "Expense Reminders Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expense Reminders"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recordatorios de gastos"
+ }
+ }
+ }
+ },
+ "Expenses" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gastos"
+ }
+ }
+ }
+ },
+ "expenses_tab_string" : {
+ "comment" : "Expenses Tab String",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expenses"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gastos"
+ }
+ }
+ }
+ },
+ "export_report_button" : {
+ "comment" : "Export Report Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Export Report"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Exportar reporte"
+ }
+ }
+ }
+ },
+ "Eyes open" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ojos abiertos"
+ }
+ }
+ }
+ },
+ "Failed to generate the PDF report." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo generar el reporte en PDF."
+ }
+ }
+ }
+ },
+ "Failed to locate the PDF file." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se encontró el archivo PDF."
+ }
+ }
+ }
+ },
+ "Favorite" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Favorita"
}
}
}
},
- "add_income_button" : {
- "comment" : "Add Income Button",
- "extractionState" : "extracted_with_value",
+ "FAVORITE" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Add Income"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Agregar Ingreso"
+ "value" : "FAVORITA"
}
}
}
},
- "add_income_label" : {
- "comment" : "Add Income Label",
- "extractionState" : "extracted_with_value",
+ "FAVORITE GOAL" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Add Income"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Agregar Ingreso"
+ "value" : "META FAVORITA"
}
}
}
},
- "all_achievements_unlocked_label" : {
- "comment" : "Shown when nothing is left to unlock",
+ "favorites_placeholder" : {
+ "comment" : "Favorites Placeholder",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Every achievement unlocked"
+ "value" : "Favorites"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Todos los logros desbloqueados"
+ "value" : "Favoritas"
}
}
}
},
- "ALMOST THERE" : {
-
- },
- "already_have_and_account_label" : {
- "comment" : "Already have an account Label",
- "extractionState" : "stale",
+ "fetch_report_button" : {
+ "comment" : "Fetch Report Button",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Don't have an account?"
+ "value" : "Fetch Report"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¿No tiene una cuenta?"
+ "value" : "Obtener reporte"
}
}
}
},
- "Amount" : {
-
- },
- "amount_placeholder_label" : {
- "comment" : "Amount Placeholder Label",
+ "filter_button" : {
+ "comment" : "Filter Button",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Amount"
+ "value" : "Filter"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cantidad"
+ "value" : "Filtrar"
}
}
}
},
- "app_preferences_title" : {
- "comment" : "App Preferences Title",
- "extractionState" : "extracted_with_value",
+ "First seed" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "App Preferences"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Preferencias de la Aplicación"
+ "value" : "Primera semilla"
}
}
}
},
- "Are you sure you want to delete this expense?" : {
-
- },
- "Are you sure you want to delete this goal?" : {
-
- },
- "Are you sure you want to delete this income?" : {
-
- },
- "AUTO" : {
-
- },
- "Auto-move on payday" : {
-
- },
- "Back" : {
+ "Full week" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Back"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Atrás"
+ "value" : "Semana completa"
}
}
}
},
- "Back to home" : {
-
- },
- "Badges unlock as you save — no pressure." : {
-
- },
- "blue_color" : {
- "comment" : "Blue Color",
+ "general_placeholder" : {
+ "comment" : "General Placeholder",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Blue"
+ "value" : "General"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Azul"
+ "value" : "General"
}
}
}
},
- "Can't save yet" : {
-
- },
- "Cancel" : {
-
- },
- "cancel_button" : {
- "comment" : "Cancel Button",
- "extractionState" : "extracted_with_value",
+ "Give it a name\nyou'll recognize." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Cancel"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cancelar"
+ "value" : "Ponle un nombre\nque reconozcas."
}
}
}
},
- "Category" : {
-
- },
- "category_placeholder" : {
- "comment" : "Category Placeholder",
- "extractionState" : "extracted_with_value",
+ "Give the goal a name." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Category"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Categoria"
+ "value" : "Ponle un nombre a la meta."
}
}
}
},
- "change_password_button" : {
- "comment" : "Change Password Button",
- "extractionState" : "stale",
+ "Goal" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Change Password"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cambiar Contraseña"
+ "value" : "Meta"
}
}
}
},
- "Clear name" : {
+ "Goal · %@" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Clear name"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Borrar nombre"
+ "value" : "Meta · %@"
}
}
}
},
- "Close" : {
+ "Goal closer" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Close"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cerrar"
+ "value" : "Meta cumplida"
}
}
}
},
- "COLOR" : {
-
- },
- "color_label" : {
- "comment" : "Color Label",
- "extractionState" : "extracted_with_value",
+ "Goal name" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Color"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Color"
+ "value" : "Nombre de la meta"
}
}
}
},
- "Completed" : {
+ "Goal setter" : {
"localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Completadas"
+ "value" : "Primera meta"
}
}
}
},
- "confirm_password_string" : {
- "comment" : "Confirm Password String",
- "extractionState" : "stale",
+ "goal_alert_body" : {
+ "comment" : "Goal Alert Body",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Confirm Password"
+ "value" : "Check your goals and track your progress!"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Confirmar Contraseña"
+ "value" : "Revisa tus metas y sigue tu progreso."
}
}
}
},
- "consistent_savings_description" : {
- "comment" : "Description for consistent monthly savings",
- "extractionState" : "stale",
+ "goal_alert_picker_title" : {
+ "comment" : "Select Goal Alert Time Title",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "You have maintained consistent monthly savings for three months."
+ "value" : "Select Goal Alert Time"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha mantenido ahorros mensuales constantes durante tres meses."
+ "value" : "Elige la hora del aviso de metas"
}
}
}
},
- "consistent_savings_title" : {
- "comment" : "Title for consistent monthly savings",
- "extractionState" : "stale",
+ "goal_alert_title" : {
+ "comment" : "Goal Alert Title",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Consistent Monthly Savings"
+ "value" : "Goal Reminder"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ahorros Mensuales Constantes"
+ "value" : "Recordatorio de metas"
}
}
}
},
- "Continue" : {
-
- },
- "continue_button" : {
- "comment" : "Continue Button",
+ "goal_alerts_label" : {
+ "comment" : "Goal Alerts Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Continue"
+ "value" : "Goal Alerts"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Continuar"
+ "value" : "Avisos de metas"
}
}
}
},
- "Couldn't save the deposit" : {
-
- },
- "Couldn't save the goal" : {
-
- },
- "Create your first goal" : {
-
- },
- "create_account_button" : {
- "comment" : "Create Account Button",
+ "goaldetail.eta" : {
+ "comment" : "Stat pill label: estimated completion date (uppercase, keep short)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Create Account"
+ "value" : "ETA"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Crear una cuenta"
+ "value" : "Fecha est."
}
}
}
},
- "create_account_title" : {
- "comment" : "Create Account Title",
- "extractionState" : "stale",
+ "goaldetail.neededPerWeek" : {
+ "comment" : "Stat pill label (rendered uppercase, keep short)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Create your account!"
+ "value" : "Needed / wk"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¡Crea tú cuenta!"
+ "value" : "Necesario / sem"
}
}
}
},
- "creating_account_label" : {
- "comment" : "Creating Account Label",
- "extractionState" : "stale",
+ "goaldetail.remaining" : {
+ "comment" : "Stat pill label (rendered uppercase, keep short)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "By creating an account, I accept Savely's"
+ "value" : "Remaining"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Al crear una cuenta, acepto los"
+ "value" : "Restante"
}
}
}
},
- "dark_mode_label" : {
- "comment" : "Dark Mode Label",
+ "goaldetail.status" : {
+ "comment" : "Status line: pace status · target date · actual weekly pace (e.g. $40)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Dark Mode"
+ "value" : "%@ · by %@ · %@/wk"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Modo Oscuro"
+ "value" : "%1$@ · para el %2$@ · %3$@/sem"
}
}
}
},
- "dashboard_tab_string" : {
- "comment" : "Dashboard Tab String",
+ "goaldetail.status.noDate" : {
+ "comment" : "Status line; %@ is the pace status (On track / Behind)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Dashboard"
+ "value" : "%@ · no target date"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Panel"
+ "value" : "%@ · sin fecha objetivo"
}
}
}
},
- "data_privacy_title" : {
- "comment" : "Section header",
- "extractionState" : "extracted_with_value",
+ "Goals" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Data & privacy"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Datos y privacidad"
+ "value" : "Metas"
}
}
}
},
- "data_stays_local_label" : {
- "comment" : "One-line intro of the Data & privacy section",
+ "goals_tab_string" : {
+ "comment" : "Goals Tab String",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Your data never leaves this iPhone."
+ "value" : "Goals"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tus datos nunca salen de este iPhone."
+ "value" : "Metas"
}
}
}
},
- "Day one" : {
-
- },
- "debt_paid_off_description" : {
- "comment" : "Description for paying off debt",
- "extractionState" : "stale",
+ "green_color" : {
+ "comment" : "Green Color",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "You have paid off a major debt."
+ "value" : "Green"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha pagado una deuda importante."
+ "value" : "Verde"
}
}
}
},
- "debt_paid_off_title" : {
- "comment" : "Title for paying off debt",
- "extractionState" : "stale",
+ "Half-way there" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Debt Paid Off"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Deuda Saldada"
+ "value" : "A la mitad"
}
}
}
},
- "Decimal point" : {
+ "History" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Decimal point"
+ "state" : "translated",
+ "value" : "Historial"
}
- },
+ }
+ }
+ },
+ "HOW MUCH" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Punto decimal"
+ "value" : "CUÁNTO"
}
}
}
},
- "Delete" : {
+ "Income" : {
"localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Eliminar"
+ "value" : "Ingreso"
}
}
}
},
- "Delete Expense" : {
-
- },
- "Delete Goal" : {
-
- },
- "Delete Income" : {
-
- },
- "delete_all_data_confirm_button" : {
- "comment" : "Destructive confirm button",
- "extractionState" : "extracted_with_value",
+ "Income amount" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Delete everything"
+ "value" : "Income amount"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Borrar todo"
+ "value" : "Monto del ingreso"
+ }
+ }
+ }
+ },
+ "Income Breakdown" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Desglose de ingresos"
}
}
}
},
- "delete_all_data_confirm_message" : {
- "comment" : "Confirmation dialog message",
- "extractionState" : "extracted_with_value",
+ "Income Overview" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Every income, expense and goal will be removed from this iPhone. Your name and reminder settings stay. This cannot be undone."
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Se eliminarán todos los ingresos, gastos y metas de este iPhone. Tu nombre y tus recordatorios se conservan. Esto no se puede deshacer."
+ "value" : "Resumen de ingresos"
}
}
}
},
- "delete_all_data_confirm_title" : {
- "comment" : "Confirmation dialog title",
+ "income_distribution_label" : {
+ "comment" : "Income Distribution Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Delete all data?"
+ "value" : "Income Distribution"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¿Borrar todos los datos?"
+ "value" : "Distribución de ingresos"
}
}
}
},
- "delete_all_data_label" : {
- "comment" : "Destructive row",
+ "incomes_tab_string" : {
+ "comment" : "Incomes Tab String",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Delete all data"
+ "value" : "Incomes"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Borrar todos los datos"
+ "value" : "Ingresos"
}
}
}
},
- "delete_data_failed_message" : {
- "comment" : "Alert when deletion fails",
+ "is_it_rigth_label" : {
+ "comment" : "Is It Ritght Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Couldn't delete your data. Please try again."
+ "value" : "Is it right?"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No se pudieron borrar tus datos. Inténtalo de nuevo."
+ "value" : "¿Está bien?"
}
}
}
},
- "description_placeholder_label" : {
- "comment" : "Description Placeholder Label",
+ "just_started_label" : {
+ "comment" : "Subtitle under the display name when nothing is logged yet",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Description"
+ "value" : "Just getting started"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Descripción"
+ "value" : "Apenas empezando"
}
}
}
},
- "device_not_connected_to_internet_body" : {
- "comment" : "Device not connected to internet Body",
- "extractionState" : "extracted_with_value",
+ "Keep a 30-day streak" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Your device is offline. Features like Tips may not work, but the app is fully functional offline."
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Su dispositivo está desconectado. Funciones como Consejos pueden no funcionar, pero la aplicación sigue siendo completamente funcional sin conexión."
+ "value" : "Mantén una racha de 30 días"
}
}
}
},
- "down" : {
+ "Keep a 100-day streak" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "down"
+ "state" : "translated",
+ "value" : "Mantén una racha de 100 días"
}
- },
+ }
+ }
+ },
+ "LIFETIME INCOME" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "abajo"
+ "value" : "INGRESOS TOTALES"
}
}
}
},
- "download_weekly_report_button" : {
- "comment" : "Download Weekly Report Button",
+ "limited_connection_header" : {
+ "comment" : "Limited Connection Header",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Download Weekly Report"
+ "value" : "Limited Connectivity"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Descargar Reporte Semanal"
+ "value" : "Conexión limitada"
}
}
}
},
- "e.g. Kyoto, autumn '26" : {
-
- },
- "Edit anything" : {
-
- },
- "Edit goal" : {
+ "Live preview" : {
"localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Editar meta"
+ "value" : "Vista previa"
}
}
}
},
- "email_placeholder_title" : {
- "comment" : "Email Placeholder Label",
+ "loading_tip_label" : {
+ "comment" : "Loading Tip Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Email"
+ "value" : "Loading tip..."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Correo Electrónico"
+ "value" : "Cargando consejo…"
}
}
}
},
- "emergency_fund_description" : {
- "comment" : "Description for establishing an emergency fund",
- "extractionState" : "stale",
+ "Log $1,000 of income in total" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "You have created an emergency fund worth three months of expenses."
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha creado un fondo de emergencia equivalente a tres meses de gastos."
+ "value" : "Registra $1,000 de ingresos en total"
}
}
}
},
- "emergency_fund_title" : {
- "comment" : "Title for establishing an emergency fund",
- "extractionState" : "stale",
+ "Log $10,000 of income in total" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Emergency Fund Established"
+ "state" : "translated",
+ "value" : "Registra $10,000 de ingresos en total"
}
- },
+ }
+ }
+ },
+ "Log something 7 days in a row" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Fondo de Emergencia Establecido"
+ "value" : "Registra algo 7 días seguidos"
}
}
}
},
- "end_date_label" : {
- "comment" : "End Date Label",
- "extractionState" : "extracted_with_value",
+ "Log your first expense" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "End Date"
+ "state" : "translated",
+ "value" : "Registra tu primer gasto"
}
- },
+ }
+ }
+ },
+ "Log your first income" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Fecha de Finalización"
+ "value" : "Registra tu primer ingreso"
}
}
}
},
- "enter_email_and_password_label" : {
- "comment" : "Enter your email and password to sign in Label",
- "extractionState" : "stale",
+ "Long press to delete" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Enter your email and password to sign in."
+ "value" : "Long press to delete"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingrese su correo electrónico y contraseña para iniciar sesión."
+ "value" : "Mantén presionado para eliminar"
}
}
}
},
- "error_invalid_amount" : {
- "comment" : "Shown when an amount is empty, not a number, or not positive",
- "extractionState" : "extracted_with_value",
+ "Make favorite" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Enter a valid amount greater than zero."
+ "value" : "Make favorite"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Escribe un monto válido mayor que cero."
+ "value" : "Marcar como favorita"
}
}
}
},
- "error_label" : {
- "comment" : "Error Label",
- "extractionState" : "extracted_with_value",
+ "Merchant · Today" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Error"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Error"
+ "value" : "Comercio · Hoy"
}
}
}
},
- "error_save_expense_failed" : {
- "comment" : "Shown when writing an expense to the local store fails",
- "extractionState" : "extracted_with_value",
+ "minus %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "We couldn't save the expense. Please try again."
+ "value" : "minus %@"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No pudimos guardar el gasto. Inténtalo de nuevo."
+ "value" : "menos %@"
}
}
}
},
- "Expense" : {
+ "money.income.title" : {
+ "comment" : "Money tab: income segment and list title (plural in Spanish, like Expenses)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Expense"
+ "value" : "Income"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Gasto"
+ "value" : "Ingresos"
}
}
}
},
- "Expense amount" : {
+ "Month over month" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Expense amount"
+ "value" : "Month over month"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Monto del gasto"
+ "value" : "Mes contra mes"
}
}
}
},
- "expense_distribution_label" : {
- "comment" : "Expense Distribution Label",
+ "monthly_goal_label" : {
+ "comment" : "Monthly Goal Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Expense Distribution"
+ "value" : "Monthly Goal"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Distribución de Gastos"
+ "value" : "Meta mensual"
}
}
}
},
- "expense_reminder_body" : {
- "comment" : "Expense Reminder Body",
+ "monthly_summary_placeholder" : {
+ "comment" : "Monthly Summary Placeholder",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Don't forget to log your expenses today!"
+ "value" : "Monthly Summary"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¡No olvide registrar sus gastos hoy!"
+ "value" : "Resumen mensual"
}
}
}
},
- "expense_reminder_picker_title" : {
- "comment" : "Select Expense Reminder Time Title",
+ "months_placeholder" : {
+ "comment" : "Months Placeholder",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Select Expense Reminder Time"
+ "value" : "Months"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Seleccione la Hora del Recordatorio de Gastos"
+ "value" : "Meses"
}
}
}
},
- "expense_reminder_title" : {
- "comment" : "Expense Reminder Title",
- "extractionState" : "extracted_with_value",
+ "Move money toward %@." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Expense Reminder"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Recordatorio de Gastos"
+ "value" : "Mueve dinero hacia %@."
}
}
}
},
- "expense_reminders_label" : {
- "comment" : "Expense Reminders Label",
- "extractionState" : "extracted_with_value",
+ "Name" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Expense Reminders"
+ "state" : "translated",
+ "value" : "Nombre"
}
- },
+ }
+ }
+ },
+ "NAME" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Recordatorios de Gastos"
+ "value" : "NOMBRE"
}
}
}
},
- "Expenses" : {
-
- },
- "expenses_tab_string" : {
- "comment" : "Expenses Tab String",
+ "name_placeholder_label" : {
+ "comment" : "Name Placeholder Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Expenses"
+ "value" : "Name"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Gastos"
+ "value" : "Nombre"
}
}
}
},
- "export_report_button" : {
- "comment" : "Export Report Button",
- "extractionState" : "extracted_with_value",
+ "New" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Export Report"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Exportar Reporte"
+ "value" : "Nueva"
}
}
}
},
- "FAVORITE" : {
-
- },
- "FAVORITE GOAL" : {
-
- },
- "favorites_placeholder" : {
- "comment" : "Favorites Placeholder",
- "extractionState" : "extracted_with_value",
+ "New goal" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Favorites"
+ "value" : "New goal"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Favoritos"
+ "value" : "Nueva meta"
}
}
}
},
- "fetch_report_button" : {
- "comment" : "Fetch Report Button",
- "extractionState" : "extracted_with_value",
+ "New goal · " : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Fetch Report"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Obtener Reporte"
+ "value" : "Nueva meta · "
}
}
}
},
- "filter_button" : {
- "comment" : "Filter Button",
- "extractionState" : "extracted_with_value",
+ "Next month" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Filter"
+ "value" : "Next month"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Filtrar"
+ "value" : "Mes siguiente"
}
}
}
},
- "first_goal_description" : {
- "comment" : "Description for achieving the first goal",
- "extractionState" : "stale",
+ "next_achievement_label" : {
+ "comment" : "Kicker above the closest locked achievement",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "You have reached your first savings goal."
+ "value" : "Next up"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha alcanzado su primera meta de ahorros."
+ "value" : "Siguiente"
}
}
}
},
- "first_goal_title" : {
- "comment" : "Title for achieving the first goal",
- "extractionState" : "stale",
+ "next_button" : {
+ "comment" : "Next Button",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "First Goal Achieved"
+ "value" : "Next"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Primera Meta Alcanzada"
+ "value" : "Siguiente"
}
}
}
},
- "first_investment_description" : {
- "comment" : "Description for making the first investment",
- "extractionState" : "stale",
+ "No active goal" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "You have made your first investment towards your financial future."
+ "state" : "translated",
+ "value" : "Sin meta activa"
}
- },
+ }
+ }
+ },
+ "No data available to generate the report." : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha realizado su primera inversión para su futuro financiero."
+ "value" : "No hay datos para generar el reporte."
}
}
}
},
- "first_investment_title" : {
- "comment" : "Title for making the first investment",
- "extractionState" : "stale",
+ "no date" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "First Investment Made"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Primera Inversión Realizada"
+ "value" : "sin fecha"
}
}
}
},
- "forgot_password_label" : {
- "comment" : "Forgot Password Label",
- "extractionState" : "stale",
+ "No date" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Forgot password?"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¿Olvidó su contraseña?"
+ "value" : "Sin fecha"
}
}
}
},
- "full_name_placeholder" : {
- "comment" : "Full Name Placeholder",
- "extractionState" : "stale",
+ "No deposits yet — the first one lands here." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Full Name"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nombre Completo"
+ "value" : "Aún no hay depósitos — el primero aparecerá aquí."
}
}
}
},
- "general_placeholder" : {
- "comment" : "General Placeholder",
- "extractionState" : "extracted_with_value",
+ "No expenses yet" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "General"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "General"
+ "value" : "Aún no hay gastos"
}
}
}
},
- "Give it a name\nyou'll recognize." : {
-
- },
- "Goal" : {
-
- },
- "Goal · %@" : {
-
- },
- "Goal name" : {
-
- },
- "goal_alert_body" : {
- "comment" : "Goal Alert Body",
- "extractionState" : "extracted_with_value",
+ "No goals yet — create one first." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Check your goals and track your progress!"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¡Revise sus metas y siga su progreso!"
+ "value" : "Aún no hay metas — primero crea una."
}
}
}
},
- "goal_alert_picker_title" : {
- "comment" : "Select Goal Alert Time Title",
- "extractionState" : "extracted_with_value",
+ "No income yet" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Select Goal Alert Time"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Seleccione la Hora de Alerta de Metas"
+ "value" : "Aún no hay ingresos"
}
}
}
},
- "goal_alert_title" : {
- "comment" : "Goal Alert Title",
- "extractionState" : "extracted_with_value",
+ "No target date — save at your own pace." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Goal Reminder"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Recordatorio de Metas"
+ "value" : "Sin fecha objetivo — ahorra a tu ritmo."
}
}
}
},
- "goal_alerts_label" : {
- "comment" : "Goal Alerts Label",
- "extractionState" : "extracted_with_value",
+ "No tip for today yet — check back soon." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Goal Alerts"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Alertas de Metas"
+ "value" : "Aún no hay consejo para hoy — vuelve pronto."
}
}
}
},
- "Goals" : {
-
- },
- "goals_tab_string" : {
- "comment" : "Goals Tab String",
- "extractionState" : "extracted_with_value",
+ "No transactions yet" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Goals"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Metas"
+ "value" : "Aún no hay movimientos"
}
}
}
},
- "green_color" : {
- "comment" : "Green Color",
+ "no_button" : {
+ "comment" : "No Button",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Green"
+ "value" : "No"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Verde"
+ "value" : "No"
}
}
}
},
- "high_savings_rate_description" : {
- "comment" : "Description for achieving a high savings rate",
- "extractionState" : "stale",
+ "no_data_label" : {
+ "comment" : "No Data Label",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "You have saved more than 20% of your income this month."
+ "value" : "No data available for the selected dates."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha ahorrado más del 20% de sus ingresos este mes."
+ "value" : "No hay datos disponibles para las fechas seleccionadas."
}
}
}
},
- "high_savings_rate_title" : {
- "comment" : "Title for achieving a high savings rate",
- "extractionState" : "stale",
+ "no_goal_recently" : {
+ "comment" : "No Goal Recently Label",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "High Savings Rate"
+ "value" : "No goals recently"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Alta Tasa de Ahorro"
+ "value" : "Sin metas recientes"
}
}
}
},
- "History" : {
+ "NOTE" : {
"localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Historial"
+ "value" : "NOTA"
}
}
}
},
- "HOW MUCH" : {
-
- },
- "Income" : {
+ "Nothing here yet — let's plant the first one." : {
"localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingreso"
+ "value" : "Aún no hay nada — plantemos la primera."
}
}
}
},
- "Income amount" : {
+ "notice_label" : {
+ "comment" : "Notice Label",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Income amount"
+ "value" : "Notice"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Monto del ingreso"
+ "value" : "Aviso"
}
}
}
},
- "income_distribution_label" : {
- "comment" : "Income Distribution Label",
+ "notification_settings_description" : {
+ "comment" : "Notification Settings Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Income Distribution"
+ "value" : "Choose the times to receive alerts for expenses and goals."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Distribución de Ingresos"
+ "value" : "Elige a qué hora quieres recibir avisos de gastos y metas."
}
}
}
},
- "incomes_tab_string" : {
- "comment" : "Incomes Tab String",
+ "notification_settings_title" : {
+ "comment" : "Notification Settings Title",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Incomes"
+ "value" : "Notification Settings"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingresos"
+ "value" : "Configuración de notificaciones"
}
}
}
},
- "is_it_rigth_label" : {
- "comment" : "Is It Ritght Label",
+ "notifications_denied_hint" : {
+ "comment" : "Shown instead of the reminder switches when permission is denied",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Is it right?"
+ "value" : "Notifications are off for Savely in iOS Settings."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¿Es correcto?"
+ "value" : "Las notificaciones de Savely están desactivadas en Ajustes de iOS."
}
}
}
},
- "just_started_label" : {
- "comment" : "Subtitle under the display name when nothing is logged yet",
+ "notifications_title" : {
+ "comment" : "Notifications Title",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Just getting started"
+ "value" : "Notifications"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Apenas empezando"
+ "value" : "Notificaciones"
}
}
}
},
- "LIFETIME INCOME" : {
-
- },
- "limited_connection_header" : {
- "comment" : "Limited Connection Header",
- "extractionState" : "extracted_with_value",
+ "of %@" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Limited Connectivity"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Conectividad Limitada"
+ "value" : "de %@"
}
}
}
},
- "Live preview" : {
-
- },
- "loading_tip_label" : {
- "comment" : "Loading Tip Label",
- "extractionState" : "extracted_with_value",
+ "of $%lld" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Loading tip..."
+ "state" : "translated",
+ "value" : "de $%lld"
}
- },
+ }
+ }
+ },
+ "OK" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cargando consejo..."
+ "value" : "OK"
}
}
}
},
- "Log an expense, income, deposit, or new goal" : {
+ "ok_button" : {
+ "comment" : "OK Button",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Log an expense, income, deposit, or new goal"
+ "value" : "OK"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registra un gasto, un ingreso, un depósito o una meta nueva"
+ "value" : "OK"
}
}
}
},
- "Log expense" : {
-
- },
- "Log income" : {
-
- },
- "log_in_string" : {
- "comment" : "Log In String",
- "extractionState" : "stale",
+ "On — $%lld each payday" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Log in to your existant account"
+ "state" : "translated",
+ "value" : "Activo — $%lld cada día de pago"
}
- },
+ }
+ }
+ },
+ "On — amount set later" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Inicie sesión en su cuenta existente"
+ "value" : "Activo — monto por definir"
}
}
}
},
- "Long press to delete" : {
+ "onboarding_welcome_label" : {
+ "comment" : "Onboarding welcome step description (privacy)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Long press to delete"
+ "value" : "No accounts, no cloud. Your money data lives on your iPhone and never leaves it."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mantén presionado para eliminar"
+ "value" : "Sin cuentas ni nube. Tus datos de dinero viven en tu iPhone y nunca salen de él."
}
}
}
},
- "Make favorite" : {
+ "onboarding_welcome_title" : {
+ "comment" : "Onboarding welcome step title (privacy)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Make favorite"
+ "value" : "Yours, and only yours"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Marcar como favorita"
+ "value" : "Tuyo, y solo tuyo"
}
}
}
},
- "Merchant · Today" : {
-
- },
- "minus %@" : {
+ "open_settings_button" : {
+ "comment" : "Button that opens the iOS Settings app",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "minus %@"
+ "value" : "Open Settings"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "menos %@"
+ "value" : "Abrir Ajustes"
}
}
}
},
- "Month over month" : {
+ "Optional note" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Month over month"
+ "state" : "translated",
+ "value" : "Nota opcional"
}
- },
+ }
+ }
+ },
+ "PACE" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mes contra mes"
+ "value" : "RITMO"
}
}
}
},
- "monthly_goal_label" : {
- "comment" : "Monthly Goal Label",
+ "pace.behind" : {
+ "comment" : "Goal pace status (the goal is feminine in es: Atrasada)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Monthly Goal"
+ "value" : "Behind"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Meta Mensual"
+ "value" : "Atrasada"
}
}
}
},
- "monthly_summary_placeholder" : {
- "comment" : "Monthly Summary Placeholder",
+ "pace.complete" : {
+ "comment" : "Goal pace status when the goal is funded",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Monthly Summary"
+ "value" : "Complete!"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Resumen Mensual"
+ "value" : "Completa"
}
}
}
},
- "months_placeholder" : {
- "comment" : "Months Placeholder",
+ "pace.eta.done" : {
+ "comment" : "ETA value when the goal is complete",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Months"
+ "value" : "Done!"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Meses"
+ "value" : "Lista"
}
}
}
- },
- "Move money" : {
-
- },
- "Move money toward %@." : {
-
- },
- "NAME" : {
-
- },
- "name_placeholder_label" : {
- "comment" : "Name Placeholder Label",
+ },
+ "pace.eta.overAYear" : {
+ "comment" : "ETA value when the projection is more than a year out",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Name"
+ "value" : "1+ year"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nombre"
+ "value" : "1+ año"
}
}
}
},
- "New" : {
-
- },
- "New goal" : {
+ "pace.onTrack" : {
+ "comment" : "Goal pace status (the goal is feminine in es: a tiempo)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "New goal"
+ "value" : "On track"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nueva meta"
+ "value" : "A tiempo"
}
}
}
},
- "New goal · " : {
-
+ "Payday" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Día de pago"
+ }
+ }
+ }
},
- "Next month" : {
+ "Per month" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Next month"
+ "state" : "translated",
+ "value" : "Por mes"
}
- },
+ }
+ }
+ },
+ "Per week" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mes siguiente"
+ "value" : "Por semana"
}
}
}
},
- "next_achievement_label" : {
- "comment" : "Kicker above the closest locked achievement",
+ "personal_information_title" : {
+ "comment" : "Personal Information Title",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Next up"
+ "value" : "Personal Information"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Siguiente"
+ "value" : "Información personal"
}
}
}
},
- "next_button" : {
- "comment" : "Next Button",
+ "personalized_placeholder" : {
+ "comment" : "Personalized Placeholder",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Next"
+ "value" : "Personalized"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Siguiente"
+ "value" : "Personalizados"
}
}
}
},
- "No active goal" : {
-
- },
- "no date" : {
-
- },
- "No deposits yet — the first one lands here." : {
-
- },
- "No expenses yet" : {
-
- },
- "No goals yet — create one first." : {
-
- },
- "No income yet" : {
-
- },
- "No target date — save at your own pace." : {
-
- },
- "No tip for today yet — check back soon." : {
-
- },
- "No transactions yet" : {
-
+ "Pick a date in the future, or turn the date off." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige una fecha futura o desactiva la fecha."
+ }
+ }
+ }
},
- "no_button" : {
- "comment" : "No Button",
- "extractionState" : "extracted_with_value",
+ "Pick the amount later in the goal's settings." : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "No"
+ "state" : "translated",
+ "value" : "Elige el monto después en los ajustes de la meta."
}
- },
+ }
+ }
+ },
+ "Pick what to log right now." : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No"
+ "value" : "Elige qué registrar ahora."
}
}
}
},
- "no_data_label" : {
- "comment" : "No Data Label",
+ "pick_date_label" : {
+ "comment" : "Pick Date Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "No data available for the selected dates."
+ "value" : "Pick Date"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No hay datos disponibles para las fechas seleccionadas."
+ "value" : "Elige una fecha"
}
}
}
},
- "no_goal_recently" : {
- "comment" : "No Goal Recently Label",
+ "pick_time_period" : {
+ "comment" : "Pick Time Period Label",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "No goals recently"
+ "value" : "Pick Time Period"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sin metas recientemente"
+ "value" : "Elige un periodo"
}
}
}
},
- "no_unnecessary_spending_description" : {
- "comment" : "Description for avoiding unnecessary spending",
- "extractionState" : "stale",
+ "Plant goal" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "You have avoided unnecessary spending for a whole month."
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha evitado gastos innecesarios durante un mes entero."
+ "value" : "Plantar meta"
}
}
}
},
- "no_unnecessary_spending_title" : {
- "comment" : "Title for avoiding unnecessary spending",
- "extractionState" : "stale",
+ "Planted." : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "No Unnecessary Spending"
+ "state" : "translated",
+ "value" : "Plantada."
}
- },
+ }
+ }
+ },
+ "Please try again." : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sin Gastos Innecesarios"
+ "value" : "Inténtalo de nuevo."
}
}
}
},
- "NOTE" : {
-
- },
- "Nothing here yet — let's plant the first one." : {
-
- },
- "notice_label" : {
- "comment" : "Notice Label",
+ "please_confirm_value_title" : {
+ "comment" : "Please Confirm Value Title",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Notice"
+ "value" : "Please Confirm Value"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aviso"
+ "value" : "Confirma el valor"
}
}
}
},
- "notification_settings_description" : {
- "comment" : "Notification Settings Description",
- "extractionState" : "extracted_with_value",
+ "plus %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Choose the times to receive alerts for expenses and goals."
+ "value" : "plus %@"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Elija los horarios para recibir alertas sobre gastos y metas."
+ "value" : "más %@"
}
}
}
},
- "notification_settings_title" : {
- "comment" : "Notification Settings Title",
- "extractionState" : "extracted_with_value",
+ "Previous month" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Notification Settings"
+ "value" : "Previous month"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Configuración de Notificaciones"
+ "value" : "Mes anterior"
}
}
}
},
- "notifications_denied_hint" : {
- "comment" : "Shown instead of the reminder switches when permission is denied",
+ "previous_tips_title" : {
+ "comment" : "Previous Tips",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Notifications are off for Savely in iOS Settings."
+ "value" : "Previous Tips"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Las notificaciones de Savely están desactivadas en Ajustes de iOS."
+ "value" : "Consejos anteriores"
}
}
}
},
- "notifications_title" : {
- "comment" : "Notifications Title",
+ "profile_tab_string" : {
+ "comment" : "Profile Tab String",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Notifications"
+ "value" : "Profile"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Notificaciones"
+ "value" : "Perfil"
}
}
}
},
- "of %@" : {
-
- },
- "of $%lld" : {
-
- },
- "OK" : {
-
- },
- "ok_button" : {
- "comment" : "OK Button",
- "extractionState" : "extracted_with_value",
+ "Progress" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "OK"
+ "value" : "Progress"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "OK"
+ "value" : "Progreso"
}
}
}
},
- "onboarding_welcome_label" : {
- "comment" : "Onboarding welcome step description (privacy)",
- "extractionState" : "extracted_with_value",
+ "Progress overall" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "No accounts, no cloud. Your money data lives on your iPhone and never leaves it."
+ "state" : "translated",
+ "value" : "Progreso total"
}
}
}
},
- "onboarding_welcome_title" : {
- "comment" : "Onboarding welcome step title (privacy)",
+ "quickadd.automove.failed" : {
+ "comment" : "Alert when the income saved but the goal deposit did not; %@ is the goal name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Yours, and only yours"
+ "value" : "Income saved, but the auto-move to %@ failed."
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se guardó el ingreso, pero no se pudo mover el dinero a %@."
}
}
}
},
- "open_settings_button" : {
- "comment" : "Button that opens the iOS Settings app",
+ "quickadd.automove.note" : {
+ "comment" : "Shown in goal history for payday auto-move deposits (display only; the stored note stays English)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Open Settings"
+ "value" : "Payday auto-move"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Abrir Ajustes"
+ "value" : "Movido en día de pago"
}
}
}
},
- "Optional note" : {
-
- },
- "other_way_to_connect_label" : {
- "comment" : "Other Way to Connect",
- "extractionState" : "stale",
+ "quickadd.automove.undo" : {
+ "comment" : "Disarms the payday auto-move suggestion (short, uppercase)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Or connect using"
+ "value" : "UNDO"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "O conéctese usando"
+ "value" : "DESHACER"
}
}
}
},
- "PACE" : {
-
- },
- "password_string" : {
- "comment" : "Password String",
- "extractionState" : "stale",
+ "quickadd.automove.yes" : {
+ "comment" : "Arms the payday auto-move suggestion (short, uppercase)",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Password"
+ "value" : "YES"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Contraseña"
+ "value" : "SÍ"
}
}
}
},
- "passwords_dont_match" : {
- "comment" : "Passwords Doesnt Match",
- "extractionState" : "stale",
+ "quickadd.deposit" : {
+ "comment" : "Quick-add action",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Passwords doesn't match"
+ "value" : "Deposit to a goal"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Las contraseñas no coinciden"
+ "value" : "Depositar a una meta"
}
}
}
},
- "personal_information_title" : {
- "comment" : "Personal Information Title",
+ "quickadd.deposit.sub" : {
+ "comment" : "Quick-add action subtitle",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Personal Information"
+ "value" : "Move money toward a goal"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Información Personal"
+ "value" : "Mueve dinero hacia una meta"
}
}
}
},
- "personalized_placeholder" : {
- "comment" : "Personalized Placeholder",
+ "quickadd.logExpense" : {
+ "comment" : "Quick-add action and sheet title",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Personalized"
+ "value" : "Log expense"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Personalizados"
+ "value" : "Registrar gasto"
}
}
}
},
- "Pick the amount later in the goal's settings." : {
-
- },
- "Pick what to log right now." : {
-
- },
- "pick_date_label" : {
- "comment" : "Pick Date Label",
+ "quickadd.logExpense.sub" : {
+ "comment" : "Quick-add action subtitle",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Pick Date"
+ "value" : "Coffee, groceries, anything"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selecciona Fecha"
+ "value" : "Café, súper, lo que sea"
}
}
}
},
- "pick_time_period" : {
- "comment" : "Pick Time Period Label",
+ "quickadd.logIncome" : {
+ "comment" : "Quick-add action and sheet title",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Pick Time Period"
+ "value" : "Log income"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selecciona Periodo de Tiempo"
+ "value" : "Registrar ingreso"
}
}
}
},
- "Plant goal" : {
-
- },
- "Planted." : {
-
- },
- "please_confirm_value_title" : {
- "comment" : "Please Confirm Value Title",
+ "quickadd.logIncome.sub" : {
+ "comment" : "Quick-add action subtitle",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Please Confirm Value"
+ "value" : "Paycheck, gift, side work"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Por favor confirme el valor"
+ "value" : "Sueldo, regalo, trabajo extra"
}
}
}
},
- "plus %@" : {
+ "quickadd.moveMoney" : {
+ "comment" : "Deposit sheet title",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "plus %@"
+ "value" : "Move money"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "más %@"
+ "value" : "Mover dinero"
}
}
}
},
- "Previous month" : {
+ "quickadd.newGoal" : {
+ "comment" : "Quick-add action",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Previous month"
+ "value" : "New goal"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mes anterior"
+ "value" : "Nueva meta"
}
}
}
},
- "previous_tips_title" : {
- "comment" : "Previous Tips",
+ "quickadd.newGoal.sub" : {
+ "comment" : "Quick-add action subtitle",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Previous Tips"
+ "value" : "Start something new"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Consejos Anteriores"
+ "value" : "Empieza algo nuevo"
}
}
}
},
- "profile_tab_string" : {
- "comment" : "Profile Tab String",
+ "quickadd.scan" : {
+ "comment" : "Quick-add action",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Profile"
+ "value" : "Scan a receipt"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Perfil"
+ "value" : "Escanear recibo"
}
}
}
},
- "Progress" : {
+ "quickadd.scan.sub" : {
+ "comment" : "Quick-add action subtitle",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Progress"
+ "value" : "We'll read the total, merchant and date"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Progreso"
+ "value" : "Leemos el total, el comercio y la fecha"
}
}
}
},
- "Progress overall" : {
-
+ "Reach %lld%% on any goal" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Llega al %lld%% en cualquier meta"
+ }
+ }
+ }
},
"receive_tips_label" : {
"comment" : "Receive Tips Label",
@@ -2745,7 +4392,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Obtenga consejos personalizados para mejorar su salud financiera."
+ "value" : "Recibe consejos personalizados para mejorar tu salud financiera."
}
}
}
@@ -2763,13 +4410,20 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Recibir Consejos"
+ "value" : "Recibe consejos"
+ }
+ }
+ }
+ },
+ "Recent" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Recientes"
}
}
}
- },
- "Recent" : {
-
},
"recent_expenses_title" : {
"comment" : "Recent Expenses Title",
@@ -2784,7 +4438,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Gastos Recientes"
+ "value" : "Gastos recientes"
}
}
}
@@ -2802,7 +4456,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingresos Recientes"
+ "value" : "Ingresos recientes"
}
}
}
@@ -2860,7 +4514,14 @@
}
},
"Reports" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reportes"
+ }
+ }
+ }
},
"reports_tab_string" : {
"comment" : "Reports Tab String",
@@ -2881,10 +4542,24 @@
}
},
"Save" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Guardar"
+ }
+ }
+ }
},
"Save with intention" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ahorra con intención"
+ }
+ }
+ }
},
"save_button" : {
"comment" : "Save Button",
@@ -2905,10 +4580,14 @@
}
},
"SAVED" : {
-
- },
- "Savely" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "AHORRADO"
+ }
+ }
+ }
},
"savely_app_title" : {
"comment" : "Savely App Title",
@@ -2928,6 +4607,16 @@
}
}
},
+ "Saver" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Buen ahorro"
+ }
+ }
+ }
+ },
"saving_since_label" : {
"comment" : "Subtitle under the display name; %@ is a month and year",
"extractionState" : "extracted_with_value",
@@ -2977,7 +4666,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Resumen de Ahorros"
+ "value" : "Resumen de ahorros"
}
}
}
@@ -2995,13 +4684,20 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tendencia de Ahorros"
+ "value" : "Tendencia de ahorros"
}
}
}
},
"Scan a receipt" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escanear recibo"
+ }
+ }
+ }
},
"scan_alternatives_label" : {
"comment" : "Label above the alternative amount chips",
@@ -3376,7 +5072,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Escanear Recibo"
+ "value" : "Escanear recibo"
}
}
}
@@ -3408,6 +5104,12 @@
"state" : "new",
"value" : "Point the camera at a receipt and Savely reads the total for you."
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apunta la cámara a un recibo y Savely lee el total por ti."
+ }
}
}
},
@@ -3420,6 +5122,12 @@
"state" : "new",
"value" : "Scan Receipts"
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escanea recibos"
+ }
}
}
},
@@ -3640,10 +5348,24 @@
}
},
"See all" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ver todo"
+ }
+ }
+ }
},
"See all ›" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ver todo ›"
+ }
+ }
+ }
},
"see_previous_tips_label" : {
"comment" : "See Previous Tips Label",
@@ -3664,7 +5386,14 @@
}
},
"Select a goal" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige una meta"
+ }
+ }
+ }
},
"select_tab_placeholder" : {
"comment" : "Select Tab Placeholder",
@@ -3679,7 +5408,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selecciona Pestaña"
+ "value" : "Elige una pestaña"
}
}
}
@@ -3697,13 +5426,30 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Seleccionar Hora"
+ "value" : "Elige la hora"
}
}
}
},
"Set $%lld aside the day after each paycheck." : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aparta $%lld el día después de cada sueldo."
+ }
+ }
+ }
+ },
+ "Set an auto-move amount above $0, or turn auto-move off." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pon un monto mayor que $0 para mover al ahorro, o desactívalo."
+ }
+ }
+ }
},
"set_goals_label" : {
"comment" : "Set Goals Label",
@@ -3718,7 +5464,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Defina sus objetivos financieros y trabaje para alcanzarlos."
+ "value" : "Define tus objetivos financieros y trabaja para alcanzarlos."
}
}
}
@@ -3736,229 +5482,362 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Definir Metas"
+ "value" : "Define tus metas"
+ }
+ }
+ }
+ },
+ "Settings" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajustes"
+ }
+ }
+ }
+ },
+ "Show on home screen" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mostrar en Inicio"
+ }
+ }
+ }
+ },
+ "Skip" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Omitir"
+ }
+ }
+ }
+ },
+ "Source" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Origen"
+ }
+ }
+ }
+ },
+ "Source · Today" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Origen · Hoy"
+ }
+ }
+ }
+ },
+ "Source…" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Origen…"
+ }
+ }
+ }
+ },
+ "source.freelance" : {
+ "comment" : "Income source chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Freelance"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Freelance"
+ }
+ }
+ }
+ },
+ "source.gift" : {
+ "comment" : "Income source chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Gift"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Regalo"
+ }
+ }
+ }
+ },
+ "source.paycheck" : {
+ "comment" : "Income source chip",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Paycheck"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sueldo"
+ }
+ }
+ }
+ },
+ "source.untagged" : {
+ "comment" : "Reports: income logged without a source",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Untagged"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sin etiqueta"
+ }
+ }
+ }
+ },
+ "Star a goal to see it here" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Marca una meta como favorita para verla aquí"
+ }
+ }
+ }
+ },
+ "start_button" : {
+ "comment" : "Start Button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Start"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Empezar"
+ }
+ }
+ }
+ },
+ "start_date_label" : {
+ "comment" : "Start Date Label",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Start Date"
+ }
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fecha de inicio"
}
}
}
},
- "sign_in_string" : {
- "comment" : "Sign In String",
- "extractionState" : "stale",
+ "stat_active_goals_label" : {
+ "comment" : "Stat cell: goals not yet complete",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Sign In"
+ "value" : "Active goals"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Iniciar Sesión"
+ "value" : "Metas activas"
}
}
}
},
- "sign_out_button" : {
- "comment" : "Sign Out Button",
- "extractionState" : "stale",
+ "stat_movements_label" : {
+ "comment" : "Stat cell: number of incomes + expenses logged",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Sign Out"
+ "value" : "Movements"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cerrar Sesión"
+ "value" : "Movimientos"
}
}
}
},
- "sign_up_label" : {
- "comment" : "Sign Up Label",
- "extractionState" : "stale",
+ "stat_saved_label" : {
+ "comment" : "Stat cell: total saved across goals",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Sign Up"
+ "value" : "Saved"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrarse"
+ "value" : "Ahorrado"
}
}
}
},
- "sign_up_to_get_started" : {
- "comment" : "Sign Up To Get Started",
- "extractionState" : "stale",
+ "Steady hand" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Sign up to get started"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Regístrese para comenzar"
+ "value" : "Mano firme"
}
}
}
},
- "Skip" : {
-
- },
- "Source" : {
-
- },
- "Source · Today" : {
-
- },
- "Source…" : {
-
- },
- "Star a goal to see it here" : {
-
- },
- "start_button" : {
- "comment" : "Start Button",
- "extractionState" : "extracted_with_value",
+ "SUGGESTED PACE" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Start"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Iniciar"
+ "value" : "RITMO SUGERIDO"
}
}
}
},
- "start_date_label" : {
- "comment" : "Start Date Label",
+ "tabbar.add.hint" : {
+ "comment" : "Accessibility hint of the + tab bar button",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Start Date"
+ "value" : "Log an expense, income, deposit, or new goal"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Fecha de Inicio"
+ "value" : "Registra un gasto, un ingreso, un depósito o una nueva meta"
}
}
}
},
- "stat_active_goals_label" : {
- "comment" : "Stat cell: goals not yet complete",
+ "tabbar.goals" : {
+ "comment" : "Tab bar item",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Active goals"
+ "value" : "Goals"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Metas activas"
+ "value" : "Metas"
}
}
}
},
- "stat_movements_label" : {
- "comment" : "Stat cell: number of incomes + expenses logged",
+ "tabbar.home" : {
+ "comment" : "Tab bar item",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Movements"
+ "value" : "Home"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Movimientos"
+ "value" : "Inicio"
}
}
}
},
- "stat_saved_label" : {
- "comment" : "Stat cell: total saved across goals",
+ "tabbar.me" : {
+ "comment" : "Tab bar item (profile)",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Saved"
+ "value" : "Me"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ahorrado"
+ "value" : "Perfil"
}
}
}
},
- "subscription_cleanup_description" : {
- "comment" : "Description for canceling unused subscriptions",
- "extractionState" : "stale",
+ "tabbar.money" : {
+ "comment" : "Tab bar item",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "You have canceled unused subscriptions to reduce your monthly expenses."
+ "value" : "Money"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha cancelado suscripciones sin uso para reducir sus gastos mensuales."
+ "value" : "Dinero"
}
}
}
},
- "subscription_cleanup_title" : {
- "comment" : "Title for canceling unused subscriptions",
- "extractionState" : "stale",
+ "Take a look\nbefore we start." : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Subscription Clean-up"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Limpieza de Suscripciones"
+ "value" : "Échale un vistazo\nantes de empezar."
}
}
}
- },
- "SUGGESTED PACE" : {
-
- },
- "Take a look\nbefore we start." : {
-
},
"take_another_photo_label" : {
"comment" : "Take Another Photo Label",
@@ -3978,32 +5857,55 @@
}
}
},
- "Target date" : {
-
- },
- "terms_of_service_label" : {
- "comment" : "Terms of Service Label",
- "extractionState" : "stale",
+ "Target" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Terms of Service"
+ "state" : "translated",
+ "value" : "Objetivo"
}
- },
+ }
+ }
+ },
+ "Target date" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Términos del Servicio"
+ "value" : "Fecha objetivo"
}
}
}
},
"That's **about one less takeout per week**. Doable." : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es **como pedir comida una vez menos por semana**. Se puede."
+ }
+ }
+ }
+ },
+ "The target has to be more than $0." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El objetivo tiene que ser mayor que $0."
+ }
+ }
+ }
},
"This is what you'll see on your dashboard." : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esto es lo que verás en Inicio."
+ }
+ }
+ }
},
"this_month_label" : {
"comment" : "This Month Label",
@@ -4018,7 +5920,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Este Mes"
+ "value" : "Este mes"
}
}
}
@@ -4036,7 +5938,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Este Trimestre"
+ "value" : "Este trimestre"
}
}
}
@@ -4054,52 +5956,50 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Este Año"
+ "value" : "Este año"
}
}
}
},
- "three_goals_description" : {
- "comment" : "Description for achieving three goals",
- "extractionState" : "stale",
+ "Timing" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "You have achieved three different savings goals."
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ha logrado tres metas de ahorro diferentes."
+ "value" : "Plazo"
}
}
}
},
- "three_goals_title" : {
- "comment" : "Title for achieving three goals",
- "extractionState" : "stale",
+ "TIMING" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "Three Goals Achieved"
+ "state" : "translated",
+ "value" : "PLAZO"
}
- },
+ }
+ }
+ },
+ "Tip history" : {
+ "localizations" : {
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tres Metas Alcanzadas"
+ "value" : "Historial de consejos"
}
}
}
- },
- "TIMING" : {
-
},
"TIP OF THE DAY" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CONSEJO DEL DÍA"
+ }
+ }
+ }
},
"tip_of_the_day_title" : {
"comment" : "Tip of the Day Title",
@@ -4114,13 +6014,20 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Consejo del Día"
+ "value" : "Consejo del día"
}
}
}
},
"Tip: be specific. Try a specific name like Kyoto trip '26." : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Consejo: entre más específico, mejor. Prueba algo como Viaje a Kioto '26."
+ }
+ }
+ }
},
"tips_and_suggestion_title" : {
"comment" : "Tips and Suggestions Tittle",
@@ -4135,7 +6042,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Consejos y Sugerencias"
+ "value" : "Consejos y sugerencias"
}
}
}
@@ -4153,41 +6060,36 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Historial de Consejos"
+ "value" : "Historial de consejos"
}
}
}
},
- "title" : {
- "comment" : "Title for loading view",
- "extractionState" : "stale",
+ "To hit **$%lld** by **%@**." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Loading..."
+ "value" : "To hit **$%1$lld** by **%2$@**."
}
},
"es-419" : {
"stringUnit" : {
- "state" : "needs_review",
- "value" : "Logros"
+ "state" : "translated",
+ "value" : "Para llegar a **$%1$lld** para el **%2$@**."
}
}
}
},
- "To hit **$%lld** by **%@**." : {
+ "TO WHICH GOAL" : {
"localizations" : {
- "en" : {
+ "es-419" : {
"stringUnit" : {
- "state" : "new",
- "value" : "To hit **$%1$lld** by **%2$@**."
+ "state" : "translated",
+ "value" : "A QUÉ META"
}
}
}
- },
- "TO WHICH GOAL" : {
-
},
"track_expenses_label" : {
"comment" : "Track Expenses Label",
@@ -4202,7 +6104,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registre y categorice sus gastos para entender sus hábitos de consumo."
+ "value" : "Registra y clasifica tus gastos para entender tus hábitos de consumo."
}
}
}
@@ -4220,7 +6122,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Controle sus Gastos"
+ "value" : "Controla tus gastos"
}
}
}
@@ -4238,7 +6140,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registre sus ingresos para planificar mejor su presupuesto y alcanzar sus metas de ahorro."
+ "value" : "Registra tus ingresos para planear mejor tu presupuesto y alcanzar tus metas de ahorro."
}
}
}
@@ -4256,7 +6158,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Controle sus Ingresos"
+ "value" : "Controla tus ingresos"
}
}
}
@@ -4270,6 +6172,12 @@
"state" : "new",
"value" : "Log income and expenses in seconds and understand where your money goes."
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registra ingresos y gastos en segundos y entiende a dónde va tu dinero."
+ }
}
}
},
@@ -4282,11 +6190,24 @@
"state" : "new",
"value" : "Track Your Money"
}
+ },
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Controla tu dinero"
+ }
}
}
},
- "UNDO" : {
-
+ "Unable to open sharing options." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudieron abrir las opciones para compartir."
+ }
+ }
+ }
},
"Unlocked" : {
"localizations" : {
@@ -4333,25 +6254,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Actualizar Información"
- }
- }
- }
- },
- "view_achievements" : {
- "comment" : "View Achievements Label",
- "extractionState" : "stale",
- "localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "View Achievements"
- }
- },
- "es-419" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Ver Logros"
+ "value" : "Actualizar información"
}
}
}
@@ -4372,38 +6275,22 @@
}
}
},
- "weekly_budget_description" : {
- "comment" : "Description for achieving the weekly budget goal",
- "extractionState" : "stale",
+ "Weekly Financial Report" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "You have successfully stayed within your budget for a whole week."
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Se ha mantenido dentro de su presupuesto durante una semana completa."
+ "value" : "Reporte financiero semanal"
}
}
}
},
- "weekly_budget_title" : {
- "comment" : "Title for achieving the weekly budget goal",
- "extractionState" : "stale",
+ "Weekly PDF report" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Weekly Budget Goal"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Meta Presupuestaria Semanal"
+ "value" : "Reporte semanal en PDF"
}
}
}
@@ -4421,7 +6308,7 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Gastos Semanales"
+ "value" : "Gastos semanales"
}
}
}
@@ -4439,64 +6326,88 @@
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Reporte Semanal"
+ "value" : "Reporte semanal"
}
}
}
},
- "welcome_back_string" : {
- "comment" : "Welcome Back String",
- "extractionState" : "stale",
+ "welcome_header" : {
+ "comment" : "Welcome to Savely Header",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Welcome Back!"
+ "value" : "Welcome to Savely"
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "¡Bienvenido de Vuelta!"
+ "value" : "Te damos la bienvenida a Savely"
}
}
}
},
- "welcome_header" : {
- "comment" : "Welcome to Savely Header",
- "extractionState" : "extracted_with_value",
+ "What are you saving for?" : {
"localizations" : {
- "en" : {
- "stringUnit" : {
- "state" : "new",
- "value" : "Welcome to Savely"
- }
- },
"es-419" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Bienvenido a Savely"
+ "value" : "¿Para qué ahorras?"
}
}
}
- },
- "What are you saving for?" : {
-
},
"What did you buy?" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Qué compraste?"
+ }
+ }
+ }
},
"WHAT YOU'RE SAVING FOR" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PARA QUÉ AHORRAS"
+ }
+ }
+ }
},
"What's the\nfinish line?" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Hasta dónde\nquieres llegar?"
+ }
+ }
+ }
},
"What's the move?" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Qué hacemos?"
+ }
+ }
+ }
},
"When do you\nwant this by?" : {
-
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Para cuándo\nlo quieres?"
+ }
+ }
+ }
},
"yellow_Color" : {
"comment" : "Yellow Color",
@@ -4515,9 +6426,6 @@
}
}
}
- },
- "YES" : {
-
},
"yes_button" : {
"comment" : "Yes Button",
@@ -4536,7 +6444,37 @@
}
}
}
+ },
+ "You've already saved %@ — the target can't be lower than that." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ya ahorraste %@ — el objetivo no puede ser menor."
+ }
+ }
+ }
+ },
+ "Your data isn't ready yet. Please try again." : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tus datos aún no están listos. Inténtalo de nuevo."
+ }
+ }
+ }
+ },
+ "Your goal name" : {
+ "localizations" : {
+ "es-419" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El nombre de tu meta"
+ }
+ }
+ }
}
},
"version" : "1.0"
-}
\ No newline at end of file
+}
diff --git a/Savely/Resources/Strings.swift b/Savely/Resources/Strings.swift
index e4ed747..1143f79 100755
--- a/Savely/Resources/Strings.swift
+++ b/Savely/Resources/Strings.swift
@@ -154,6 +154,8 @@ struct Strings {
"add_income_label",
value: "Add Income",
comment: "Add Income Label")
+ static let title = String(localized: "money.income.title", defaultValue: "Income",
+ comment: "Money tab: income segment and list title (plural in Spanish, like Expenses)")
}
struct ReportsView {
@@ -709,3 +711,177 @@ struct Strings {
comment: "Color Label")
}
}
+
+// MARK: - 1.1 (es-419 localization)
+//
+// Values that reach the UI typed as `String` (model display helpers, arrays
+// of labels, error messages) — `Text(someString)` does not localize, so they
+// resolve here. Keys are dotted `.`; the English value is
+// the `defaultValue`, the es-419 value lives in Localizable.xcstrings.
+
+extension Date.FormatStyle {
+ /// The same style, capitalized for the start of a label — Spanish month
+ /// and weekday names are lowercase mid-sentence ("martes" → "Martes").
+ /// No effect in English.
+ var sentenceCased: Date.FormatStyle {
+ var style = self
+ style.capitalizationContext = .beginningOfSentence
+ return style
+ }
+}
+
+extension Strings {
+ struct Common {
+ static let today = String(localized: "common.today", defaultValue: "Today",
+ comment: "Relative day label in lists")
+ static let yesterday = String(localized: "common.yesterday", defaultValue: "Yesterday",
+ comment: "Relative day label in lists")
+ static let back = String(localized: "common.back", defaultValue: "Back",
+ comment: "Accessibility label of a back chevron")
+ static let close = String(localized: "common.close", defaultValue: "Close",
+ comment: "Accessibility label of a close button")
+ static let add = String(localized: "common.add", defaultValue: "Add",
+ comment: "Accessibility label of the + tab bar button")
+ static let deleteKey = String(localized: "common.keypad.delete", defaultValue: "Delete",
+ comment: "Accessibility label of the keypad backspace key")
+ static let decimalPoint = String(localized: "common.keypad.decimal", defaultValue: "Decimal point",
+ comment: "Accessibility label of the keypad decimal key")
+ static let noDate = String(localized: "common.noDate", defaultValue: "No date",
+ comment: "Shown where a goal has no target date")
+
+ /// "Today", "Yesterday", or the localized medium date ("12 dic 2026").
+ static func relativeDay(_ date: Date, calendar: Calendar = .current) -> String {
+ if calendar.isDateInToday(date) { return today }
+ if calendar.isDateInYesterday(date) { return yesterday }
+ return date.formatted(.dateTime.month(.abbreviated).day().year())
+ }
+ }
+
+ struct TabBar {
+ static let home = String(localized: "tabbar.home", defaultValue: "Home", comment: "Tab bar item")
+ static let goals = String(localized: "tabbar.goals", defaultValue: "Goals", comment: "Tab bar item")
+ static let money = String(localized: "tabbar.money", defaultValue: "Money", comment: "Tab bar item")
+ static let me = String(localized: "tabbar.me", defaultValue: "Me", comment: "Tab bar item (profile)")
+ static let addHint = String(localized: "tabbar.add.hint",
+ defaultValue: "Log an expense, income, deposit, or new goal",
+ comment: "Accessibility hint of the + tab bar button")
+ }
+
+ struct QuickAdd {
+ static let logExpense = String(localized: "quickadd.logExpense", defaultValue: "Log expense",
+ comment: "Quick-add action and sheet title")
+ static let logExpenseSub = String(localized: "quickadd.logExpense.sub",
+ defaultValue: "Coffee, groceries, anything",
+ comment: "Quick-add action subtitle")
+ static let logIncome = String(localized: "quickadd.logIncome", defaultValue: "Log income",
+ comment: "Quick-add action and sheet title")
+ static let logIncomeSub = String(localized: "quickadd.logIncome.sub",
+ defaultValue: "Paycheck, gift, side work",
+ comment: "Quick-add action subtitle")
+ static let depositToGoal = String(localized: "quickadd.deposit", defaultValue: "Deposit to a goal",
+ comment: "Quick-add action")
+ static let depositToGoalSub = String(localized: "quickadd.deposit.sub",
+ defaultValue: "Move money toward a goal",
+ comment: "Quick-add action subtitle")
+ static let scanReceipt = String(localized: "quickadd.scan", defaultValue: "Scan a receipt",
+ comment: "Quick-add action")
+ static let scanReceiptSub = String(localized: "quickadd.scan.sub",
+ defaultValue: "We'll read the total, merchant and date",
+ comment: "Quick-add action subtitle")
+ static let newGoal = String(localized: "quickadd.newGoal", defaultValue: "New goal",
+ comment: "Quick-add action")
+ static let newGoalSub = String(localized: "quickadd.newGoal.sub", defaultValue: "Start something new",
+ comment: "Quick-add action subtitle")
+ static let moveMoney = String(localized: "quickadd.moveMoney", defaultValue: "Move money",
+ comment: "Deposit sheet title")
+ static let yes = String(localized: "quickadd.automove.yes", defaultValue: "YES",
+ comment: "Arms the payday auto-move suggestion (short, uppercase)")
+ static let undo = String(localized: "quickadd.automove.undo", defaultValue: "UNDO",
+ comment: "Disarms the payday auto-move suggestion (short, uppercase)")
+ static let paydayAutoMoveNote = String(localized: "quickadd.automove.note",
+ defaultValue: "Payday auto-move",
+ comment: "Shown in goal history for payday auto-move deposits (display only; the stored note stays English)")
+ static func autoMoveFailed(goal: String) -> String {
+ String(localized: "quickadd.automove.failed",
+ defaultValue: "Income saved, but the auto-move to \(goal) failed.",
+ comment: "Alert when the income saved but the goal deposit did not; %@ is the goal name")
+ }
+ }
+
+ /// Display names only — the stored values are `ExpenseCategory` /
+ /// `IncomeSource` raw values and never go through here.
+ struct Categories {
+ static let coffee = String(localized: "category.coffee", defaultValue: "Coffee", comment: "Expense category chip")
+ static let food = String(localized: "category.food", defaultValue: "Food", comment: "Expense category chip")
+ static let transit = String(localized: "category.transit", defaultValue: "Transit",
+ comment: "Expense category chip")
+ static let shopping = String(localized: "category.shopping", defaultValue: "Shopping",
+ comment: "Expense category chip")
+ static let other = String(localized: "category.other", defaultValue: "Other",
+ comment: "Expense category / income source chip")
+ static let paycheck = String(localized: "source.paycheck", defaultValue: "Paycheck",
+ comment: "Income source chip")
+ static let freelance = String(localized: "source.freelance", defaultValue: "Freelance",
+ comment: "Income source chip")
+ static let gift = String(localized: "source.gift", defaultValue: "Gift", comment: "Income source chip")
+ static let untagged = String(localized: "source.untagged", defaultValue: "Untagged",
+ comment: "Reports: income logged without a source")
+ }
+
+ struct Pace {
+ static let complete = String(localized: "pace.complete", defaultValue: "Complete!",
+ comment: "Goal pace status when the goal is funded")
+ static let onTrack = String(localized: "pace.onTrack", defaultValue: "On track",
+ comment: "Goal pace status (the goal is feminine in es: a tiempo)")
+ static let behind = String(localized: "pace.behind", defaultValue: "Behind",
+ comment: "Goal pace status (the goal is feminine in es: Atrasada)")
+ static let done = String(localized: "pace.eta.done", defaultValue: "Done!",
+ comment: "ETA value when the goal is complete")
+ static let overAYear = String(localized: "pace.eta.overAYear", defaultValue: "1+ year",
+ comment: "ETA value when the projection is more than a year out")
+ }
+
+ struct GoalDetail {
+ static let remaining = String(localized: "goaldetail.remaining", defaultValue: "Remaining",
+ comment: "Stat pill label (rendered uppercase, keep short)")
+ static let neededPerWeek = String(localized: "goaldetail.neededPerWeek", defaultValue: "Needed / wk",
+ comment: "Stat pill label (rendered uppercase, keep short)")
+ static let eta = String(localized: "goaldetail.eta", defaultValue: "ETA",
+ comment: "Stat pill label: estimated completion date (uppercase, keep short)")
+ static func statusNoDate(_ pace: String) -> String {
+ String(localized: "goaldetail.status.noDate", defaultValue: "\(pace) · no target date",
+ comment: "Status line; %@ is the pace status (On track / Behind)")
+ }
+ static func status(_ pace: String, date: String, weekly: String) -> String {
+ String(localized: "goaldetail.status",
+ defaultValue: "\(pace) · by \(date) · \(weekly)/wk",
+ comment: "Status line: pace status · target date · actual weekly pace (e.g. $40)")
+ }
+ }
+
+ struct Dashboard {
+ static let greetingMorning = String(localized: "dashboard.greeting.morning", defaultValue: "Morning.",
+ comment: "Large serif greeting, 5:00–11:59")
+ static let greetingAfternoon = String(localized: "dashboard.greeting.afternoon", defaultValue: "Afternoon.",
+ comment: "Large serif greeting, 12:00–16:59")
+ static let greetingEvening = String(localized: "dashboard.greeting.evening", defaultValue: "Evening.",
+ comment: "Large serif greeting, 17:00–4:59")
+ static let spokenGreetingMorning = String(localized: "dashboard.greeting.morning.spoken",
+ defaultValue: "Good morning.",
+ comment: "VoiceOver form of the morning greeting")
+ static let spokenGreetingAfternoon = String(localized: "dashboard.greeting.afternoon.spoken",
+ defaultValue: "Good afternoon.",
+ comment: "VoiceOver form of the afternoon greeting")
+ static let spokenGreetingEvening = String(localized: "dashboard.greeting.evening.spoken",
+ defaultValue: "Good evening.",
+ comment: "VoiceOver form of the evening greeting")
+ static func monthIn(_ month: String) -> String {
+ String(localized: "dashboard.month.in", defaultValue: "In · \(month)",
+ comment: "Monthly income cell label; %@ is the abbreviated month in caps")
+ }
+ static func monthOut(_ month: String) -> String {
+ String(localized: "dashboard.month.out", defaultValue: "Out · \(month)",
+ comment: "Monthly expenses cell label; %@ is the abbreviated month in caps")
+ }
+ }
+}
diff --git a/Savely/Utilities/AchievementEngine.swift b/Savely/Utilities/AchievementEngine.swift
index fa54a07..1a30666 100644
--- a/Savely/Utilities/AchievementEngine.swift
+++ b/Savely/Utilities/AchievementEngine.swift
@@ -55,34 +55,34 @@ enum AchievementEngine {
}
private static let definitions: [Definition] = [
- Definition(id: "first-income", title: "First seed", subtitle: "Log your first income",
+ Definition(id: "first-income", title: String(localized: "First seed"), subtitle: String(localized: "Log your first income"),
icon: "leaf.fill", tileColor: .warmAmber, tileBackground: .warmAmberSoft,
progress: { input, _ in Double(min(input.incomeCount, 1)) }),
- Definition(id: "first-expense", title: "Eyes open", subtitle: "Log your first expense",
+ Definition(id: "first-expense", title: String(localized: "Eyes open"), subtitle: String(localized: "Log your first expense"),
icon: "checkmark", tileColor: .warmGreen, tileBackground: .warmGreenSoft,
progress: { input, _ in Double(min(input.expenseCount, 1)) }),
- Definition(id: "goal-setter", title: "Goal setter", subtitle: "Create your first goal",
+ Definition(id: "goal-setter", title: String(localized: "Goal setter"), subtitle: String(localized: "Create your first goal"),
icon: "target", tileColor: .warmSky, tileBackground: .warmSkySoft,
progress: { input, _ in Double(min(input.goalCount, 1)) }),
- Definition(id: "half-way", title: "Half-way there", subtitle: "Reach 50% on any goal",
+ Definition(id: "half-way", title: String(localized: "Half-way there"), subtitle: String(localized: "Reach \(50)% on any goal"),
icon: "chart.line.uptrend.xyaxis", tileColor: .warmSky, tileBackground: .warmSkySoft,
progress: { input, _ in input.bestGoalProgress / 0.5 }),
- Definition(id: "goal-closer", title: "Goal closer", subtitle: "Complete a full goal",
+ Definition(id: "goal-closer", title: String(localized: "Goal closer"), subtitle: String(localized: "Complete a full goal"),
icon: "flag.checkered", tileColor: .warmClay, tileBackground: .warmClaySoft,
progress: { input, _ in input.completedGoalCount >= 1 ? 1 : input.bestGoalProgress }),
- Definition(id: "full-week", title: "Full week", subtitle: "Log something 7 days in a row",
+ Definition(id: "full-week", title: String(localized: "Full week"), subtitle: String(localized: "Log something 7 days in a row"),
icon: "calendar", tileColor: .warmGreen, tileBackground: .warmGreenSoft,
progress: { _, streak in Double(streak) / 7 }),
- Definition(id: "steady-hand", title: "Steady hand", subtitle: "Keep a 30-day streak",
+ Definition(id: "steady-hand", title: String(localized: "Steady hand"), subtitle: String(localized: "Keep a 30-day streak"),
icon: "medal.fill", tileColor: .warmClay, tileBackground: .warmClaySoft,
progress: { _, streak in Double(streak) / 30 }),
- Definition(id: "century", title: "Century club", subtitle: "Keep a 100-day streak",
+ Definition(id: "century", title: String(localized: "Century club"), subtitle: String(localized: "Keep a 100-day streak"),
icon: "crown.fill", tileColor: .warmAmber, tileBackground: .warmAmberSoft,
progress: { _, streak in Double(streak) / 100 }),
- Definition(id: "saver", title: "Saver", subtitle: "Log $1,000 of income in total",
+ Definition(id: "saver", title: String(localized: "Saver"), subtitle: String(localized: "Log $1,000 of income in total"),
icon: "banknote", tileColor: .warmGreen, tileBackground: .warmGreenSoft,
progress: { input, _ in input.incomeTotal / 1_000 }),
- Definition(id: "big-saver", title: "Big saver", subtitle: "Log $10,000 of income in total",
+ Definition(id: "big-saver", title: String(localized: "Big saver"), subtitle: String(localized: "Log $10,000 of income in total"),
icon: "arrow.up.circle.fill", tileColor: .warmAmber, tileBackground: .warmAmberSoft,
progress: { input, _ in input.incomeTotal / 10_000 }),
]
diff --git a/Savely/Utilities/GoalDeposits.swift b/Savely/Utilities/GoalDeposits.swift
index 481768a..ffd28d3 100644
--- a/Savely/Utilities/GoalDeposits.swift
+++ b/Savely/Utilities/GoalDeposits.swift
@@ -16,12 +16,17 @@ enum GoalDepositsError: LocalizedError {
var errorDescription: String? {
switch self {
- case .nonPositiveAmount: return "Enter an amount greater than zero."
+ case .nonPositiveAmount: return String(localized: "Enter an amount greater than zero.")
}
}
}
enum GoalDeposits {
+ /// The note written on payday auto-move deposits. It is persisted, so it
+ /// stays this stable English value in every language; the goal history
+ /// shows the localized `Strings.QuickAdd.paydayAutoMoveNote` for it.
+ static let autoMoveNote = "Payday auto-move"
+
/// Adds `amount` to the goal (clamped at its target, exactly as every
/// flow already did), inserts the ledger row, and saves.
///
diff --git a/Savely/Utilities/GoalEditValidation.swift b/Savely/Utilities/GoalEditValidation.swift
index 96891ec..8b00673 100644
--- a/Savely/Utilities/GoalEditValidation.swift
+++ b/Savely/Utilities/GoalEditValidation.swift
@@ -18,17 +18,17 @@ enum GoalEditError: Equatable, LocalizedError {
var errorDescription: String? {
switch self {
case .emptyName:
- return "Give the goal a name."
+ return String(localized: "Give the goal a name.")
case .nonPositiveTarget:
- return "The target has to be more than $0."
+ return String(localized: "The target has to be more than $0.")
case .targetBelowSaved(let saved):
let f = NumberFormatter(); f.numberStyle = .currency; f.currencySymbol = "$"; f.maximumFractionDigits = 0
let savedText = f.string(from: NSNumber(value: saved)) ?? "$0"
- return "You've already saved \(savedText) — the target can't be lower than that."
+ return String(localized: "You've already saved \(savedText) — the target can't be lower than that.")
case .deadlineInPast:
- return "Pick a date in the future, or turn the date off."
+ return String(localized: "Pick a date in the future, or turn the date off.")
case .nonPositiveAutoMove:
- return "Set an auto-move amount above $0, or turn auto-move off."
+ return String(localized: "Set an auto-move amount above $0, or turn auto-move off.")
}
}
}
diff --git a/Savely/Utilities/GoalPace.swift b/Savely/Utilities/GoalPace.swift
index 6a55a0d..ba991db 100644
--- a/Savely/Utilities/GoalPace.swift
+++ b/Savely/Utilities/GoalPace.swift
@@ -92,20 +92,19 @@ struct GoalPace: Equatable {
var label: String {
switch status {
- case .complete: return "Complete!"
- case .onTrack: return "On track"
- case .behind: return "Behind"
+ case .complete: return Strings.Pace.complete
+ case .onTrack: return Strings.Pace.onTrack
+ case .behind: return Strings.Pace.behind
}
}
- /// "Jun 12", "1+ year", or "—". Never a made-up date.
- func etaText(from now: Date = Date(), calendar: Calendar = .current) -> String {
- guard status != .complete else { return "Done!" }
+ /// "Jun 12" / "12 jun", "1+ year", or "—". Never a made-up date.
+ func etaText(from now: Date = Date(), calendar: Calendar = .current, locale: Locale = .current) -> String {
+ guard status != .complete else { return Strings.Pace.done }
guard let etaWeeks else { return "—" }
- if etaWeeks > Self.etaCapWeeks { return "1+ year" }
+ if etaWeeks > Self.etaCapWeeks { return Strings.Pace.overAYear }
let date = calendar.date(byAdding: .day, value: Int((etaWeeks * 7).rounded()), to: now) ?? now
- let f = DateFormatter(); f.dateFormat = "MMM d"
- return f.string(from: date)
+ return date.formatted(.dateTime.month(.abbreviated).day().locale(locale))
}
}
diff --git a/Savely/Utilities/MoneyCategories.swift b/Savely/Utilities/MoneyCategories.swift
index c3b220b..fccf11c 100644
--- a/Savely/Utilities/MoneyCategories.swift
+++ b/Savely/Utilities/MoneyCategories.swift
@@ -20,7 +20,20 @@ enum ExpenseCategory: String, CaseIterable, Identifiable {
case other = "Other"
var id: String { rawValue }
- var label: String { rawValue }
+ /// The stable storage key — what `ExpenseModel.category` holds. Never
+ /// localized: rows written in any language must keep matching.
+ var storageKey: String { rawValue }
+
+ /// What the UI shows. Localized; never written to the store.
+ var displayName: String {
+ switch self {
+ case .coffee: return Strings.Categories.coffee
+ case .food: return Strings.Categories.food
+ case .transit: return Strings.Categories.transit
+ case .shopping: return Strings.Categories.shopping
+ case .other: return Strings.Categories.other
+ }
+ }
var icon: String {
switch self {
@@ -93,7 +106,18 @@ enum IncomeSource: String, CaseIterable, Identifiable {
case other = "Other"
var id: String { rawValue }
- var label: String { rawValue }
+ /// The stable storage key — what `IncomeModel.source` holds. Never localized.
+ var storageKey: String { rawValue }
+
+ /// What the UI shows. Localized; never written to the store.
+ var displayName: String {
+ switch self {
+ case .paycheck: return Strings.Categories.paycheck
+ case .freelance: return Strings.Categories.freelance
+ case .gift: return Strings.Categories.gift
+ case .other: return Strings.Categories.other
+ }
+ }
var icon: String {
switch self {
diff --git a/Savely/Utilities/ReportsPDFGenerator.swift b/Savely/Utilities/ReportsPDFGenerator.swift
index 820667d..ff7a218 100644
--- a/Savely/Utilities/ReportsPDFGenerator.swift
+++ b/Savely/Utilities/ReportsPDFGenerator.swift
@@ -44,7 +44,7 @@ struct ReportsPDFGenerator {
}
// Title
- let title = "Weekly Financial Report"
+ let title = String(localized: "Weekly Financial Report")
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
@@ -59,11 +59,11 @@ struct ReportsPDFGenerator {
private static func addIncomePage(incomes: [IncomeModel], pageWidth: CGFloat, pageHeight: CGFloat) {
UIGraphicsBeginPDFPage()
- drawHeader(title: "Income Overview", yPosition: 20, pageWidth: pageWidth)
+ drawHeader(title: String(localized: "Income Overview"), yPosition: 20, pageWidth: pageWidth)
// Table
drawTable(
- title: "Income Breakdown",
+ title: String(localized: "Income Breakdown"),
data: incomes.map { ($0.incomeDescription, String(format: "$%.2f", $0.amount)) },
startY: 60,
pageWidth: pageWidth
@@ -72,11 +72,11 @@ struct ReportsPDFGenerator {
private static func addExpensePage(expenses: [ExpenseModel], pageWidth: CGFloat, pageHeight: CGFloat) {
UIGraphicsBeginPDFPage()
- drawHeader(title: "Expense Overview", yPosition: 20, pageWidth: pageWidth)
+ drawHeader(title: String(localized: "Expense Overview"), yPosition: 20, pageWidth: pageWidth)
// Table
drawTable(
- title: "Expense Breakdown",
+ title: String(localized: "Expense Breakdown"),
data: expenses.map { ($0.expenseDescription, String(format: "$%.2f", $0.amount)) },
startY: 60,
pageWidth: pageWidth
diff --git a/Savely/ViewModels/Camera/ReceiptScanModel.swift b/Savely/ViewModels/Camera/ReceiptScanModel.swift
index d712d07..7caa20e 100644
--- a/Savely/ViewModels/Camera/ReceiptScanModel.swift
+++ b/Savely/ViewModels/Camera/ReceiptScanModel.swift
@@ -204,7 +204,7 @@ final class ReceiptScanModel: Identifiable {
description: description,
amount: amount,
date: review.date,
- category: review.category.rawValue
+ category: review.category.storageKey
)
guard saved else {
saveErrorMessage = expenseStore.errorMessage
diff --git a/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift b/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift
index d93c639..d5def45 100644
--- a/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift
+++ b/Savely/ViewModels/ExpensesTab/ExpensesTrackerViewModel.swift
@@ -58,7 +58,7 @@ class ExpenseTrackerViewModel: ObservableObject {
expenses = try modelContext.fetch(fetchDescriptor)
} catch {
print("Error fetching expenses: \(error)")
- errorMessage = "Error al obtener los gastos."
+ errorMessage = String(localized: "Couldn't load your expenses. Please try again.")
showError = true
}
}
@@ -132,7 +132,7 @@ class ExpenseTrackerViewModel: ObservableObject {
NotificationCenter.default.post(name: .expenseDeleted, object: nil, userInfo: ["amount": expense.amount])
} catch {
print("Error saving after deleting expense: \(error)")
- errorMessage = "Error al eliminar el gasto."
+ errorMessage = String(localized: "Couldn't delete the expense. Please try again.")
showError = true
}
diff --git a/Savely/ViewModels/GoalsTab/GoalsViewModel.swift b/Savely/ViewModels/GoalsTab/GoalsViewModel.swift
index 8236271..534f947 100644
--- a/Savely/ViewModels/GoalsTab/GoalsViewModel.swift
+++ b/Savely/ViewModels/GoalsTab/GoalsViewModel.swift
@@ -47,7 +47,7 @@ class GoalsViewModel: ObservableObject {
goals = fetchedGoals
} catch {
print("Error fetching goals: \(error)")
- errorMessage = "Error al obtener las metas."
+ errorMessage = String(localized: "Couldn't load your goals. Please try again.")
showError = true
}
}
@@ -55,13 +55,13 @@ class GoalsViewModel: ObservableObject {
func addGoal() {
guard let modelContext = modelContext else {
print("modelContext is nil in addGoal")
- errorMessage = "El contexto del modelo está vacío."
+ errorMessage = String(localized: "Your data isn't ready yet. Please try again.")
showError = true
return
}
guard let target = Double(targetAmount), target > 0 else {
print("Invalid target amount")
- errorMessage = "La cantidad objetivo debe ser un número positivo."
+ errorMessage = String(localized: "The target has to be more than $0.")
showError = true
return
}
@@ -82,7 +82,7 @@ class GoalsViewModel: ObservableObject {
print("New goal saved successfully")
} catch {
print("Error saving new goal: \(error)")
- errorMessage = "Error al guardar la nueva meta."
+ errorMessage = String(localized: "Couldn't save the goal. Please try again.")
showError = true
}
@@ -99,7 +99,7 @@ class GoalsViewModel: ObservableObject {
print("Goal deleted successfully")
} catch {
print("Error saving after deleting goal: \(error)")
- errorMessage = "Error al eliminar la meta."
+ errorMessage = String(localized: "Couldn't delete the goal. Please try again.")
showError = true
}
fetchGoals()
@@ -128,7 +128,7 @@ class GoalsViewModel: ObservableObject {
print("Favorite goal updated successfully")
} catch {
print("Error saving after setting favorite goal: \(error)")
- errorMessage = "Error al actualizar la meta favorita."
+ errorMessage = String(localized: "Couldn't update your favorite goal. Please try again.")
showError = true
}
@@ -145,7 +145,7 @@ class GoalsViewModel: ObservableObject {
print("New favorite goal assigned")
} catch {
print("Error assigning new favorite goal: \(error)")
- errorMessage = "Error al asignar una nueva meta favorita."
+ errorMessage = String(localized: "Couldn't choose a new favorite goal. Please try again.")
showError = true
}
fetchGoals()
diff --git a/Savely/ViewModels/IncomesTab/IncomesTrackerViewModel.swift b/Savely/ViewModels/IncomesTab/IncomesTrackerViewModel.swift
index ff7bbb6..39d6724 100644
--- a/Savely/ViewModels/IncomesTab/IncomesTrackerViewModel.swift
+++ b/Savely/ViewModels/IncomesTab/IncomesTrackerViewModel.swift
@@ -95,7 +95,7 @@ class IncomesTrackerViewModel: ObservableObject {
incomes = try modelContext.fetch(fetchDescriptor)
} catch {
print("Error fetching incomes: \(error)")
- errorMessage = "Error al obtener los ingresos."
+ errorMessage = String(localized: "Couldn't load your income. Please try again.")
showError = true
}
}
@@ -106,7 +106,7 @@ class IncomesTrackerViewModel: ObservableObject {
guard let modelContext = modelContext else { return }
guard let amountValue = parseAmount(amount) else {
// Used to return silently, so a typo just did nothing at all.
- errorMessage = "Enter a valid amount greater than zero."
+ errorMessage = Strings.Errors.invalidAmount
showError = true
return
}
@@ -126,7 +126,7 @@ class IncomesTrackerViewModel: ObservableObject {
NotificationCenter.default.post(name: .incomeAdded, object: nil, userInfo: ["amount": amountValue])
} catch {
print("Error saving new income: \(error)")
- errorMessage = "Error al guardar el ingreso."
+ errorMessage = String(localized: "Couldn't save the income. Please try again.")
showError = true
}
@@ -146,7 +146,7 @@ class IncomesTrackerViewModel: ObservableObject {
NotificationCenter.default.post(name: .incomeDeleted, object: nil, userInfo: ["amount": income.amount])
} catch {
print("Error saving after deleting income: \(error)")
- errorMessage = "Error al eliminar el ingreso."
+ errorMessage = String(localized: "Couldn't delete the income. Please try again.")
showError = true
}
diff --git a/Savely/ViewModels/ProfileTab/ProfileViewModel.swift b/Savely/ViewModels/ProfileTab/ProfileViewModel.swift
index e255bd6..ec62bd0 100644
--- a/Savely/ViewModels/ProfileTab/ProfileViewModel.swift
+++ b/Savely/ViewModels/ProfileTab/ProfileViewModel.swift
@@ -195,7 +195,7 @@ class ProfileViewModel: ObservableObject {
func generateWeeklyReportPDF() {
print("generateWeeklyReportPDF: Checking data availability.")
guard !weeklyIncomes.isEmpty || !weeklyExpenses.isEmpty else {
- alertMessage = "No data available to generate the report."
+ alertMessage = String(localized: "No data available to generate the report.")
showAlert = true
print("generateWeeklyReportPDF: No data available to generate the report.")
return
@@ -203,7 +203,7 @@ class ProfileViewModel: ObservableObject {
guard let pdfData = ReportsPDFGenerator.generateWeeklyReport(incomes: weeklyIncomes, expenses: weeklyExpenses) else {
print("generateWeeklyReportPDF: Failed to generate PDF")
- alertMessage = "Failed to generate the PDF report."
+ alertMessage = String(localized: "Failed to generate the PDF report.")
showAlert = true
return
}
@@ -216,7 +216,7 @@ class ProfileViewModel: ObservableObject {
// Ensure file exists
guard FileManager.default.fileExists(atPath: tempURL.path) else {
print("generateWeeklyReportPDF: File does not exist at \(tempURL)")
- alertMessage = "Failed to locate the PDF file."
+ alertMessage = String(localized: "Failed to locate the PDF file.")
showAlert = true
return
}
@@ -229,13 +229,13 @@ class ProfileViewModel: ObservableObject {
rootViewController.present(activityViewController, animated: true, completion: nil)
} else {
print("generateWeeklyReportPDF: Unable to find rootViewController")
- self.alertMessage = "Unable to open sharing options."
+ self.alertMessage = String(localized: "Unable to open sharing options.")
self.showAlert = true
}
}
} catch {
print("generateWeeklyReportPDF: Error saving PDF: \(error)")
- alertMessage = "Error saving the PDF file."
+ alertMessage = String(localized: "Error saving the PDF file.")
showAlert = true
}
}
diff --git a/Savely/ViewModels/Tips/TipsAndSuggestionViewModel.swift b/Savely/ViewModels/Tips/TipsAndSuggestionViewModel.swift
index 5a9a783..ebc3624 100644
--- a/Savely/ViewModels/Tips/TipsAndSuggestionViewModel.swift
+++ b/Savely/ViewModels/Tips/TipsAndSuggestionViewModel.swift
@@ -115,14 +115,8 @@ class TipsAndSuggestionViewModel: ObservableObject {
print("Error saving new tip: \(error)")
}
} else {
- // Manejo del error o ausencia de tip
- let errorMessage: String
- switch languageCode {
- case "es":
- errorMessage = "No se pudo generar un consejo en este momento. Por favor, inténtalo más tarde."
- default:
- errorMessage = "Could not generate a tip at this time. Please try again later."
- }
+ // No tip came back — say so plainly, in the user's language.
+ let errorMessage = String(localized: "Couldn't get a tip right now. Please try again later.")
self.currentTip = TipModel(date: Date(), content: errorMessage)
}
}
diff --git a/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift b/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift
index 53aed50..4795c90 100644
--- a/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift
+++ b/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift
@@ -239,7 +239,7 @@ struct ReceiptReviewView: View {
let isOn = review.wrappedValue.category == cat
let fg = cat.tileTextColor
Button(action: { review.wrappedValue.category = cat }) {
- Text(cat.label)
+ Text(cat.displayName)
.warmFont(13, weight: .semibold)
.foregroundStyle(isOn ? fg : Color.warmInkSoft)
.padding(.horizontal, 14).padding(.vertical, 8)
diff --git a/Savely/Views/DashboardTab/DashboardView.swift b/Savely/Views/DashboardTab/DashboardView.swift
index e7f90ed..036f78b 100755
--- a/Savely/Views/DashboardTab/DashboardView.swift
+++ b/Savely/Views/DashboardTab/DashboardView.swift
@@ -15,19 +15,40 @@ struct DashboardView: View {
private var favoriteGoal: GoalModel? { goals.first(where: { $0.isFavorite }) }
+ private enum DayPart { case morning, afternoon, evening }
+
+ private var dayPart: DayPart {
+ switch Calendar.current.component(.hour, from: Date()) {
+ case 5..<12: return .morning
+ case 12..<17: return .afternoon
+ default: return .evening
+ }
+ }
+
+ /// The on-screen serif greeting ("Morning." / "Buenos días.").
private var greeting: String {
- let hour = Calendar.current.component(.hour, from: Date())
- switch hour {
- case 5..<12: return "Morning"
- case 12..<17: return "Afternoon"
- default: return "Evening"
+ switch dayPart {
+ case .morning: return Strings.Dashboard.greetingMorning
+ case .afternoon: return Strings.Dashboard.greetingAfternoon
+ case .evening: return Strings.Dashboard.greetingEvening
+ }
+ }
+
+ /// The spoken form — "Good afternoon." rather than the bare "Afternoon."
+ private var spokenGreeting: String {
+ switch dayPart {
+ case .morning: return Strings.Dashboard.spokenGreetingMorning
+ case .afternoon: return Strings.Dashboard.spokenGreetingAfternoon
+ case .evening: return Strings.Dashboard.spokenGreetingEvening
}
}
+ /// "Tuesday · August 18" / "Martes · 18 de agosto" — locale-driven, not a fixed pattern.
private var formattedDate: String {
- let f = DateFormatter()
- f.dateFormat = "EEEE · MMMM d"
- return f.string(from: Date())
+ let now = Date()
+ let weekday = now.formatted(Date.FormatStyle.dateTime.weekday(.wide).sentenceCased)
+ let day = now.formatted(.dateTime.month(.wide).day())
+ return "\(weekday) · \(day)"
}
// Monthly totals (current month)
@@ -58,7 +79,7 @@ struct DashboardView: View {
Text(formattedDate)
.warmFont(13)
.foregroundStyle(Color.warmInkMuted)
- Text(greeting + ".")
+ Text(greeting)
.warmFont(30, weight: .regular, design: .serif)
.foregroundStyle(Color.warmInk)
}
@@ -69,7 +90,7 @@ struct DashboardView: View {
// not human-readable.
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isHeader)
- .accessibilityLabel(Text("Good \(greeting.lowercased()). \(formattedDate)"))
+ .accessibilityLabel(Text(verbatim: "\(spokenGreeting) \(formattedDate)"))
// — Hero Goal Card —
if let goal = favoriteGoal {
@@ -85,8 +106,10 @@ struct DashboardView: View {
// — Monthly summary —
HStack(spacing: 10) {
- MonthlySummaryCell(label: "In · \(currentMonthAbbr)", value: monthlyIncome, change: nil, positive: true)
- MonthlySummaryCell(label: "Out · \(currentMonthAbbr)", value: monthlyExpenses, change: nil, positive: false)
+ MonthlySummaryCell(label: Strings.Dashboard.monthIn(currentMonthAbbr),
+ value: monthlyIncome, change: nil, positive: true)
+ MonthlySummaryCell(label: Strings.Dashboard.monthOut(currentMonthAbbr),
+ value: monthlyExpenses, change: nil, positive: false)
}
// — Recent transactions —
@@ -149,8 +172,9 @@ struct DashboardView: View {
}
}
+ /// "SEP" / "SEPT" — the locale's own abbreviation, shown in a tracked caps label.
private var currentMonthAbbr: String {
- DateFormatter().monthSymbols[Calendar.current.component(.month, from: Date()) - 1].prefix(3).uppercased()
+ Date().formatted(.dateTime.month(.abbreviated)).replacingOccurrences(of: ".", with: "").uppercased()
}
}
@@ -569,10 +593,9 @@ struct RecentTransactionRow: View {
private func shortDate(_ d: Date) -> String {
let cal = Calendar.current
- if cal.isDateInToday(d) { return "Today" }
- if cal.isDateInYesterday(d) { return "Yesterday" }
- let f = DateFormatter(); f.dateFormat = "EEE"
- return f.string(from: d)
+ if cal.isDateInToday(d) { return Strings.Common.today }
+ if cal.isDateInYesterday(d) { return Strings.Common.yesterday }
+ return d.formatted(.dateTime.weekday(.abbreviated))
}
}
diff --git a/Savely/Views/DashboardTab/ReportsView.swift b/Savely/Views/DashboardTab/ReportsView.swift
index e30ab9e..2a16c66 100755
--- a/Savely/Views/DashboardTab/ReportsView.swift
+++ b/Savely/Views/DashboardTab/ReportsView.swift
@@ -26,7 +26,8 @@ struct ReportsView: View {
private var incomeBySource: [Slice] {
var totals: [String: Double] = [:]
for income in viewModel.weeklyIncomes {
- let key = IncomeSource.display(stored: income.source)?.label ?? "Untagged"
+ // Grouped by display name — stored values stay the English raw values.
+ let key = IncomeSource.display(stored: income.source)?.displayName ?? Strings.Categories.untagged
totals[key, default: 0] += income.amount
}
return totals.map { Slice(label: $0.key, amount: $0.value) }.sorted { $0.amount > $1.amount }
@@ -35,7 +36,7 @@ struct ReportsView: View {
private var expensesByCategory: [Slice] {
var totals: [String: Double] = [:]
for expense in viewModel.weeklyExpenses {
- let key = ExpenseCategory.display(stored: expense.category, description: expense.expenseDescription).label
+ let key = ExpenseCategory.display(stored: expense.category, description: expense.expenseDescription).displayName
totals[key, default: 0] += expense.amount
}
return totals.map { Slice(label: $0.key, amount: $0.value) }.sorted { $0.amount > $1.amount }
@@ -43,11 +44,11 @@ struct ReportsView: View {
private var expenseColorScale: KeyValuePairs {
KeyValuePairs(dictionaryLiteral:
- (ExpenseCategory.coffee.label, ExpenseCategory.coffee.tileColor),
- (ExpenseCategory.food.label, ExpenseCategory.food.tileColor),
- (ExpenseCategory.transit.label, ExpenseCategory.transit.tileColor),
- (ExpenseCategory.shopping.label, ExpenseCategory.shopping.tileColor),
- (ExpenseCategory.other.label, ExpenseCategory.other.tileColor)
+ (ExpenseCategory.coffee.displayName, ExpenseCategory.coffee.tileColor),
+ (ExpenseCategory.food.displayName, ExpenseCategory.food.tileColor),
+ (ExpenseCategory.transit.displayName, ExpenseCategory.transit.tileColor),
+ (ExpenseCategory.shopping.displayName, ExpenseCategory.shopping.tileColor),
+ (ExpenseCategory.other.displayName, ExpenseCategory.other.tileColor)
)
}
@@ -139,8 +140,8 @@ struct ReportsView: View {
}
private var rangeSubtitle: String {
- let f = DateFormatter(); f.dateFormat = "MMM d"
- return "\(f.string(from: viewModel.startDate)) – \(f.string(from: viewModel.endDate))"
+ let style = Date.FormatStyle.dateTime.month(.abbreviated).day()
+ return "\(viewModel.startDate.formatted(style)) – \(viewModel.endDate.formatted(style))"
}
private func dateRow(_ title: String, selection: Binding) -> some View {
diff --git a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift
index c10e0f9..f1a94cd 100755
--- a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift
+++ b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift
@@ -10,31 +10,25 @@ struct ExpenseTrackerView: View {
enum Field: Hashable { case description, amount }
- // Group expenses by relative date
+ // Group expenses by calendar day (in list order), labelled "Today",
+ // "Yesterday" or a localized "Tue, Aug 18" / "mar, 18 ago".
private var groupedExpenses: [(String, [ExpenseModel])] {
let calendar = Calendar.current
let sorted = viewModel.expenses
- var groups: [(String, [ExpenseModel])] = []
- var used: Set = []
+ var days: [Date] = []
+ var byDay: [Date: [ExpenseModel]] = [:]
for expense in sorted {
- let label: String
- if calendar.isDateInToday(expense.date) { label = "Today" }
- else if calendar.isDateInYesterday(expense.date) { label = "Yesterday" }
- else {
- let f = DateFormatter(); f.dateFormat = "EEE, MMM d"
- label = f.string(from: expense.date)
- }
- if !used.contains(label) {
- used.insert(label)
- groups.append((label, sorted.filter { e in
- if label == "Today" { return calendar.isDateInToday(e.date) }
- if label == "Yesterday" { return calendar.isDateInYesterday(e.date) }
- let f = DateFormatter(); f.dateFormat = "EEE, MMM d"
- return f.string(from: e.date) == label
- }))
- }
+ let day = calendar.startOfDay(for: expense.date)
+ if byDay[day] == nil { days.append(day) }
+ byDay[day, default: []].append(expense)
}
- return groups
+ return days.map { day in (dayLabel(day, calendar: calendar), byDay[day] ?? []) }
+ }
+
+ private func dayLabel(_ day: Date, calendar: Calendar) -> String {
+ if calendar.isDateInToday(day) { return Strings.Common.today }
+ if calendar.isDateInYesterday(day) { return Strings.Common.yesterday }
+ return day.formatted(Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day().sentenceCased)
}
var body: some View {
@@ -180,7 +174,7 @@ struct ExpenseTrackerView: View {
}
private var currentMonthName: String {
- Date().formatted(.dateTime.month(.wide))
+ Date().formatted(Date.FormatStyle.dateTime.month(.wide).sentenceCased)
}
/// Scoped to the current month so the label and the number agree — this
@@ -246,7 +240,7 @@ struct ExpenseRowWarm: View {
private var expenseIcon: String { category.icon }
private var iconBg: Color { category.tileBackground }
private var iconColor: Color { category.tileColor }
- private var categoryLabel: String { category.label }
+ private var categoryLabel: String { category.displayName }
private func formattedAmount(_ v: Double) -> String {
let f = NumberFormatter(); f.numberStyle = .currency; f.currencySymbol = "$"; f.maximumFractionDigits = 2
return f.string(from: NSNumber(value: v)) ?? "$0"
diff --git a/Savely/Views/GoalsTab/AddGoalFlow.swift b/Savely/Views/GoalsTab/AddGoalFlow.swift
index fcb51b6..d4ab26c 100644
--- a/Savely/Views/GoalsTab/AddGoalFlow.swift
+++ b/Savely/Views/GoalsTab/AddGoalFlow.swift
@@ -96,7 +96,7 @@ struct AddGoalFlowView: View {
withAnimation(.easeInOut(duration: 0.4)) { showSuccess = true }
} catch {
modelContext.delete(goal)
- saveError = "Please try again."
+ saveError = String(localized: "Please try again.")
}
}
}
@@ -191,7 +191,7 @@ struct AddGoalStep1View: View {
HStack(spacing: 12) {
GoalInitialCircle(name: state.name, color: state.color, size: 44)
VStack(alignment: .leading, spacing: 2) {
- Text(state.name.isEmpty ? "Your goal name" : state.name)
+ Text(state.name.isEmpty ? String(localized: "Your goal name") : state.name)
.warmFont(20, weight: .regular, design: .serif)
.foregroundStyle(state.name.isEmpty ? Color.warmInkMuted : Color.warmInk)
Text("Live preview")
@@ -371,7 +371,7 @@ struct AddGoalStep2View: View {
// MARK: - Step 3: Deadline + pace
-private let durationPresets: [(label: String, months: Int?)] = [
+private let durationPresets: [(label: LocalizedStringKey, months: Int?)] = [
("6 mo", 6), ("1 yr", 12), ("18 mo", 18), ("2 yr", 24), ("No date", nil)
]
@@ -434,7 +434,7 @@ struct AddGoalStep3View: View {
// Duration presets
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
- ForEach(durationPresets, id: \.label) { preset in
+ ForEach(durationPresets, id: \.months) { preset in
let isOn = (preset.months ?? -1) == selectedDuration
Button(action: {
if let m = preset.months {
@@ -501,8 +501,7 @@ struct AddGoalStep3View: View {
private var formattedDeadline: String {
guard state.hasDeadline else { return "—" }
- let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"
- return f.string(from: state.deadline)
+ return state.deadline.formatted(.dateTime.month(.abbreviated).day().year())
}
}
@@ -511,7 +510,8 @@ struct AddGoalStep3View: View {
struct MiniCalendarView: View {
@Binding var displayMonth: Date
@Binding var selectedDate: Date
- private let dayLabels = ["S","M","T","W","T","F","S"]
+ /// Sunday-first, like `leadingBlanks` below; localized initials ("D L M M J V S" in es).
+ private let dayLabels = Calendar.current.veryShortStandaloneWeekdaySymbols
var body: some View {
VStack(spacing: 0) {
@@ -535,7 +535,7 @@ struct MiniCalendarView: View {
.padding(.bottom, 14)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 4) {
- ForEach(dayLabels, id: \.self) { d in
+ ForEach(Array(dayLabels.enumerated()), id: \.offset) { _, d in
Text(d).warmFont(10, weight: .semibold).foregroundStyle(Color.warmInkMuted)
}
ForEach(0.. String {
@@ -244,7 +246,8 @@ struct DepositCard: View {
// CTA
Button(action: saveDeposit) {
- Text(amountText.isEmpty ? "Add deposit" : "Add $\(amountText) to \(goalFirstName)")
+ Text(amountText.isEmpty ? String(localized: "Add deposit")
+ : String(localized: "Add $\(amountText) to \(goalFirstName)"))
.warmFont(15, weight: .semibold)
.foregroundStyle(Color.warmOnGreen)
.frame(maxWidth: .infinity)
@@ -339,6 +342,12 @@ struct DepositRow: View {
private var isAuto: Bool { deposit.source == DepositSource.autoMove.rawValue }
+ /// The stored auto-move note is a stable English value; show it localized.
+ private var displayNote: String? {
+ guard let note = deposit.note else { return nil }
+ return isAuto && note == GoalDeposits.autoMoveNote ? Strings.QuickAdd.paydayAutoMoveNote : note
+ }
+
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10)
@@ -366,7 +375,7 @@ struct DepositRow: View {
.clipShape(Capsule())
}
}
- if let note = deposit.note {
+ if let note = displayNote {
Text(note)
.warmFont(12)
.foregroundStyle(Color.warmInkSoft)
@@ -383,13 +392,7 @@ struct DepositRow: View {
.accessibilityElement(children: .combine)
}
- private func relativeDate(_ d: Date) -> String {
- let cal = Calendar.current
- if cal.isDateInToday(d) { return "Today" }
- if cal.isDateInYesterday(d) { return "Yesterday" }
- let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"
- return f.string(from: d)
- }
+ private func relativeDate(_ d: Date) -> String { Strings.Common.relativeDay(d) }
private func formattedAmount(_ v: Double) -> String {
let f = NumberFormatter(); f.numberStyle = .currency; f.currencySymbol = "$"; f.maximumFractionDigits = 2
diff --git a/Savely/Views/GoalsTab/GoalEditSheet.swift b/Savely/Views/GoalsTab/GoalEditSheet.swift
index bba62e3..a4751fa 100644
--- a/Savely/Views/GoalsTab/GoalEditSheet.swift
+++ b/Savely/Views/GoalsTab/GoalEditSheet.swift
@@ -177,7 +177,7 @@ struct GoalEditSheet: View {
try modelContext.save()
dismiss()
} catch {
- errorMessage = "Couldn't save the goal. Please try again."
+ errorMessage = String(localized: "Couldn't save the goal. Please try again.")
}
}
@@ -190,8 +190,9 @@ struct GoalEditSheet: View {
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.warmLine, lineWidth: 1))
}
- private func sectionLabel(_ text: String) -> some View {
- Text(text.uppercased())
+ private func sectionLabel(_ text: LocalizedStringKey) -> some View {
+ Text(text)
+ .textCase(.uppercase)
.warmFont(11, weight: .semibold)
.foregroundStyle(Color.warmInkMuted)
.tracking(0.8)
@@ -199,7 +200,7 @@ struct GoalEditSheet: View {
.accessibilityAddTraits(.isHeader)
}
- private func fieldRow(label: String, @ViewBuilder content: () -> Content) -> some View {
+ private func fieldRow(label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
HStack(spacing: 16) {
Text(label)
.warmFont(14, weight: .medium)
@@ -210,7 +211,7 @@ struct GoalEditSheet: View {
.padding(.horizontal, 16).padding(.vertical, 12)
}
- private func toggleRow(_ title: String, isOn: Binding) -> some View {
+ private func toggleRow(_ title: LocalizedStringKey, isOn: Binding) -> some View {
Toggle(isOn: isOn) {
Text(title).warmFont(14, weight: .medium).foregroundStyle(Color.warmInk)
}
diff --git a/Savely/Views/GoalsTab/GoalsView.swift b/Savely/Views/GoalsTab/GoalsView.swift
index bf1be94..81610e0 100755
--- a/Savely/Views/GoalsTab/GoalsView.swift
+++ b/Savely/Views/GoalsTab/GoalsView.swift
@@ -159,6 +159,11 @@ struct GoalsView: View {
struct GoalsEmptyStateView: View {
let onCreateGoal: () -> Void
+ /// Emoji + localized idea. Both open the add-goal flow; nothing is prefilled.
+ fileprivate static let suggestionChips: [(String, LocalizedStringKey)] = [
+ ("🌿", "Emergency fund"), ("✈️", "A trip"), ("🏡", "Down payment"),
+ ]
+
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
@@ -225,7 +230,7 @@ struct GoalsEmptyStateView: View {
// Suggestion chips
HStack(spacing: 8) {
- ForEach([("🌿","Emergency fund"),("✈️","A trip"),("🏡","Down payment")], id: \.0) { item in
+ ForEach(Self.suggestionChips, id: \.0) { item in
Button(action: onCreateGoal) {
HStack(spacing: 6) {
Text(item.0).warmFont(13)
diff --git a/Savely/Views/IncomesTab/IncomesTrackerView.swift b/Savely/Views/IncomesTab/IncomesTrackerView.swift
index 9778dca..d8dddae 100755
--- a/Savely/Views/IncomesTab/IncomesTrackerView.swift
+++ b/Savely/Views/IncomesTab/IncomesTrackerView.swift
@@ -18,8 +18,7 @@ struct IncomesTrackerView: View {
let total = viewModel.incomes.filter {
calendar.isDate($0.date, equalTo: date, toGranularity: .month)
}.reduce(0) { $0 + $1.amount }
- let f = DateFormatter(); f.dateFormat = "MMM"
- return (f.string(from: date), total)
+ return (date.formatted(.dateTime.month(.abbreviated)), total)
}
}
private var maxBar: Double { max(barData.map(\.1).max() ?? 1, 1) }
@@ -29,7 +28,7 @@ struct IncomesTrackerView: View {
VStack(spacing: 14) {
// — Header —
VStack(alignment: .leading, spacing: 2) {
- Text("Income")
+ Text(Strings.IncomesTrackerView.title)
.warmFont(34, weight: .regular, design: .serif)
.foregroundStyle(Color.warmInk)
Text("\(currentMonthName) · \(formattedAmount(viewModel.totalIncomeThisMonth))")
@@ -170,7 +169,7 @@ struct IncomesTrackerView: View {
}
private var currentMonthName: String {
- Date().formatted(.dateTime.month(.wide))
+ Date().formatted(Date.FormatStyle.dateTime.month(.wide).sentenceCased)
}
private func formattedAmount(_ v: Double) -> String {
@@ -206,7 +205,7 @@ struct IncomeRowWarm: View {
Text(income.incomeDescription)
.warmFont(14, weight: .semibold)
.foregroundStyle(Color.warmInk)
- Text(source.map { "\($0.label) · \(shortDate(income.date))" } ?? shortDate(income.date))
+ Text(source.map { "\($0.displayName) · \(shortDate(income.date))" } ?? shortDate(income.date))
.warmFont(12)
.foregroundStyle(Color.warmInkMuted)
}
@@ -218,7 +217,7 @@ struct IncomeRowWarm: View {
}
.padding(.horizontal, 14).padding(.vertical, 14)
.accessibilityElement(children: .combine)
- .accessibilityLabel(Text("\(income.incomeDescription), \(source.map { "\($0.label), " } ?? "")\(shortDate(income.date))"))
+ .accessibilityLabel(Text("\(income.incomeDescription), \(source.map { "\($0.displayName), " } ?? "")\(shortDate(income.date))"))
.accessibilityValue(Text("plus \(formattedAmount(income.amount))"))
.accessibilityHint("Long press to delete")
.contextMenu {
@@ -231,8 +230,7 @@ struct IncomeRowWarm: View {
}
private func shortDate(_ d: Date) -> String {
- let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"
- return f.string(from: d)
+ d.formatted(.dateTime.month(.abbreviated).day().year())
}
private func formattedAmount(_ v: Double) -> String {
let f = NumberFormatter(); f.numberStyle = .currency; f.currencySymbol = "$"; f.maximumFractionDigits = 2
diff --git a/Savely/Views/MainNavigationView.swift b/Savely/Views/MainNavigationView.swift
index b2bde4f..e56b344 100755
--- a/Savely/Views/MainNavigationView.swift
+++ b/Savely/Views/MainNavigationView.swift
@@ -92,8 +92,8 @@ struct WarmTabBar: View {
var body: some View {
HStack(alignment: .center, spacing: 0) {
- WarmTabBarItem(icon: "house.fill", label: "Home", tag: 0, selectedTab: $selectedTab)
- WarmTabBarItem(icon: "target", label: "Goals", tag: 1, selectedTab: $selectedTab)
+ WarmTabBarItem(icon: "house.fill", label: Strings.TabBar.home, tag: 0, selectedTab: $selectedTab)
+ WarmTabBarItem(icon: "target", label: Strings.TabBar.goals, tag: 1, selectedTab: $selectedTab)
Button(action: onAddTapped) {
Image(systemName: "plus")
@@ -108,14 +108,14 @@ struct WarmTabBar: View {
)
.rotationEffect(.degrees(isExpanded ? 45 : 0))
}
- .accessibilityLabel(isExpanded ? "Close" : "Add")
- .accessibilityHint("Log an expense, income, deposit, or new goal")
+ .accessibilityLabel(isExpanded ? Strings.Common.close : Strings.Common.add)
+ .accessibilityHint(Strings.TabBar.addHint)
.animation(.spring(response: 0.28, dampingFraction: 0.65), value: isExpanded)
.frame(maxWidth: .infinity)
.offset(y: -14)
- WarmTabBarItem(icon: "wallet.pass.fill", label: "Money", tag: 2, selectedTab: $selectedTab)
- WarmTabBarItem(icon: "person.fill", label: "Me", tag: 3, selectedTab: $selectedTab)
+ WarmTabBarItem(icon: "wallet.pass.fill", label: Strings.TabBar.money, tag: 2, selectedTab: $selectedTab)
+ WarmTabBarItem(icon: "person.fill", label: Strings.TabBar.me, tag: 3, selectedTab: $selectedTab)
}
.padding(.horizontal, 8)
.padding(.top, 10)
@@ -205,11 +205,15 @@ private struct QuickAction {
}
private let quickActions: [QuickAction] = [
- QuickAction(icon: "wallet.pass", label: "Log expense", sub: "Coffee, groceries, anything", bg: .warmAmberSoft, fg: .warmAmber, kind: .screen(.expense)),
- QuickAction(icon: "arrow.up", label: "Log income", sub: "Paycheck, gift, side work", bg: .warmGreenSoft, fg: .warmGreen, kind: .screen(.income)),
- QuickAction(icon: "target", label: "Deposit to a goal", sub: "Move money toward a goal", bg: .warmSkySoft, fg: .warmSky, kind: .screen(.deposit)),
- QuickAction(icon: "camera", label: "Scan a receipt", sub: "We'll read the total, merchant and date", bg: .warmClaySoft, fg: .warmClay, kind: .scan),
- QuickAction(icon: "plus", label: "New goal", sub: "Start something new",
+ QuickAction(icon: "wallet.pass", label: Strings.QuickAdd.logExpense, sub: Strings.QuickAdd.logExpenseSub,
+ bg: .warmAmberSoft, fg: .warmAmber, kind: .screen(.expense)),
+ QuickAction(icon: "arrow.up", label: Strings.QuickAdd.logIncome, sub: Strings.QuickAdd.logIncomeSub,
+ bg: .warmGreenSoft, fg: .warmGreen, kind: .screen(.income)),
+ QuickAction(icon: "target", label: Strings.QuickAdd.depositToGoal, sub: Strings.QuickAdd.depositToGoalSub,
+ bg: .warmSkySoft, fg: .warmSky, kind: .screen(.deposit)),
+ QuickAction(icon: "camera", label: Strings.QuickAdd.scanReceipt, sub: Strings.QuickAdd.scanReceiptSub,
+ bg: .warmClaySoft, fg: .warmClay, kind: .scan),
+ QuickAction(icon: "plus", label: Strings.QuickAdd.newGoal, sub: Strings.QuickAdd.newGoalSub,
bg: .warmLilacSoft, fg: .warmLilac, kind: .newGoal),
]
@@ -239,7 +243,7 @@ struct WarmActionSheet: View {
.overlay(Circle().stroke(Color.warmLine, lineWidth: 1))
.tappable44()
}
- .accessibilityLabel("Close")
+ .accessibilityLabel(Strings.Common.close)
}
.padding(.horizontal, 20)
@@ -323,7 +327,7 @@ struct WarmKeypad: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
- .accessibilityLabel(key == "⌫" ? "Delete" : key == "." ? "Decimal point" : key)
+ .accessibilityLabel(key == "⌫" ? Strings.Common.deleteKey : key == "." ? Strings.Common.decimalPoint : key)
}
}
.background(Color.warmSurface)
@@ -364,10 +368,9 @@ func formatKeypadAmount(_ raw: String) -> String {
// MARK: - Quick expense
-/// The expense chips, straight from the canonical categories (what gets stored).
-private let expenseCats: [(label: String, bg: Color, fg: Color)] = ExpenseCategory.allCases.map {
- ($0.label, $0.tileBackground, $0.tileTextColor)
-}
+/// The expense chips, straight from the canonical categories. The chip
+/// *shows* `displayName` (localized) and *stores* `storageKey` (stable English).
+private let expenseCats: [ExpenseCategory] = ExpenseCategory.allCases
struct WarmQuickExpenseView: View {
@Environment(\.modelContext) private var modelContext
@@ -375,7 +378,7 @@ struct WarmQuickExpenseView: View {
@State private var amountStr = "0"
@State private var description = ""
- @State private var selectedCat = ExpenseCategory.coffee.label
+ @State private var selectedCat = ExpenseCategory.coffee
@StateObject private var vm = ExpenseTrackerViewModel()
private var canSave: Bool { amountStr != "0" }
@@ -392,9 +395,9 @@ struct WarmQuickExpenseView: View {
.frame(width: 32, height: 32)
.tappable44()
}
- .accessibilityLabel("Back")
+ .accessibilityLabel(Strings.Common.back)
Spacer()
- Text("Log expense")
+ Text(Strings.QuickAdd.logExpense)
.warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk)
Spacer()
Button("Save", action: saveAndDismiss)
@@ -427,18 +430,19 @@ struct WarmQuickExpenseView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
- ForEach(expenseCats, id: \.label) { cat in
- let isOn = selectedCat == cat.label
- Button(action: { selectedCat = cat.label }) {
- Text(cat.label)
+ ForEach(expenseCats) { cat in
+ let isOn = selectedCat == cat
+ Button(action: { selectedCat = cat }) {
+ Text(cat.displayName)
.warmFont(13, weight: .semibold)
- .foregroundStyle(isOn ? cat.fg : Color.warmInkSoft)
+ .foregroundStyle(isOn ? cat.tileTextColor : Color.warmInkSoft)
.padding(.horizontal, 14).padding(.vertical, 8)
- .background(isOn ? cat.bg : Color.clear)
+ .background(isOn ? cat.tileBackground : Color.clear)
.clipShape(Capsule())
- .overlay(Capsule().stroke(isOn ? cat.bg : Color.warmLine, lineWidth: 1))
+ .overlay(Capsule().stroke(isOn ? cat.tileBackground : Color.warmLine, lineWidth: 1))
}
.buttonStyle(.plain)
+ .accessibilityAddTraits(isOn ? [.isSelected] : [])
.animation(.easeInOut(duration: 0.15), value: selectedCat)
}
}
@@ -457,10 +461,12 @@ struct WarmQuickExpenseView: View {
private func saveAndDismiss() {
guard canSave, let amt = Double(amountStr), amt > 0 else { return }
- // Description and category are independent: the chip is stored as
- // the category always, and only stands in for an empty description.
- vm.expenseDescription = description.isEmpty ? selectedCat : description
- vm.amount = amountStr; vm.addExpense(category: selectedCat); onSave()
+ // Description and category are independent: the chip's stable
+ // storage key is stored as the category always. Only an empty
+ // description borrows the chip's *display* name — the description
+ // is free text the user reads back, so it follows their language.
+ vm.expenseDescription = description.isEmpty ? selectedCat.displayName : description
+ vm.amount = amountStr; vm.addExpense(category: selectedCat.storageKey); onSave()
}
}
@@ -476,11 +482,11 @@ struct WarmQuickIncomeView: View {
@State private var amountStr = "0"
@State private var description = ""
- @State private var selectedSource = IncomeSource.paycheck.label
+ @State private var selectedSource = IncomeSource.paycheck
@State private var autoMoveArmed = false
@StateObject private var vm = IncomesTrackerViewModel()
- private let sources = IncomeSource.allCases.map(\.label)
+ private let sources = IncomeSource.allCases
private var canSave: Bool { amountStr != "0" }
private var enteredAmount: Double { Double(amountStr) ?? 0 }
@@ -519,9 +525,9 @@ struct WarmQuickIncomeView: View {
.frame(width: 32, height: 32)
.tappable44()
}
- .accessibilityLabel("Back")
+ .accessibilityLabel(Strings.Common.back)
Spacer()
- Text("Log income")
+ Text(Strings.QuickAdd.logIncome)
.warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk)
Spacer()
Button("Save", action: saveAndDismiss)
@@ -553,10 +559,10 @@ struct WarmQuickIncomeView: View {
.padding(.horizontal, 60).padding(.top, 12)
HStack(spacing: 8) {
- ForEach(sources, id: \.self) { src in
+ ForEach(sources) { src in
let isOn = selectedSource == src
Button(action: { selectedSource = src }) {
- Text(src)
+ Text(src.displayName)
.warmFont(13, weight: .semibold)
.foregroundStyle(isOn ? Color.warmGreenDeep : Color.warmInkSoft)
.padding(.horizontal, 14).padding(.vertical, 8)
@@ -565,6 +571,7 @@ struct WarmQuickIncomeView: View {
.overlay(Capsule().stroke(isOn ? Color.warmGreenSoft : Color.warmLine, lineWidth: 1))
}
.buttonStyle(.plain)
+ .accessibilityAddTraits(isOn ? [.isSelected] : [])
.animation(.easeInOut(duration: 0.15), value: selectedSource)
}
}
@@ -581,7 +588,7 @@ struct WarmQuickIncomeView: View {
Text(bannerText(for: suggestion))
.warmFont(12).foregroundStyle(Color.warmGreenDeep)
.frame(maxWidth: .infinity, alignment: .leading)
- Button(autoMoveArmed ? "UNDO" : "YES") {
+ Button(autoMoveArmed ? Strings.QuickAdd.undo : Strings.QuickAdd.yes) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
autoMoveArmed.toggle()
}
@@ -608,13 +615,19 @@ struct WarmQuickIncomeView: View {
}
}
+ /// Whole sentences per case (armed / paycheck / other income) so each
+ /// language can order and inflect them freely; markdown bold survives
+ /// through `AttributedString(localized:)`.
private func bannerText(for suggestion: AutoMoveSuggestion) -> AttributedString {
- let amount = "$\(Int(suggestion.amount))"
- let origin = selectedSource == IncomeSource.paycheck.label ? "paycheck" : "income"
- let raw = autoMoveArmed
- ? "**Moving \(amount)** to \(suggestion.goal.name) when you save."
- : "**Auto-move \(amount)** to \(suggestion.goal.name) from this \(origin)?"
- return (try? AttributedString(markdown: raw)) ?? AttributedString(raw)
+ let amount = Int(suggestion.amount)
+ let goal = suggestion.goal.name
+ if autoMoveArmed {
+ return AttributedString(localized: "**Moving $\(amount)** to \(goal) when you save.")
+ }
+ if selectedSource == .paycheck {
+ return AttributedString(localized: "**Auto-move $\(amount)** to \(goal) from this paycheck?")
+ }
+ return AttributedString(localized: "**Auto-move $\(amount)** to \(goal) from this income?")
}
private func saveAndDismiss() {
@@ -622,19 +635,21 @@ struct WarmQuickIncomeView: View {
// Snapshot the suggestion BEFORE the income insert mutates the
// month totals — this is exactly what the banner was showing.
let armedSuggestion = autoMoveArmed ? autoMoveSuggestion : nil
- vm.incomeDescription = description.isEmpty ? selectedSource : description
+ // Stored source = stable storage key; an empty description borrows
+ // the chip's display name (free text, read back in the user's language).
+ vm.incomeDescription = description.isEmpty ? selectedSource.displayName : description
vm.amount = amountStr
- vm.addIncome(source: selectedSource)
+ vm.addIncome(source: selectedSource.storageKey)
if let suggestion = armedSuggestion {
do {
try GoalDeposits.record(
goal: suggestion.goal, amount: suggestion.amount,
- note: "Payday auto-move", source: .autoMove, context: modelContext
+ note: GoalDeposits.autoMoveNote, source: .autoMove, context: modelContext
)
} catch {
// The income itself is already saved; the move failing must
// not lose it. Surface through the income VM's alert.
- vm.errorMessage = "Income saved, but the auto-move to \(suggestion.goal.name) failed."
+ vm.errorMessage = Strings.QuickAdd.autoMoveFailed(goal: suggestion.goal.name)
vm.showError = true
return
}
@@ -667,9 +682,9 @@ struct WarmQuickDepositView: View {
.frame(width: 32, height: 32)
.tappable44()
}
- .accessibilityLabel("Back")
+ .accessibilityLabel(Strings.Common.back)
Spacer()
- Text("Move money")
+ Text(Strings.QuickAdd.moveMoney)
.warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk)
Spacer()
Color.clear.frame(width: 32, height: 32)
@@ -776,7 +791,7 @@ struct WarmQuickDepositView: View {
Button(action: saveDeposit) {
let goalName = selectedGoal.map { $0.name.components(separatedBy: ",").first ?? $0.name }
- Text(goalName.map { "Add $\(Int(selectedPreset)) to \($0)" } ?? "Select a goal")
+ Text(goalName.map { String(localized: "Add $\(Int(selectedPreset)) to \($0)") } ?? String(localized: "Select a goal"))
.warmFont(15, weight: .semibold).foregroundStyle(Color.warmOnGreen)
.frame(maxWidth: .infinity).frame(height: 50)
.background(selectedGoal != nil ? Color.warmGreenFill : Color.warmInkMuted)
diff --git a/Savely/Views/MoneyView.swift b/Savely/Views/MoneyView.swift
index af2cbdc..e71c1c6 100644
--- a/Savely/Views/MoneyView.swift
+++ b/Savely/Views/MoneyView.swift
@@ -8,7 +8,7 @@ struct MoneyView: View {
// Segmented control header
Picker("", selection: $selectedSegment) {
Text("Expenses").tag(0)
- Text("Income").tag(1)
+ Text(Strings.IncomesTrackerView.title).tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
diff --git a/Savely/Views/ProfileTab/ProfileView.swift b/Savely/Views/ProfileTab/ProfileView.swift
index f6c6192..a31f99b 100755
--- a/Savely/Views/ProfileTab/ProfileView.swift
+++ b/Savely/Views/ProfileTab/ProfileView.swift
@@ -46,8 +46,9 @@ struct ProfileView: View {
dataPrivacySection
if FeatureFlags.tipsEnabled {
- ProfileSection(header: "About") {
- SettingsNavRow(icon: "sparkles", title: "Tip history", detail: "128 tips", onTap: { showingTipHistory = true })
+ ProfileSection(header: String(localized: "About")) {
+ SettingsNavRow(icon: "sparkles", title: String(localized: "Tip history"),
+ onTap: { showingTipHistory = true })
}
}
@@ -200,7 +201,7 @@ struct ProfileView: View {
// MARK: - Settings
private var settingsSection: some View {
- ProfileSection(header: "Settings") {
+ ProfileSection(header: String(localized: "Settings")) {
if viewModel.notificationsDenied {
NotificationsDeniedRow(onOpenSettings: {
if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) }
@@ -262,9 +263,9 @@ struct ProfileView: View {
}
.padding(.horizontal, 16).padding(.vertical, 14)
WarmDivider()
- SettingsNavRow(icon: "doc.text.fill", title: "Weekly PDF report", onTap: { viewModel.generateWeeklyReportPDF() })
+ SettingsNavRow(icon: "doc.text.fill", title: String(localized: "Weekly PDF report"), onTap: { viewModel.generateWeeklyReportPDF() })
WarmDivider()
- SettingsNavRow(icon: "chart.bar", title: "Reports", onTap: { showingReports = true })
+ SettingsNavRow(icon: "chart.bar", title: String(localized: "Reports"), onTap: { showingReports = true })
WarmDivider()
SettingsNavRow(icon: "trash", title: Strings.Profile.deleteAllDataLabel, color: Color.warmClay, onTap: { showingDeleteDialog = true })
}
@@ -428,7 +429,8 @@ struct ProfileSection: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
- Text(header.uppercased())
+ Text(header)
+ .textCase(.uppercase)
.warmFont(11, weight: .semibold)
.foregroundStyle(Color.warmInkMuted)
.tracking(0.8)
diff --git a/SavelyTests/CategoryPersistenceTests.swift b/SavelyTests/CategoryPersistenceTests.swift
index e0aeb43..e10a756 100644
--- a/SavelyTests/CategoryPersistenceTests.swift
+++ b/SavelyTests/CategoryPersistenceTests.swift
@@ -86,4 +86,59 @@ final class CategoryPersistenceTests: XCTestCase {
XCTAssertEqual(ExpenseCategory.allCases.map(\.rawValue), ["Coffee", "Food", "Transit", "Shopping", "Other"])
XCTAssertEqual(IncomeSource.allCases.map(\.rawValue), ["Paycheck", "Freelance", "Gift", "Other"])
}
+
+ // MARK: - Localized display, stable storage (es-419, 1.1)
+
+ /// The app's es-419 string table, read directly so the test does not
+ /// depend on the simulator's language.
+ private func spanish(_ key: String) throws -> String {
+ let appBundle = Bundle(for: ExpenseTrackerViewModel.self)
+ let path = try XCTUnwrap(appBundle.path(forResource: "es-419", ofType: "lproj"), "es-419.lproj missing")
+ let bundle = try XCTUnwrap(Bundle(path: path))
+ return bundle.localizedString(forKey: key, value: nil, table: nil)
+ }
+
+ func testStorageKeysAreTheRawValues() {
+ XCTAssertEqual(ExpenseCategory.allCases.map(\.storageKey), ExpenseCategory.allCases.map(\.rawValue))
+ XCTAssertEqual(IncomeSource.allCases.map(\.storageKey), IncomeSource.allCases.map(\.rawValue))
+ }
+
+ func testSpanishDisplayNamesDifferFromWhatIsStored() throws {
+ // If these ever matched, a localized label could leak into the store unnoticed.
+ XCTAssertEqual(try spanish("category.coffee"), "Café")
+ XCTAssertNotEqual(try spanish("category.coffee"), ExpenseCategory.coffee.storageKey)
+ XCTAssertEqual(try spanish("source.paycheck"), "Sueldo")
+ XCTAssertNotEqual(try spanish("source.paycheck"), IncomeSource.paycheck.storageKey)
+ }
+
+ func testQuickAddWritesTheStorageKeyAndItRoundTrips() throws {
+ let expenses = ExpenseTrackerViewModel(modelContext: context)
+ XCTAssertTrue(expenses.addExpense(description: "Oxxo", amount: 25, date: Date(),
+ category: ExpenseCategory.coffee.storageKey))
+ context.insert(IncomeModel(incomeDescription: "Quincena", amount: 900, date: Date(),
+ source: IncomeSource.paycheck.storageKey))
+ try context.save()
+
+ let expense = try XCTUnwrap(try context.fetch(FetchDescriptor()).first)
+ XCTAssertEqual(expense.category, "Coffee")
+ XCTAssertEqual(ExpenseCategory.display(stored: expense.category, description: expense.expenseDescription), .coffee)
+
+ let income = try XCTUnwrap(try context.fetch(FetchDescriptor()).first)
+ XCTAssertEqual(income.source, "Paycheck")
+ XCTAssertEqual(IncomeSource.display(stored: income.source), .paycheck)
+ }
+
+ func testLegacyEnglishRowsStillResolveEveryChip() {
+ // Rows written by 1.0 (English-only) must keep matching after 1.1.
+ for category in ExpenseCategory.allCases {
+ XCTAssertEqual(ExpenseCategory.display(stored: category.rawValue, description: ""), category)
+ }
+ for source in IncomeSource.allCases {
+ XCTAssertEqual(IncomeSource.display(stored: source.rawValue), source)
+ }
+ }
+
+ func testAutoMoveNoteIsStoredInEnglish() {
+ XCTAssertEqual(GoalDeposits.autoMoveNote, "Payday auto-move")
+ }
}
diff --git a/SavelyTests/GoalPaceTests.swift b/SavelyTests/GoalPaceTests.swift
index bbc398f..8f1b7ea 100644
--- a/SavelyTests/GoalPaceTests.swift
+++ b/SavelyTests/GoalPaceTests.swift
@@ -97,8 +97,11 @@ final class GoalPaceTests: XCTestCase {
let pace = GoalPace.compute(goal: g, deposits: [deposit(g, 400, daysAgo: 1)], now: now, calendar: calendar) // 100/wk
XCTAssertEqual(pace.etaWeeks ?? -1, 4, accuracy: 0.001)
let expected = calendar.date(byAdding: .day, value: 28, to: now)
- let f = DateFormatter(); f.dateFormat = "MMM d"
- XCTAssertEqual(pace.etaText(from: now, calendar: calendar), f.string(from: try XCTUnwrap(expected)))
+ // Locale-driven ("Aug 10" in en_US, "10 Aug" in en_GB, "10 ago" in es), never a fixed pattern.
+ let usEnglish = Locale(identifier: "en_US")
+ let style = Date.FormatStyle.dateTime.month(.abbreviated).day().locale(usEnglish)
+ XCTAssertEqual(pace.etaText(from: now, calendar: calendar, locale: usEnglish),
+ try XCTUnwrap(expected).formatted(style))
}
func testEtaBeyondAYearIsCappedNotFaked() {
diff --git a/SavelyUITests/StoreScreenshotTourUITests.swift b/SavelyUITests/StoreScreenshotTourUITests.swift
index 4a9696b..3f4b786 100644
--- a/SavelyUITests/StoreScreenshotTourUITests.swift
+++ b/SavelyUITests/StoreScreenshotTourUITests.swift
@@ -128,6 +128,123 @@ final class StoreScreenshotTourUITests: XCTestCase {
snap("00-home-light")
}
+ /// App Preview footage: one continuous, human-paced walk through the
+ /// seeded app while `simctl io … recordVideo` captures the screen. Prints
+ /// `BRAGMARK [x y]` lines (points) so the editor can cut
+ /// scenes and draw touch indicators without guessing.
+ @MainActor
+ func testPreviewFootage() throws {
+ launch()
+ // The first launch asks for notifications; answer it before the take.
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
+ if springboard.buttons["Allow"].waitForExistence(timeout: 3) {
+ springboard.buttons["Allow"].tap()
+ }
+ pause(1.0)
+ mark("home")
+ pause(2.0)
+
+ tap(app.buttons["Goals"].firstMatch, "goals-tab")
+ let oaxaca = app.staticTexts["Trip to Oaxaca"]
+ XCTAssertTrue(oaxaca.waitForExistence(timeout: 8))
+ pause(0.8)
+ tap(oaxaca, "goal-open")
+ XCTAssertTrue(app.navigationBars["Goal"].waitForExistence(timeout: 8))
+ mark("goal-before")
+ pause(3.0)
+ tap(app.navigationBars.buttons.firstMatch, "goal-back")
+ pause(0.8)
+
+ tap(app.buttons["Add"], "add-1")
+ let expense = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Log expense'")).firstMatch
+ XCTAssertTrue(expense.waitForExistence(timeout: 5))
+ pause(0.6)
+ tap(expense, "expense-open")
+ XCTAssertTrue(app.staticTexts["Log expense"].waitForExistence(timeout: 5))
+ pause(0.5)
+ for key in ["4", "Decimal point", "5", "0"] { tap(app.buttons[key].firstMatch, "key-\(key)"); pause(0.15) }
+ pause(0.3)
+ tap(app.buttons["Coffee"].firstMatch, "expense-coffee")
+ pause(1.2)
+ tap(app.buttons["Save"].firstMatch, "expense-save")
+ pause(1.5)
+
+ tap(app.buttons["Add"], "add-2")
+ let income = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Log income'")).firstMatch
+ XCTAssertTrue(income.waitForExistence(timeout: 5))
+ pause(0.6)
+ tap(income, "income-open")
+ XCTAssertTrue(app.staticTexts["Log income"].waitForExistence(timeout: 5))
+ pause(0.5)
+ for key in ["1", "2", "4", "0"] { tap(app.buttons[key].firstMatch, "key-\(key)"); pause(0.15) }
+ XCTAssertTrue(app.buttons["YES"].waitForExistence(timeout: 3))
+ mark("income-banner")
+ pause(1.6)
+ tap(app.buttons["YES"], "income-yes")
+ pause(1.4)
+ tap(app.buttons["Save"].firstMatch, "income-save")
+ pause(1.2)
+
+ tap(app.buttons["Goals"].firstMatch, "goals-tab-2")
+ XCTAssertTrue(oaxaca.waitForExistence(timeout: 8))
+ pause(0.8)
+ tap(oaxaca, "goal-open-2")
+ XCTAssertTrue(app.navigationBars["Goal"].waitForExistence(timeout: 8))
+ mark("goal-after")
+ pause(3.0)
+ tap(app.navigationBars.buttons.firstMatch, "goal-back-2")
+ pause(0.6)
+
+ tap(app.buttons["Money"].firstMatch, "money-tab")
+ let incomeSeg = app.buttons["Income"].firstMatch
+ XCTAssertTrue(incomeSeg.waitForExistence(timeout: 8))
+ pause(1.0)
+ tap(incomeSeg, "money-income")
+ mark("trend")
+ pause(3.0)
+
+ tap(app.buttons["Me"].firstMatch, "me-tab")
+ pause(0.8)
+ for _ in 0..<9 { app.swipeUp() }
+ XCTAssertTrue(app.staticTexts["Your data never leaves this iPhone."].waitForExistence(timeout: 5))
+ mark("privacy")
+ pause(2.5)
+
+ tap(app.buttons["Add"], "add-3")
+ let scan = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Scan a receipt'")).firstMatch
+ XCTAssertTrue(scan.waitForExistence(timeout: 5))
+ pause(0.6)
+ tap(scan, "scan-open")
+ let picker = app.buttons["Choose from Photos"]
+ XCTAssertTrue(picker.waitForExistence(timeout: 8))
+ pause(0.8)
+ tap(picker, "scan-photos")
+ let photo = app.images.matching(NSPredicate(format: "label BEGINSWITH 'Photo'")).firstMatch
+ guard photo.waitForExistence(timeout: 30) else { mark("receipt-missing"); return }
+ pause(0.8)
+ tap(photo, "scan-photo")
+ XCTAssertTrue(app.staticTexts["Check the receipt"].waitForExistence(timeout: 30))
+ mark("receipt")
+ pause(3.5)
+ mark("end")
+ }
+
+ private func mark(_ name: String, _ point: CGPoint? = nil) {
+ let t = String(format: "%.3f", Date().timeIntervalSince1970)
+ let p = point.map { String(format: " %.1f %.1f", $0.x, $0.y) } ?? ""
+ print("BRAGMARK \(name) \(t)\(p)")
+ }
+
+ private func tap(_ element: XCUIElement, _ name: String) {
+ let frame = element.frame
+ mark(name, CGPoint(x: frame.midX, y: frame.midY))
+ element.tap()
+ }
+
+ private func pause(_ seconds: TimeInterval) {
+ Thread.sleep(forTimeInterval: seconds)
+ }
+
private func snap(_ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh
index 2303b38..d676ced 100755
--- a/ci_scripts/ci_post_clone.sh
+++ b/ci_scripts/ci_post_clone.sh
@@ -3,17 +3,16 @@
# ci_scripts/ci_post_clone.sh — Xcode Cloud post-clone hook for Savely.
#
# WHY IT EXISTS
-# A fresh clone of this repo is missing exactly one file xcodebuild insists
-# on: Savely/Config.plist. It is gitignored (it carries the OpenAI key for
-# the Tips feature) but the Xcode project lists it as a Resources build
-# input, so on a clean checkout xcodebuild stops with "Build input file
-# cannot be found". .github/workflows/ci.yml solves this on GitHub Actions
-# by writing a placeholder; this script is the same idea for Xcode Cloud,
-# plus one honesty gate for archives (see section 2).
+# Until 1.1 this script wrote Savely/Config.plist (the OpenAI key for the
+# Tips feature), because the Xcode project listed it as a Resources build
+# input and a clean checkout would not build without it. That entry was the
+# bug: anything in Resources ships inside the IPA, and a key in a bundled
+# plist can be lifted out by anyone (docs/plans/drop-bundled-config-plist.md).
+# Config.plist is no longer a build input, so nothing needs materialising.
#
-# Unlike Fave (docs/xcode-cloud.md over there is the origin of this file),
-# Savely commits its .xcodeproj and its shared scheme, so there is no
-# project generation step here. Everything about XcodeGen was dropped.
+# What is left is one honesty gate for archives: the Tips feature has no
+# safe way to get a key today, so an archive with FeatureFlags.tipsEnabled
+# set to true would ship a Tips card that silently never loads. Refuse it.
#
# APPLE'S CONTRACT — the parts this script leans on, from
# developer.apple.com/documentation/xcode/writing-custom-build-scripts and
@@ -27,25 +26,12 @@
# · The clone is at $CI_PRIMARY_REPOSITORY_PATH.
# · A nonzero exit fails the build, which is what every failure path here
# does, loudly, instead of letting xcodebuild fail later.
-# · Writes into the clone survive into the xcodebuild step.
-#
-# SECRET DISCIPLINE (house rule: env var only, never committed, never logged)
-# If a real key is provided it arrives as the SECRET workflow environment
-# variable named in $SECRET_ENV_VAR. It is never expanded into a command
-# argument, an assignment, or an echo: `printenv` writes it straight down a
-# pipe, so even under `set -x` the trace shows the word "printenv" and not
-# the value. Every check on the resulting file uses `grep -c`/`grep -q`,
-# never a form that could print the line.
#
set -euo pipefail
set +x
# ---------------------------------------------------------------- constants --
-# The workflow environment variable that carries the OpenAI key. Must be
-# marked SECRET in the Xcode Cloud workflow's Environment section.
-readonly SECRET_ENV_VAR="OPENAI_API_KEY"
-readonly PLIST_REL="Savely/Config.plist"
readonly FLAGS_REL="Savely/Utilities/FeatureFlags.swift"
# ------------------------------------------------------------------ helpers --
@@ -72,30 +58,8 @@ cd "$repo_root"
log "repo root: $repo_root"
[ -f "$FLAGS_REL" ] || fail "$FLAGS_REL missing; the archive gate below reads it"
-# --------------------------------------------- 2. materialise Config.plist --
-# Three states, all of them valid:
-# a) variable set and non-empty -> write the real key
-# b) variable set but empty -> write the placeholder
-# c) variable not set at all -> write the placeholder
-# The placeholder is what a fresh local clone and GitHub Actions build with:
-# the app runs, OpenAIClient.init? finds a string that is not a key, and the
-# Tips call answers nothing. That is fine while the Tips feature is OFF.
-
-key_state="absent"
-if printenv "$SECRET_ENV_VAR" >/dev/null 2>&1; then
- key_state="empty"
- key_chars="$(printenv "$SECRET_ENV_VAR" | tr -d '[:space:]' | wc -c | tr -d '[:space:]')"
- if [ "${key_chars:-0}" -gt 0 ]; then
- key_state="present"
- fi
-fi
+# ------------------------------------------------ 2. the Tips archive gate --
-# The Tips feature is gated by FeatureFlags.tipsEnabled. While it is `false`
-# the shipped app never touches OpenAI and a placeholder key is not merely
-# acceptable, it is preferable: a real key inside a plist in the bundle can be
-# lifted out of the IPA. The moment someone flips the flag on, an ARCHIVE
-# without a real key would ship a Tips card that silently never loads, with a
-# green checkmark on the workflow. Refuse that combination and nothing else.
tips_enabled="unknown"
if grep -Eq '^\s*static let tipsEnabled\s*=\s*true\b' "$FLAGS_REL"; then
tips_enabled="true"
@@ -105,50 +69,20 @@ fi
[ "$tips_enabled" != "unknown" ] \
|| fail "could not read FeatureFlags.tipsEnabled from $FLAGS_REL; the line changed shape, update the grep here"
-if [ "${CI_XCODEBUILD_ACTION:-}" = "archive" ] && [ "$tips_enabled" = "true" ] && [ "$key_state" != "present" ]; then
- fail "\$$SECRET_ENV_VAR is $key_state, CI_XCODEBUILD_ACTION=archive and FeatureFlags.tipsEnabled is true.
- Refusing to ship a release with the Tips feature on and no OpenAI key: the
- card would never load and nothing would say so.
- Either add $SECRET_ENV_VAR as a SECRET environment variable on the workflow
- (Xcode 26/27: Integrate > Manage Workflows > Environment) or turn the flag off."
-fi
-
-mkdir -p "$(dirname "$PLIST_REL")"
-umask 077
-{
- printf '\n'
- printf '\n'
- printf '\n\n'
- printf '\t%s\n' "$SECRET_ENV_VAR"
- printf '\t'
- if [ "$key_state" = "present" ]; then
- # Whitespace stripped (a stray CR pasted into the App Store Connect field
- # would otherwise become part of the key), then XML-escaped. The value
- # never binds to a shell variable on the way.
- printenv "$SECRET_ENV_VAR" | tr -d '[:space:]' | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g'
- else
- printf 'xcode-cloud-placeholder-not-a-real-key'
- fi
- printf '\n\n\n'
-} > "$PLIST_REL"
-chmod 600 "$PLIST_REL"
-
-if [ "$key_state" = "present" ]; then
- log "$PLIST_REL written from \$$SECRET_ENV_VAR (value not logged)"
-else
- log "\$$SECRET_ENV_VAR is $key_state; $PLIST_REL written with a placeholder (tipsEnabled=$tips_enabled, action=${CI_XCODEBUILD_ACTION:-})"
+if [ "${CI_XCODEBUILD_ACTION:-}" = "archive" ] && [ "$tips_enabled" = "true" ]; then
+ fail "CI_XCODEBUILD_ACTION=archive and FeatureFlags.tipsEnabled is true.
+ Refusing to ship the Tips feature: it has no safe key source. A bundled
+ Config.plist is equivalent to publishing the key; serve it from a proxy
+ you control first (docs/plans/drop-bundled-config-plist.md, step 3),
+ then update this gate."
fi
-# ------------------------------------------------------------- 3. verify it --
-# plutil -lint prints only OK / the error, never the values.
-plutil -lint "$PLIST_REL" >/dev/null || fail "$PLIST_REL is not a valid plist"
-[ "$(grep -c "${SECRET_ENV_VAR}" "$PLIST_REL")" -eq 1 ] \
- || fail "$PLIST_REL does not contain exactly one ${SECRET_ENV_VAR} entry"
+# ------------------------------------------------------------ 3. sanity check --
if [ -n "${CI_PROJECT_FILE_PATH:-}" ] && [ ! -e "$CI_PROJECT_FILE_PATH" ]; then
fail "Xcode Cloud expects the project at CI_PROJECT_FILE_PATH='$CI_PROJECT_FILE_PATH', which does not exist.
Recreate the workflow against Savely.xcodeproj at the repo root."
fi
-log "done — $PLIST_REL in place."
+log "done (tipsEnabled=$tips_enabled, action=${CI_XCODEBUILD_ACTION:-})."
exit 0
diff --git a/docs/plans/dead-api-records.md b/docs/plans/dead-api-records.md
new file mode 100644
index 0000000..026dfc5
--- /dev/null
+++ b/docs/plans/dead-api-records.md
@@ -0,0 +1,74 @@
+# Dead API records still in the project
+
+> Found 2026-08-20 while grounding atelierbelli.com/savely/support in what the
+> app actually does. Companion to `drop-bundled-config-plist.md`, which covers
+> the third record (the bundled OpenAI plist) on its own.
+> Status: **§1 and the on-disk half of §2 DONE in 1.1 (branch
+> `chore/release-hygiene`)** — ATS block removed, GoogleService-Info.plist
+> deleted locally. **Still open, owner-only:** the Firebase project
+> `savely-5160e` (§2, "How much this actually matters") — confirm it is dormant
+> and delete it in the Firebase console.
+
+Savely 1.0 talks to nothing. The OpenAI tips feature is unreachable
+(`FeatureFlags.tipsEnabled = false`, `Savely/Utilities/FeatureFlags.swift:22`)
+and the Firebase auth stack was removed in 2026-08. What is left is paperwork
+that still describes integrations the app no longer has.
+
+## 1. ATS exception for openai.com
+
+`Savely/Info.plist:10-26` still declares an `NSAppTransportSecurity`
+exception-domain block for `openai.com`. It grants nothing dangerous
+(`NSExceptionAllowsInsecureHTTPLoads` is `false`), but it is statically visible
+to App Review and it advertises a network integration in an app whose support
+page and App Privacy answers both say nothing leaves the phone.
+
+**Chore:** delete the whole `NSAppTransportSecurity` dict. Keep
+`ITSAppUsesNonExemptEncryption` — that one is load-bearing for upload.
+Restore the exception only if the tips feature is ever re-enabled.
+
+## 2. GoogleService-Info.plist
+
+Two separate facts, and only the second one matters:
+
+- **On disk:** `Savely/GoogleService-Info.plist` still exists but is inert. It
+ is not referenced by `project.pbxproj` and not imported by any `.swift` file
+ (verified by grep), so it does **not** ship in the binary. It is leftover
+ weight from the removed auth stack. Safe to delete whenever.
+- **In git history:** it was committed in `80cf033` ("Implementation of
+ Firebase to the Project") and the blob is still reachable
+ (`git cat-file -p 311b1042689098ca3a8e97e432f3a1c560cabb18`). `.gitignore`
+ only stopped future tracking. **`github.com/Ivan-LB/Savely` is a public
+ repo**, so the contents are publicly readable today.
+
+### How much this actually matters
+
+Less than it looks, but not zero, and **this key was never revoked** — it is
+not the OpenAI key Iván killed on 2026-08-19, it is a separate Firebase
+credential for project `savely-5160e`.
+
+A Firebase iOS `API_KEY` is not a secret in the classic sense: Google documents
+it as a project identifier, not an authorization token. Real protection comes
+from Firebase Security Rules, API-key restrictions, and App Check, not from
+hiding the string. So the exposure is closer to "project identifiers are
+public" than "credentials leaked".
+
+What still deserves a look, in rough priority order:
+
+1. **Confirm the Firebase project is actually dormant.** If `savely-5160e` has
+ no active Auth users, no Firestore/Storage data, and no billing, the
+ exposure is academic and deleting the whole project is the cleanest fix.
+2. **If the project stays alive**, add an API-key restriction (bundle-id
+ allowlist) in Google Cloud Console → APIs & Services → Credentials, and
+ confirm Security Rules are not left in test mode.
+3. **Only then consider a history rewrite.** `git filter-repo` plus a
+ force-push would purge the blob, but it rewrites every commit hash, breaks
+ existing clones, and touches a repo wired to Xcode Cloud. Given the key is a
+ project identifier and the project is likely dormant, deleting the Firebase
+ project outright is a better fix than rewriting history.
+
+**Do not** treat "the key was revoked" as covering this one. It was not.
+
+## 3. Bundled Config.plist
+
+See `drop-bundled-config-plist.md`. Key revoked 2026-08-19; the build-phase
+entry is the remaining bug.
diff --git a/docs/plans/drop-bundled-config-plist.md b/docs/plans/drop-bundled-config-plist.md
new file mode 100644
index 0000000..797bc1b
--- /dev/null
+++ b/docs/plans/drop-bundled-config-plist.md
@@ -0,0 +1,41 @@
+# Drop the bundled Config.plist from Resources
+
+> Found 2026-08-19 during an external privacy review of all shipping apps.
+> Status: **DONE in 1.1 (branch `chore/release-hygiene`)** — steps 1–2 shipped,
+> the CI placeholder steps (ci.yml, ci_post_clone.sh) were retired with it and the
+> Xcode Cloud gate now refuses any archive with Tips on. Step 3 stands for when
+> Tips returns. Key revoked by Iván 2026-08-19.
+
+## What happened
+
+`Savely/Config.plist` holds the OpenAI API key for the tips feature and is
+**bundled into the app**: it appears in the Resources build phase at
+`Savely.xcodeproj/project.pbxproj:27` and `:721`, loaded at
+`Savely/Utilities/OpenAIClient.swift:16-22` via
+`Bundle.main.path(forResource: "Config")`. It is gitignored
+(`.gitignore:14`) so it never reached git history, but anything in Resources
+is extractable from the .ipa by anyone. The build submitted to App Review
+carried a live key. The feature itself is unreachable
+(`FeatureFlags.tipsEnabled = false`, `Savely/Utilities/FeatureFlags.swift:22`)
+but the plist ships regardless.
+
+Iván revoked the key in the OpenAI account on 2026-08-19, so the shipped copy
+is dead. Remaining work is hygiene:
+
+## The chore
+
+1. Remove `Config.plist` from the Resources build phase (keep the file on disk
+ and gitignored for local dev when the feature returns).
+2. Verify: `grep -n "Config.plist" Savely.xcodeproj/project.pbxproj` shows no
+ `in Resources` entry, the app still builds, and a fresh archive's .app
+ bundle contains no Config.plist (`unzip -l` the .ipa or inspect the archive).
+3. When the tips feature is ever turned back on: the key must NOT return via
+ bundled plist. Ship it behind a proxy you control, or at minimum load from
+ Keychain after first launch. A bundled plist is equivalent to publishing
+ the key.
+
+## Why not urgent anymore
+
+Revoked key = dead file. But the pattern regenerates the incident the moment
+someone rotates the key locally and archives: the build phase entry is the
+bug, not the key value.
diff --git a/docs/xcode-cloud.md b/docs/xcode-cloud.md
index 2f03ea2..ca13568 100644
--- a/docs/xcode-cloud.md
+++ b/docs/xcode-cloud.md
@@ -16,18 +16,15 @@ different for Savely and the settings that matter. Where Fave's notes say
| Fave | Savely |
|---|---|
| Project is generated by XcodeGen, not committed | `Savely.xcodeproj` and its shared scheme are committed. No generation step, no XcodeGen, no scheme patch. |
-| Secret is `Config/Secrets.xcconfig` (TMDB key), gitignored | Secret is `Savely/Config.plist` (OpenAI key), gitignored, **but registered as a Resources build input**, so a clean checkout fails to build without it. |
-| An archive without the key is refused | The Tips feature is behind `FeatureFlags.tipsEnabled = false`, so the shipped app never calls OpenAI and a placeholder is preferable (a real key in a bundled plist can be lifted from the IPA). The archive is refused only if the flag is `true` AND no key was provided. |
+| Secret is `Config/Secrets.xcconfig` (TMDB key), gitignored | No build-time secret. `Savely/Config.plist` (OpenAI key, gitignored) stopped being a Resources build input in 1.1, so a clean checkout builds as-is. |
+| An archive without the key is refused | The Tips feature is behind `FeatureFlags.tipsEnabled = false` and has no safe key source (a key in a bundled plist can be lifted from the IPA). Any archive with the flag `true` is refused. |
-`ci_scripts/ci_post_clone.sh` writes `Savely/Config.plist` from the workflow
-variable `OPENAI_API_KEY` if it exists, or a placeholder if it does not, and
-enforces that one gate. It was dry-run against a scratch clone with a canary
-key containing `&<>` under `bash -x`: zero hits in the trace, one hit in the
-plist, XML-escaped, `plutil -lint` OK, and the tips-on/no-key/archive
-combination exits 1 with a message that names the fix.
+`ci_scripts/ci_post_clone.sh` now only enforces that gate. Until 1.1 it also
+wrote `Savely/Config.plist` from a SECRET `OPENAI_API_KEY` variable (or a
+placeholder); that ended with `docs/plans/drop-bundled-config-plist.md`.
-The workflow does not need any environment variable for a 1.0 archive. Add
-`OPENAI_API_KEY` as a SECRET variable only when the flag flips on.
+The workflow needs no environment variables. Do not add `OPENAI_API_KEY`
+back: when Tips returns, the key lives behind a proxy, not in the build.
## Workflow settings
diff --git a/scripts/check-l10n.py b/scripts/check-l10n.py
new file mode 100755
index 0000000..f5acd11
--- /dev/null
+++ b/scripts/check-l10n.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Localization completeness check for Savely/Resources/Localizable.xcstrings.
+
+Fails (exit 1) when:
+ * a key that is not `extractionState: "stale"` has no es-419 value in the
+ `translated` state (plain stringUnit, every plural/device variation, and
+ every substitution variation all count), or
+ * an es-419 value uses a different multiset of format-specifier types than
+ the English source (`%1$@` counts as `%@`, `%#@name@` resolves to its
+ substitution's specifier, `%%` is a literal).
+
+Stdlib only, so it runs on any CI image: `python3 scripts/check-l10n.py`.
+"""
+
+import json
+import re
+import sys
+from collections import Counter
+from pathlib import Path
+
+CATALOG = Path(__file__).resolve().parent.parent / "Savely" / "Resources" / "Localizable.xcstrings"
+TARGET = "es-419"
+
+# %% | %#@name@ | %[n$][flags][width][.precision][length]conversion
+TOKEN = re.compile(
+ r"%%"
+ r"|%#@(?P\w+)@"
+ r"|%(?:\d+\$)?[-+ #0']*(?:\d+|\*)?(?:\.(?:\d+|\*))?"
+ r"(?Phh|h|ll|l|q|L|z|t|j)?(?P[@dDiuUxXoOfFeEgGcCsSpaA])"
+)
+
+
+def specifiers(text, substitutions=None):
+ """Multiset of specifier types in `text`, e.g. Counter({'lld': 1, '@': 2})."""
+ found = Counter()
+ for match in TOKEN.finditer(text):
+ if match.group(0) == "%%":
+ continue
+ if match.group("sub"):
+ sub = (substitutions or {}).get(match.group("sub"))
+ spec = sub.get("formatSpecifier") if sub else None
+ found[spec or "?" + match.group("sub")] += 1
+ continue
+ found[(match.group("len") or "") + match.group("conv")] += 1
+ return found
+
+
+def leaves(node):
+ """Yield every stringUnit under a localization (plain or variations)."""
+ if "stringUnit" in node:
+ yield node["stringUnit"]
+ for kinds in node.get("variations", {}).values():
+ for variant in kinds.values():
+ yield from leaves(variant)
+
+
+def english_source(key, entry):
+ unit = entry.get("localizations", {}).get("en", {}).get("stringUnit")
+ return unit["value"] if unit and "value" in unit else key
+
+
+def check(catalog):
+ problems = []
+ for key, entry in catalog["strings"].items():
+ if entry.get("extractionState") == "stale" or entry.get("shouldTranslate") is False:
+ continue
+ label = repr(key)
+ loc = entry.get("localizations", {}).get(TARGET)
+ if not loc:
+ problems.append(f"{label}: no {TARGET} localization")
+ continue
+
+ units = list(leaves(loc))
+ subs = loc.get("substitutions", {})
+ for sub in subs.values():
+ units.extend(leaves(sub))
+ if not units:
+ problems.append(f"{label}: {TARGET} has no stringUnit")
+ continue
+ untranslated = [u for u in units if u.get("state") != "translated"]
+ if untranslated:
+ problems.append(f"{label}: {TARGET} state is {untranslated[0].get('state')!r}, not 'translated'")
+ continue
+
+ expected = specifiers(english_source(key, entry))
+ if "stringUnit" in loc:
+ got = specifiers(loc["stringUnit"]["value"], subs)
+ if got != expected:
+ problems.append(
+ f"{label}: format specifiers differ — en {dict(expected)} vs {TARGET} {dict(got)}"
+ )
+ for kind, variants in loc.get("variations", {}).items():
+ for name, variant in variants.items():
+ for unit in leaves(variant):
+ got = specifiers(unit["value"], subs)
+ # A 'zero'/'one' form may spell the number out; it may not add specifiers.
+ ok = got == expected or (name in ("zero", "one") and not (got - expected))
+ if not ok:
+ problems.append(
+ f"{label} [{kind}.{name}]: format specifiers differ — "
+ f"en {dict(expected)} vs {TARGET} {dict(got)}"
+ )
+ for name, sub in subs.items():
+ for unit in leaves(sub):
+ if specifiers(unit["value"].replace("%arg", "")):
+ problems.append(f"{label} [substitution {name}]: unexpected specifier in {unit['value']!r}")
+ return problems
+
+
+def main():
+ try:
+ catalog = json.loads(CATALOG.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as error:
+ print(f"check-l10n: cannot read {CATALOG}: {error}", file=sys.stderr)
+ return 2
+ problems = check(catalog)
+ total = sum(1 for e in catalog["strings"].values() if e.get("extractionState") != "stale")
+ if problems:
+ print(f"check-l10n: {len(problems)} problem(s) in {CATALOG.name}:", file=sys.stderr)
+ for problem in problems:
+ print(f" - {problem}", file=sys.stderr)
+ return 1
+ print(f"check-l10n: OK — {total} keys, all translated to {TARGET} with matching format specifiers.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())