From b928a56fe2a160d36908dfd6488ca6abc5bf45a5 Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 28 Sep 2026 19:23:10 -0700 Subject: [PATCH 1/6] chore(build): stop bundling Config.plist; iPhone-only on Mac and Vision Pro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config.plist sat in Copy Bundle Resources, so whatever key was on disk at archive time shipped inside the IPA (1.0's key was revoked 2026-08-19). The file stays on disk and gitignored for local dev; it is simply no longer a build input. - Drop the Resources build-phase entry (file reference kept). - Retire the placeholder-plist steps in ci.yml and ci_post_clone.sh; a clean checkout now builds without it. - ci_post_clone.sh keeps one gate: refuse any archive with FeatureFlags.tipsEnabled = true, since Tips has no safe key source yet. - New invariant: gotchas.yaml#config-plist-never-bundled, CLAUDE.md §3. - Pending Xcode change: SUPPORTS_MACCATALYST and the "Designed for iPhone/iPad" on Mac and Vision Pro destinations set to NO, plus Xcode's re-sort of the PBXBuildFile/PBXFileReference sections. --- .claude/knowledge/gotchas.yaml | 10 +- .github/workflows/ci.yml | 18 -- CLAUDE.md | 4 +- Savely.xcodeproj/project.pbxproj | 224 ++++++++++++------------ ci_scripts/ci_post_clone.sh | 102 ++--------- docs/plans/drop-bundled-config-plist.md | 41 +++++ docs/xcode-cloud.md | 17 +- 7 files changed, 189 insertions(+), 227 deletions(-) create mode 100644 docs/plans/drop-bundled-config-plist.md diff --git a/.claude/knowledge/gotchas.yaml b/.claude/knowledge/gotchas.yaml index e72785a..3c81281 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a015f0a..2e07948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,24 +68,6 @@ 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: Build run: | set -o pipefail 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/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' - 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/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 From 0c6696217662b723ddd2e7fc5295bf84db487ad3 Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 28 Sep 2026 19:23:10 -0700 Subject: [PATCH 2/6] chore: drop the openai.com ATS exception from Info.plist Savely 1.0 makes no network calls (Tips is flagged off), but Info.plist still declared an App Transport Security exception for openai.com, visible to App Review and at odds with the "Data Not Collected" privacy answers. ITSAppUsesNonExemptEncryption stays; it is load-bearing for upload. The leftover GoogleService-Info.plist was deleted locally (gitignored, never bundled). The Firebase project itself is still open, owner-only; see docs/plans/dead-api-records.md. --- Savely/Info.plist | 23 ++--------- docs/plans/dead-api-records.md | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) create mode 100644 docs/plans/dead-api-records.md 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/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. From fa32d83d3a07834f7ed11d09a00dc6c7cdad2357 Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 28 Sep 2026 19:23:35 -0700 Subject: [PATCH 3/6] chore(store): add an App Preview footage walk to the screenshot tour testPreviewFootage walks the seeded app at human pace (home, goal, expense, payday auto-move, goal again, trend, privacy, receipt) while `simctl io recordVideo` captures it, and prints BRAGMARK lines with timestamps and tap centers so an editor can cut scenes and draw touch indicators. It also answers the first-launch notification prompt. --- .../StoreScreenshotTourUITests.swift | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) 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 From c096ad7c4dd27535673b8afe56873392658d9d2c Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 28 Sep 2026 19:23:35 -0700 Subject: [PATCH 4/6] chore: ignore /brag launch-video output --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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*/ From 44ce9d0e7e52b07f79dfa8a205c59efeb996ea36 Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 28 Sep 2026 21:06:56 -0700 Subject: [PATCH 5/6] feat(l10n): complete Latin-American Spanish (es-419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mexico is Savely's primary market; 1.0 shipped with 139 of 368 catalog keys untranslated and several screens building English in code. - Catalog: every live key translated (134 added, 267 newly translated), 69 existing values normalized to the product voice (tú, sentence case, no exclamation marks), 51 stale keys from removed features deleted, es-419 plural forms added. Keys kept in Xcode's order. - Hardcoded strings routed through Strings.swift: goal pace status (On track / Behind / 1+ year / /wk), the dashboard greeting, quick-add sheet, the auto-move banner (AttributedString(localized:)), trackers, goal flows, reports and PDF, achievements, notifications, receipt review, profile, view-model errors. Display dates use Date.FormatStyle ("Lunes · 28 de septiembre"), with a sentenceCased helper. - Money tab: "Income" as a title/segment gets its own key so Spanish reads "Ingresos" next to "Gastos"; a single income stays "Ingreso". - Persisted identifiers stay English: ExpenseCategory/IncomeSource gain storageKey (stored) vs displayName (shown); the payday deposit note is the constant GoalDeposits.autoMoveNote. Old rows keep matching; new tests in CategoryPersistenceTests cover the round trip. GoalPaceTests pins en_US instead of assuming the simulator's region. - Knowledge: gotchas.yaml#persisted-identifiers-never-localized, localization.md documents es-419 and the CI gate. --- .claude/knowledge/gotchas.yaml | 5 + .claude/knowledge/localization.md | 6 + Savely/Resources/Localizable.xcstrings | 4432 ++++++++++++----- Savely/Resources/Strings.swift | 176 + Savely/Utilities/AchievementEngine.swift | 20 +- Savely/Utilities/GoalDeposits.swift | 7 +- Savely/Utilities/GoalEditValidation.swift | 10 +- Savely/Utilities/GoalPace.swift | 17 +- Savely/Utilities/MoneyCategories.swift | 28 +- Savely/Utilities/ReportsPDFGenerator.swift | 10 +- .../ViewModels/Camera/ReceiptScanModel.swift | 2 +- .../ExpensesTrackerViewModel.swift | 4 +- .../ViewModels/GoalsTab/GoalsViewModel.swift | 14 +- .../IncomesTab/IncomesTrackerViewModel.swift | 8 +- .../ProfileTab/ProfileViewModel.swift | 10 +- .../Tips/TipsAndSuggestionViewModel.swift | 10 +- .../CameraScanner/ReceiptReviewView.swift | 2 +- Savely/Views/DashboardTab/DashboardView.swift | 57 +- Savely/Views/DashboardTab/ReportsView.swift | 19 +- .../ExpensesTab/ExpenseTrackerView.swift | 38 +- Savely/Views/GoalsTab/AddGoalFlow.swift | 35 +- Savely/Views/GoalsTab/GoalDetailView.swift | 37 +- Savely/Views/GoalsTab/GoalEditSheet.swift | 11 +- Savely/Views/GoalsTab/GoalsView.swift | 7 +- .../Views/IncomesTab/IncomesTrackerView.swift | 14 +- Savely/Views/MainNavigationView.swift | 117 +- Savely/Views/MoneyView.swift | 2 +- Savely/Views/ProfileTab/ProfileView.swift | 14 +- SavelyTests/CategoryPersistenceTests.swift | 55 + SavelyTests/GoalPaceTests.swift | 7 +- 30 files changed, 3712 insertions(+), 1462 deletions(-) diff --git a/.claude/knowledge/gotchas.yaml b/.claude/knowledge/gotchas.yaml index 3c81281..f1a22c1 100644 --- a/.claude/knowledge/gotchas.yaml +++ b/.claude/knowledge/gotchas.yaml @@ -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/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() { From 2b8585d5be16a6b4249a1a5d6ece2a194738704c Mon Sep 17 00:00:00 2001 From: Belli Date: Mon, 28 Sep 2026 21:07:02 -0700 Subject: [PATCH 6/6] ci: gate es-419 catalog completeness and format specifiers scripts/check-l10n.py (python3 stdlib) runs as the Localization completeness step after SwiftLint. It lists any live key without a translated es-419 value (plural variations included) and any translation whose format specifiers differ from the source. --- .github/workflows/ci.yml | 5 ++ scripts/check-l10n.py | 128 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100755 scripts/check-l10n.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e07948..31d73c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,11 @@ jobs: # how to tighten this over time. run: swiftlint lint --reporter github-actions-logging + - 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: | set -o pipefail 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())