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