diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 8c698ab4b..537ec8835 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -206,7 +206,7 @@ jobs: method - app-store + app-store-connect teamID ${IOS_TEAM_ID} signingStyle @@ -248,7 +248,6 @@ jobs: -destination 'generic/platform=iOS' \ MARKETING_VERSION="$IOS_VERSION" \ CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" \ - PRODUCT_BUNDLE_IDENTIFIER="$APP_BUNDLE_ID" \ archive mkdir -p "$EXPORT_PATH" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 32c1e617d..ef7e88958 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -493,7 +493,7 @@ jobs: - name: Commit and push version bump env: SOURCE_BRANCH: ${{ steps.source_branch.outputs.branch }} - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.VERSION_BUMP_PR_TOKEN || github.token }} REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail @@ -565,14 +565,30 @@ jobs: PR_BODY="This automated PR bumps .sure-version and charts/sure/Chart.yaml after the ${REF_NAME} pre-release. It was opened because the workflow could not push directly to ${SOURCE_BRANCH}." - PR_URL=$(gh pr create \ + set +e + pr_create_output=$(gh pr create \ --repo "$GITHUB_REPOSITORY" \ --head "$BUMP_BRANCH" \ --base "$SOURCE_BRANCH" \ --title "$COMMIT_MESSAGE" \ - --body "$PR_BODY") + --body "$PR_BODY" 2>&1) + pr_create_status=$? + set -e + if [[ $pr_create_status -ne 0 ]]; then + echo "::warning::Pushed ${BUMP_BRANCH}, but could not create the version bump PR: ${pr_create_output}" + if grep -q "GitHub Actions is not permitted to create or approve pull requests" <<< "$pr_create_output"; then + echo "::notice::Enable the repository setting that allows GitHub Actions to create pull requests, or add a VERSION_BUMP_PR_TOKEN secret with contents, pull requests, and workflows permissions." + fi + exit 0 + fi + + PR_URL="$pr_create_output" echo "Created version bump PR: $PR_URL" echo "Dispatching PR checks for $BUMP_BRANCH" - gh workflow run pr.yml --repo "$GITHUB_REPOSITORY" --ref "$BUMP_BRANCH" - gh workflow run chart-ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BUMP_BRANCH" + if ! gh workflow run pr.yml --repo "$GITHUB_REPOSITORY" --ref "$BUMP_BRANCH"; then + echo "::warning::Could not dispatch pr.yml for $BUMP_BRANCH" + fi + if ! gh workflow run chart-ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BUMP_BRANCH"; then + echo "::warning::Could not dispatch chart-ci.yml for $BUMP_BRANCH" + fi diff --git a/.sure-version b/.sure-version index 05d5d9c87..ac4886464 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.2-alpha.7 +0.7.2-alpha.9 diff --git a/Gemfile b/Gemfile index fcb4f7336..5d53f193d 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,7 @@ gem "pg", "~> 1.5" gem "redis", "~> 5.4" # Deployment -gem "puma", ">= 5.0" +gem "puma", ">= 7.2.1" gem "bootsnap", require: false # Assets diff --git a/Gemfile.lock b/Gemfile.lock index 27f5c7a08..29c500ab2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -147,7 +147,7 @@ GEM logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.7) connection_pool (2.5.5) cose (1.3.1) cbor (~> 0.5.9) @@ -215,7 +215,7 @@ GEM event_stream_parser (1.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger @@ -223,7 +223,7 @@ GEM faraday (>= 1, < 3) faraday-multipart (1.1.1) multipart-post (~> 2.0) - faraday-net_http (3.4.2) + faraday-net_http (3.4.4) net-http (~> 0.5) faraday-retry (2.3.2) faraday (~> 2.0) @@ -336,7 +336,7 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - json (2.19.2) + json (2.19.9) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -429,21 +429,21 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.3-aarch64-linux-gnu) + nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-aarch64-linux-musl) + nokogiri (1.19.4-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.3-arm-linux-gnu) + nokogiri (1.19.4-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-arm-linux-musl) + nokogiri (1.19.4-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-musl) + nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) @@ -531,7 +531,7 @@ GEM date stringio public_suffix (7.0.5) - puma (6.6.0) + puma (7.2.1) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) @@ -918,7 +918,7 @@ DEPENDENCIES plaid posthog-ruby propshaft - puma (>= 5.0) + puma (>= 7.2.1) pundit rack-attack (~> 6.6) rack-cors diff --git a/app/assets/tailwind/sure-design-system/_generated.css b/app/assets/tailwind/sure-design-system/_generated.css index d70e5392c..27a345930 100644 --- a/app/assets/tailwind/sure-design-system/_generated.css +++ b/app/assets/tailwind/sure-design-system/_generated.css @@ -7,6 +7,8 @@ @theme { --font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-weight-medium: 500; + --font-weight-semibold: 600; --color-white: #ffffff; --color-black: #0B0B0B; --color-success: var(--color-green-700); diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 583737761..708c0aabf 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -30,7 +30,13 @@ module Assistant Function::GetBudget, Function::ImportBankStatement, Function::SearchFamilyFiles, - Function::CreateGoal + Function::CreateGoal, + Function::GetTags, + Function::CreateTag, + Function::UpdateTag, + Function::GetCategories, + Function::CreateCategory, + Function::UpdateCategory ] end diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb index 4e918cb54..99e4df300 100644 --- a/app/models/assistant/function.rb +++ b/app/models/assistant/function.rb @@ -79,6 +79,10 @@ class Assistant::Function user.family end + def valid_uuid?(str) + UuidFormat.valid?(str) + end + # To save tokens, we provide the AI metadata about the series and a flat array of # raw, formatted values which it can infer dates from def to_ai_time_series(series) diff --git a/app/models/assistant/function/create_category.rb b/app/models/assistant/function/create_category.rb new file mode 100644 index 000000000..8d1a6c76c --- /dev/null +++ b/app/models/assistant/function/create_category.rb @@ -0,0 +1,81 @@ +class Assistant::Function::CreateCategory < Assistant::Function + class << self + def name + "create_category" + end + + def description + <<~INSTRUCTIONS + Creates a new category for the user's family. + + Categories support two levels of hierarchy: a top-level category can have subcategories, + but subcategories cannot have children. Provide parent_id (from get_categories) to make + a subcategory — it will inherit the parent's color automatically. + + If icon is omitted it is suggested from the name. If color is omitted a palette color is used + (ignored for subcategories since color is inherited). Category names must be unique. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Category name (must be unique within the family)" + }, + color: { + type: "string", + description: "Hex color code (e.g. #e99537). Ignored for subcategories. Defaults to a palette color." + }, + icon: { + type: "string", + description: "Lucide icon name (e.g. 'shopping-cart'). Suggested from name if omitted." + }, + parent_id: { + type: "string", + description: "ID of an existing top-level category to nest under (makes this a subcategory). Use get_categories to find ids." + } + } + ) + end + + def call(params = {}) + name = params["name"].to_s.strip + return error("name_required", "Please provide a name for the category.") if name.blank? + + color = params["color"].presence || Category::COLORS.sample + icon = params["icon"].presence || Category.suggested_icon(name) + attrs = { name: name, color: color, lucide_icon: icon } + + if params["parent_id"].present? + return error("parent_not_found", "Parent category with id '#{params["parent_id"]}' not found.") unless valid_uuid?(params["parent_id"]) + parent = family.categories.find_by(id: params["parent_id"]) + return error("parent_not_found", "Parent category with id '#{params["parent_id"]}' not found.") unless parent + attrs[:parent] = parent + end + + category = family.categories.new(attrs) + + if category.save + { success: true, category: serialize(category), message: "Category '#{category.name_with_parent}' created." } + else + error("validation_failed", category.errors.full_messages.join("; ")) + end + end + + private + def serialize(c) + { id: c.id, name: c.name, name_with_parent: c.name_with_parent, color: c.color, icon: c.lucide_icon, parent_id: c.parent_id } + end + + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/app/models/assistant/function/create_tag.rb b/app/models/assistant/function/create_tag.rb new file mode 100644 index 000000000..4acffa838 --- /dev/null +++ b/app/models/assistant/function/create_tag.rb @@ -0,0 +1,56 @@ +class Assistant::Function::CreateTag < Assistant::Function + class << self + def name + "create_tag" + end + + def description + <<~INSTRUCTIONS + Creates a new tag for the user's family. + + Tags are applied to transactions to organize them beyond categories. + If color is omitted, one is chosen automatically from the default palette. + Tag names must be unique within the family. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Tag name (must be unique within the family)" + }, + color: { + type: "string", + description: "Hex color code (e.g. #e99537). If omitted, one is chosen from the default palette." + } + } + ) + end + + def call(params = {}) + name = params["name"].to_s.strip + return error("name_required", "Please provide a name for the tag.") if name.blank? + + color = params["color"].presence || Tag::COLORS.sample + tag = family.tags.new(name: name, color: color) + + if tag.save + { success: true, tag: { id: tag.id, name: tag.name, color: tag.color }, message: "Tag '#{tag.name}' created." } + else + error("validation_failed", tag.errors.full_messages.join("; ")) + end + end + + private + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/app/models/assistant/function/get_categories.rb b/app/models/assistant/function/get_categories.rb new file mode 100644 index 000000000..f7bf643d8 --- /dev/null +++ b/app/models/assistant/function/get_categories.rb @@ -0,0 +1,36 @@ +class Assistant::Function::GetCategories < Assistant::Function + class << self + def name + "get_categories" + end + + def description + <<~INSTRUCTIONS + Returns all categories for the user's family, ordered alphabetically by hierarchy. + + Each entry includes id, name, color, icon, parent_id (null for top-level), and + name_with_parent (e.g. "Food & Drink > Restaurants"). Use this before creating + subcategories or referencing a category by id in update_category. + INSTRUCTIONS + end + end + + def call(params = {}) + categories = family.categories.alphabetically_by_hierarchy + + { + categories: categories.map { |c| + { + id: c.id, + name: c.name, + name_with_parent: c.name_with_parent, + color: c.color, + icon: c.lucide_icon, + parent_id: c.parent_id, + is_subcategory: c.subcategory? + } + }, + total: categories.size + } + end +end diff --git a/app/models/assistant/function/get_tags.rb b/app/models/assistant/function/get_tags.rb new file mode 100644 index 000000000..b4dfdcd98 --- /dev/null +++ b/app/models/assistant/function/get_tags.rb @@ -0,0 +1,25 @@ +class Assistant::Function::GetTags < Assistant::Function + class << self + def name + "get_tags" + end + + def description + <<~INSTRUCTIONS + Returns all tags defined for the user's family, sorted alphabetically. + + Use this when the user wants to see available tags or before referencing + a tag in another operation like create_tag or update_tag. + INSTRUCTIONS + end + end + + def call(params = {}) + tags = family.tags.alphabetically + + { + tags: tags.map { |t| { id: t.id, name: t.name, color: t.color } }, + total: tags.size + } + end +end diff --git a/app/models/assistant/function/update_category.rb b/app/models/assistant/function/update_category.rb new file mode 100644 index 000000000..73e50e3b4 --- /dev/null +++ b/app/models/assistant/function/update_category.rb @@ -0,0 +1,73 @@ +class Assistant::Function::UpdateCategory < Assistant::Function + class << self + def name + "update_category" + end + + def description + <<~INSTRUCTIONS + Updates an existing category's name, color, or icon. + + Use get_categories first to find the category id. At least one of name, color, + or icon must be supplied. Changing a parent's color does not cascade to existing + subcategories (their colors are set at creation time). + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "id" ], + properties: { + id: { + type: "string", + description: "ID of the category to update (use get_categories to find it)" + }, + name: { + type: "string", + description: "New name for the category (optional)" + }, + color: { + type: "string", + description: "New hex color code (optional)" + }, + icon: { + type: "string", + description: "New Lucide icon name (optional)" + } + } + ) + end + + def call(params = {}) + return error("not_found", "Category with id '#{params["id"]}' not found.") unless valid_uuid?(params["id"]) + category = family.categories.find_by(id: params["id"]) + return error("not_found", "Category with id '#{params["id"]}' not found.") unless category + + attrs = {} + attrs[:name] = params["name"].to_s.strip if params["name"].present? + attrs[:color] = params["color"].to_s.strip if params["color"].present? + attrs[:lucide_icon] = params["icon"].to_s.strip if params["icon"].present? + + return error("no_changes", "Provide at least one of name, color, or icon to update.") if attrs.empty? + + if category.update(attrs) + { success: true, category: serialize(category), message: "Category '#{category.name_with_parent}' updated." } + else + error("validation_failed", category.errors.full_messages.join("; ")) + end + end + + private + def serialize(c) + { id: c.id, name: c.name, name_with_parent: c.name_with_parent, color: c.color, icon: c.lucide_icon, parent_id: c.parent_id } + end + + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/app/models/assistant/function/update_tag.rb b/app/models/assistant/function/update_tag.rb new file mode 100644 index 000000000..750b3184b --- /dev/null +++ b/app/models/assistant/function/update_tag.rb @@ -0,0 +1,63 @@ +class Assistant::Function::UpdateTag < Assistant::Function + class << self + def name + "update_tag" + end + + def description + <<~INSTRUCTIONS + Updates an existing tag's name or color. + + Identify the tag by its current name. At least one of new_name or color must be provided. + Use get_tags first to confirm the tag exists. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Current name of the tag to update", + enum: family_tag_names + }, + new_name: { + type: "string", + description: "New name for the tag (optional)" + }, + color: { + type: "string", + description: "New hex color code (optional)" + } + } + ) + end + + def call(params = {}) + tag = family.tags.find_by(name: params["name"].to_s.strip) + return error("not_found", "Tag '#{params["name"]}' not found.") unless tag + + attrs = {} + attrs[:name] = params["new_name"].strip if params["new_name"].present? + attrs[:color] = params["color"].strip if params["color"].present? + + return error("no_changes", "Provide at least one of new_name or color to update.") if attrs.empty? + + if tag.update(attrs) + { success: true, tag: { id: tag.id, name: tag.name, color: tag.color }, message: "Tag updated." } + else + error("validation_failed", tag.errors.full_messages.join("; ")) + end + end + + private + def error(key, message) + { success: false, error: key, message: message } + end +end diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index d32e337f4..7c73857cc 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.2-alpha.7 -appVersion: "0.7.2-alpha.7" +version: 0.7.2-alpha.9 +appVersion: "0.7.2-alpha.9" kubeVersion: ">=1.25.0-0" diff --git a/design/tokens/sure.tokens.json b/design/tokens/sure.tokens.json index a0a9b001a..a245f3b7f 100644 --- a/design/tokens/sure.tokens.json +++ b/design/tokens/sure.tokens.json @@ -11,6 +11,10 @@ "mono": { "$value": "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", "$type": "fontFamily" + }, + "weight": { + "medium": { "$value": 500, "$type": "fontWeight" }, + "semibold": { "$value": 600, "$type": "fontWeight" } } }, diff --git a/mobile/l10n.yaml b/mobile/l10n.yaml new file mode 100644 index 000000000..a4d323c62 --- /dev/null +++ b/mobile/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false diff --git a/mobile/lib/constants/suggested_questions.dart b/mobile/lib/constants/suggested_questions.dart index 818be7652..0a75e6df3 100644 --- a/mobile/lib/constants/suggested_questions.dart +++ b/mobile/lib/constants/suggested_questions.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; /// Suggested questions shown on the empty chat screen. -/// -/// l10n upgrade path: when Flutter localisation is added to the mobile app, -/// replace this const list with a function that accepts [BuildContext] and -/// returns localised strings via AppLocalizations. The call site in -/// _EmptyState requires only a one-line change. -const List<({IconData icon, String text})> suggestedQuestions = [ - (icon: Icons.account_balance_wallet_outlined, text: 'What is my current net worth?'), - (icon: Icons.show_chart, text: 'How has my spending changed this month?'), - (icon: Icons.savings_outlined, text: 'How can I improve my savings rate?'), - (icon: Icons.receipt_long_outlined, text: 'What are my biggest expenses lately?'), -]; +List<({IconData icon, String text})> suggestedQuestions(BuildContext context) { + final l = AppLocalizations.of(context); + return [ + (icon: Icons.account_balance_wallet_outlined, text: l.chatSuggestionNetWorth), + (icon: Icons.show_chart, text: l.chatSuggestionSpending), + (icon: Icons.savings_outlined, text: l.chatSuggestionSavings), + (icon: Icons.receipt_long_outlined, text: l.chatSuggestionExpenses), + ]; +} diff --git a/mobile/lib/l10n/app_en.arb b/mobile/lib/l10n/app_en.arb new file mode 100644 index 000000000..3b04a56c1 --- /dev/null +++ b/mobile/lib/l10n/app_en.arb @@ -0,0 +1,972 @@ +{ + "@@locale": "en", + + "appTitle": "Sure Finances", + "@appTitle": { "description": "Application title shown in the OS task switcher." }, + + "commonCancel": "Cancel", + "@commonCancel": { "description": "Generic cancel action label." }, + + "commonSave": "Save", + "@commonSave": { "description": "Generic save/confirm action label." }, + + "commonTryAgain": "Try Again", + "@commonTryAgain": { "description": "Generic retry action label." }, + + "commonDelete": "Delete", + "@commonDelete": { "description": "Generic delete action label." }, + + "commonAll": "All", + "@commonAll": { "description": "Label for the 'All' filter chip (currency filter, category filter, etc.)." }, + + "commonRefresh": "Refresh", + "@commonRefresh": { "description": "Generic refresh action label." }, + + "commonClose": "Close", + "@commonClose": { "description": "Generic close/dismiss action label." }, + + "commonUndo": "Undo", + "@commonUndo": { "description": "Generic undo action label." }, + + "chatSuggestionNetWorth": "What is my current net worth?", + "@chatSuggestionNetWorth": { "description": "Suggested chat prompt on the empty assistant screen." }, + + "chatSuggestionSpending": "How has my spending changed this month?", + "@chatSuggestionSpending": { "description": "Suggested chat prompt on the empty assistant screen." }, + + "chatSuggestionSavings": "How can I improve my savings rate?", + "@chatSuggestionSavings": { "description": "Suggested chat prompt on the empty assistant screen." }, + + "chatSuggestionExpenses": "What are my biggest expenses lately?", + "@chatSuggestionExpenses": { "description": "Suggested chat prompt on the empty assistant screen." }, + + "loginEmailLabel": "Email", + "@loginEmailLabel": { "description": "Label for the email text field on the login screen." }, + + "loginEmailRequired": "Email is required", + "@loginEmailRequired": { "description": "Validation error when email field is empty." }, + + "loginEmailInvalid": "Please enter a valid email", + "@loginEmailInvalid": { "description": "Validation error when email format is invalid." }, + + "loginPasswordLabel": "Password", + "@loginPasswordLabel": { "description": "Label for the password field on the login screen." }, + + "loginPasswordRequired": "Password is required", + "@loginPasswordRequired": { "description": "Validation error when password field is empty." }, + + "loginSignIn": "Sign in", + "@loginSignIn": { "description": "Primary sign-in button label." }, + + "loginSignInWithGoogle": "Sign in with Google", + "@loginSignInWithGoogle": { "description": "Google SSO button label." }, + + "loginMfaLabel": "Authentication Code", + "@loginMfaLabel": { "description": "Label for the MFA code input field." }, + + "loginApiKeyLabel": "API Key", + "@loginApiKeyLabel": { "description": "Label for the API key input field." }, + + "navHome": "Home", + "@navHome": { "description": "Bottom navigation label for the Home (dashboard) tab." }, + + "navIntro": "Intro", + "@navIntro": { "description": "Bottom navigation label for the Intro tab." }, + + "navAssistant": "Assistant", + "@navAssistant": { "description": "Bottom navigation label for the AI assistant tab." }, + + "navMore": "More", + "@navMore": { "description": "Bottom navigation label for the More tab." }, + + "dashboardSyncError": "Sync failed", + "@dashboardSyncError": { "description": "Snackbar message when a sync attempt fails." }, + + "dashboardSyncFailed": "Sync failed. Please try again.", + "@dashboardSyncFailed": { "description": "Snackbar message when a sync attempt fails with retry instruction." }, + + "dashboardRefreshing": "Refreshing accounts…", + "@dashboardRefreshing": { "description": "Snackbar message while accounts are being refreshed after a transaction." }, + + "dashboardAccountsUpdated": "Accounts updated", + "@dashboardAccountsUpdated": { "description": "Snackbar message shown after accounts refresh completes." }, + + "dashboardSyncing": "Syncing data from server…", + "@dashboardSyncing": { "description": "Snackbar message while sync is in progress." }, + + "dashboardSynced": "Synced", + "@dashboardSynced": { "description": "Label shown in the sync status indicator after a successful sync." }, + + "dashboardErrorLoadingAccounts": "Failed to load accounts", + "@dashboardErrorLoadingAccounts": { "description": "Error state message when accounts fail to load." }, + + "dashboardNoAccounts": "No accounts yet", + "@dashboardNoAccounts": { "description": "Empty state message when no accounts exist." }, + + "dashboardNoAccountsSubtitle": "Add accounts in the web app to see them here.", + "@dashboardNoAccountsSubtitle": { "description": "Empty state subtitle when no accounts exist." }, + + "dashboardFilterEmpty": "No accounts match the current filter", + "@dashboardFilterEmpty": { "description": "Empty state when the active account filter returns no results." }, + + "chatListTitle": "Chats", + "@chatListTitle": { "description": "Title for the chat list screen / app bar." }, + + "chatListNewChat": "New chat", + "@chatListNewChat": { "description": "FAB tooltip for creating a new chat." }, + + "chatListEmpty": "No chats yet", + "@chatListEmpty": { "description": "Empty-state heading on the chat list screen." }, + + "chatListEmptySubtitle": "Start a conversation with your AI assistant", + "@chatListEmptySubtitle": { "description": "Empty-state subtitle on the chat list screen." }, + + "chatListDeleteTitle": "Delete Chat", + "@chatListDeleteTitle": { "description": "Title for the delete-chat confirmation dialog." }, + + "chatConversationNewTitle": "New Conversation", + "@chatConversationNewTitle": { "description": "Placeholder title for a new conversation before it is saved." }, + + "chatConversationMessageHint": "Ask anything about your finances…", + "@chatConversationMessageHint": { "description": "Hint text inside the chat message input field." }, + + "chatConversationGreetingWithName": "Hi {firstName}, how can I help?", + "@chatConversationGreetingWithName": { + "description": "Greeting shown in a new conversation when the user's first name is known.", + "placeholders": { "firstName": { "type": "String" } } + }, + + "chatConversationGreetingNoName": "Hi there, how can I help?", + "@chatConversationGreetingNoName": { "description": "Greeting shown in a new conversation when the user's first name is not available." }, + + "transactionFormNewTitle": "New Transaction", + "@transactionFormNewTitle": { "description": "App bar title for the new-transaction form." }, + + "transactionFormTypeLabel": "Type", + "@transactionFormTypeLabel": { "description": "Label for the transaction type segmented control." }, + + "transactionFormTypeExpense": "Expense", + "@transactionFormTypeExpense": { "description": "Segment label for an expense transaction." }, + + "transactionFormTypeIncome": "Income", + "@transactionFormTypeIncome": { "description": "Segment label for an income transaction." }, + + "transactionFormAmountLabel": "Amount", + "@transactionFormAmountLabel": { "description": "Label for the amount input field." }, + + "transactionFormAmountRequired": "Amount is required", + "@transactionFormAmountRequired": { "description": "Validation error when amount is empty." }, + + "transactionFormAmountInvalid": "Please enter a valid amount", + "@transactionFormAmountInvalid": { "description": "Validation error when amount is not a number." }, + + "transactionFormDateLabel": "Date", + "@transactionFormDateLabel": { "description": "Label for the date picker field." }, + + "transactionFormNameLabel": "Name", + "@transactionFormNameLabel": { "description": "Label for the transaction name/payee field." }, + + "transactionFormCategoryLabel": "Category", + "@transactionFormCategoryLabel": { "description": "Label for the category picker field." }, + + "transactionEditTitle": "Edit Transaction", + "@transactionEditTitle": { "description": "App bar title for the edit-transaction screen." }, + + "transactionEditNameLabel": "Name", + "@transactionEditNameLabel": { "description": "Label for the name field in the edit form." }, + + "transactionEditNameRequired": "Name is required", + "@transactionEditNameRequired": { "description": "Validation error when name is empty in edit form." }, + + "transactionEditNotesLabel": "Notes", + "@transactionEditNotesLabel": { "description": "Label for the notes field in the edit form." }, + + "transactionEditCategoryLabel": "Category", + "@transactionEditCategoryLabel": { "description": "Label for the category field in the edit form." }, + + "transactionEditMerchantLabel": "Merchant", + "@transactionEditMerchantLabel": { "description": "Label for the merchant field in the edit form." }, + + "transactionEditTagsLabel": "Tags", + "@transactionEditTagsLabel": { "description": "Label for the tags field in the edit form." }, + + "transactionEditSaving": "Saving…", + "@transactionEditSaving": { "description": "In-progress label shown on the save button while saving." }, + + "transactionsListDeleteTitle": "Delete Transaction", + "@transactionsListDeleteTitle": { "description": "Title for the single-transaction delete confirmation dialog." }, + + "transactionsListDeleteSingleContent": "Are you sure you want to delete \"{name}\"?", + "@transactionsListDeleteSingleContent": { + "description": "Body of the delete confirmation dialog for a named transaction.", + "placeholders": { "name": { "type": "String" } } + }, + + "transactionsListDeleteMultiTitle": "Delete Transactions", + "@transactionsListDeleteMultiTitle": { "description": "Title for the multi-transaction delete confirmation dialog." }, + + "transactionsListDeleteMultiContent": "Are you sure you want to delete the selected transactions?", + "@transactionsListDeleteMultiContent": { "description": "Body of the multi-transaction delete confirmation dialog." }, + + "transactionsListEmpty": "No transactions", + "@transactionsListEmpty": { "description": "Empty-state message when no transactions exist." }, + + "transactionsListAuthFailed": "Authentication failed: Please log in again", + "@transactionsListAuthFailed": { "description": "Snackbar message when the API returns an auth failure." }, + + "transactionsListNoTransactionsYet": "No transactions yet", + "@transactionsListNoTransactionsYet": { "description": "Empty-state heading when an account has no transactions." }, + + "transactionsListEmptyAddFirst": "Tap + to add your first transaction", + "@transactionsListEmptyAddFirst": { "description": "Empty-state subtitle prompting the user to add their first transaction." }, + + "transactionsListNoCategoryMatch": "No transactions match this category", + "@transactionsListNoCategoryMatch": { "description": "Empty state shown when category filter returns no transactions." }, + + "transactionsListRetry": "Retry", + "@transactionsListRetry": { "description": "Button label on the transactions error state." }, + + "transactionsListDeletedSuccess": "Transaction deleted", + "@transactionsListDeletedSuccess": { "description": "Snackbar after a single transaction is deleted." }, + + "transactionsListSingleDeleteFailed": "Failed to delete transaction", + "@transactionsListSingleDeleteFailed": { "description": "Snackbar when deleting a single transaction fails." }, + + "transactionsListDeletedMulti": "{count, plural, one{Deleted {count} transaction} other{Deleted {count} transactions}}", + "@transactionsListDeletedMulti": { + "description": "Snackbar message after multiple transactions are deleted.", + "placeholders": { "count": { "type": "int" } } + }, + + "transactionsListDeleteFailed": "Failed to delete transactions", + "@transactionsListDeleteFailed": { "description": "Snackbar when multi-delete fails." }, + + "transactionsListDeleteNoToken": "Failed to delete: No access token", + "@transactionsListDeleteNoToken": { "description": "Snackbar when delete fails due to missing token." }, + + "transactionsListUndoTitle": "Undo Transaction", + "@transactionsListUndoTitle": { "description": "Title for the undo-transaction dialog." }, + + "transactionsListUndoRemovePending": "Remove this pending transaction?", + "@transactionsListUndoRemovePending": { "description": "Confirmation question for removing a pending transaction." }, + + "transactionsListUndoRestoreConfirm": "Restore this transaction?", + "@transactionsListUndoRestoreConfirm": { "description": "Confirmation question for restoring a transaction." }, + + "transactionsListUndoPendingRemoved": "Pending transaction removed", + "@transactionsListUndoPendingRemoved": { "description": "Snackbar after a pending transaction is removed." }, + + "transactionsListUndoRestored": "Transaction restored", + "@transactionsListUndoRestored": { "description": "Snackbar after a transaction is restored." }, + + "transactionsListUndoFailed": "Failed to undo transaction", + "@transactionsListUndoFailed": { "description": "Snackbar when undoing a transaction fails." }, + + "settingsSectionDisplay": "Display", + "@settingsSectionDisplay": { "description": "Settings section header for display/appearance options." }, + + "settingsSectionConnection": "Connection", + "@settingsSectionConnection": { "description": "Settings section header for server/connection options." }, + + "settingsSectionDataManagement": "Data Management", + "@settingsSectionDataManagement": { "description": "Settings section header for data import/export options." }, + + "settingsSectionSecurity": "Security", + "@settingsSectionSecurity": { "description": "Settings section header for security options." }, + + "settingsSectionDangerZone": "Danger Zone", + "@settingsSectionDangerZone": { "description": "Settings section header for destructive actions." }, + + "settingsThemeLabel": "Theme", + "@settingsThemeLabel": { "description": "Label for the theme picker setting." }, + + "settingsThemeSystem": "System", + "@settingsThemeSystem": { "description": "Theme option: follow system setting." }, + + "settingsThemeLight": "Light", + "@settingsThemeLight": { "description": "Theme option: light mode." }, + + "settingsThemeDark": "Dark", + "@settingsThemeDark": { "description": "Theme option: dark mode." }, + + "settingsProxyHeadersLabel": "Custom Proxy Headers", + "@settingsProxyHeadersLabel": { "description": "Label for the custom proxy headers setting." }, + + "settingsBiometricLabel": "Biometric Lock", + "@settingsBiometricLabel": { "description": "Label for the biometric lock toggle." }, + + "settingsBiometricEnable": "Enable biometric lock?", + "@settingsBiometricEnable": { "description": "Title of the dialog to enable biometric lock." }, + + "settingsBiometricEnableContent": "Require biometric authentication when resuming the app.", + "@settingsBiometricEnableContent": { "description": "Body text of the enable-biometric dialog." }, + + "settingsCheckForUpdates": "Check for Updates", + "@settingsCheckForUpdates": { "description": "Label for the check-for-updates action." }, + + "settingsUpdateAvailableTitle": "Update Available", + "@settingsUpdateAvailableTitle": { "description": "Title for the update-available dialog." }, + + "settingsUpdateAvailableContent": "Version {version} is available. Update now?", + "@settingsUpdateAvailableContent": { + "description": "Body text for the update-available dialog.", + "placeholders": { "version": { "type": "String" } } + }, + + "settingsUpdateNewerVersionFallback": "a newer version", + "@settingsUpdateNewerVersionFallback": { "description": "Fallback used in the update-available dialog when the store version number is unknown (e.g. 'Version a newer version is available')." }, + + "settingsUpdateNow": "Update Now", + "@settingsUpdateNow": { "description": "Confirm button in the update-available dialog." }, + + "settingsNoUpdateAvailable": "You're on the latest version.", + "@settingsNoUpdateAvailable": { "description": "Snackbar when no update is available." }, + + "settingsUpdateError": "Could not check for updates.", + "@settingsUpdateError": { "description": "Snackbar when the update check fails." }, + + "settingsClearDataTitle": "Clear All Data", + "@settingsClearDataTitle": { "description": "Title for the clear-all-data confirmation dialog." }, + + "settingsClearDataContent": "This will remove all locally cached data. Your data on the server will not be affected.", + "@settingsClearDataContent": { "description": "Body text for the clear-all-data dialog." }, + + "settingsClearData": "Clear Data", + "@settingsClearData": { "description": "Confirm button in the clear-data dialog." }, + + "settingsClearDataSuccess": "Local data cleared", + "@settingsClearDataSuccess": { "description": "Snackbar after local data is cleared." }, + + "settingsDeleteAccountTitle": "Delete Account", + "@settingsDeleteAccountTitle": { "description": "Title for the delete-account confirmation dialog." }, + + "settingsDeleteAccount": "Delete Account", + "@settingsDeleteAccount": { "description": "Confirm button in the delete-account dialog." }, + + "settingsSignOutTitle": "Sign Out", + "@settingsSignOutTitle": { "description": "Title for the sign-out confirmation dialog." }, + + "settingsSignOutContent": "Are you sure you want to sign out?", + "@settingsSignOutContent": { "description": "Body text for the sign-out dialog." }, + + "settingsSignOut": "Sign Out", + "@settingsSignOut": { "description": "Confirm button in the sign-out dialog and settings list tile label." }, + + "settingsDebugLogs": "Debug Logs", + "@settingsDebugLogs": { "description": "Label for the debug logs navigation tile." }, + + "ssoOnboardingTitle": "Link Your Account", + "@ssoOnboardingTitle": { "description": "App bar title for the SSO onboarding screen." }, + + "ssoOnboardingTabLink": "Link existing", + "@ssoOnboardingTabLink": { "description": "Tab label for linking an existing Sure account via SSO." }, + + "ssoOnboardingTabCreate": "Create new", + "@ssoOnboardingTabCreate": { "description": "Tab label for creating a new Sure account via SSO." }, + + "ssoOnboardingFirstNameLabel": "First Name", + "@ssoOnboardingFirstNameLabel": { "description": "Label for the first-name field on the SSO onboarding screen." }, + + "ssoOnboardingLastNameLabel": "Last Name", + "@ssoOnboardingLastNameLabel": { "description": "Label for the last-name field on the SSO onboarding screen." }, + + "ssoOnboardingLinkButton": "Link Account", + "@ssoOnboardingLinkButton": { "description": "Button label to link an existing account." }, + + "ssoOnboardingCreateButton": "Create Account", + "@ssoOnboardingCreateButton": { "description": "Button label to create a new account." }, + + "ssoOnboardingAcceptInvitation": "Accept Invitation", + "@ssoOnboardingAcceptInvitation": { "description": "Tab and submit button label when the user has a pending household invitation to accept." }, + + "calendarTitle": "Account Calendar", + "@calendarTitle": { "description": "App bar title for the account calendar screen." }, + + "calendarAccountTypeSection": "Account Type", + "@calendarAccountTypeSection": { "description": "Section header for the account-type selector on the calendar screen." }, + + "calendarSegmentAssets": "Assets", + "@calendarSegmentAssets": { "description": "Segment label for asset-type accounts on the calendar." }, + + "calendarSegmentLiabilities": "Liabilities", + "@calendarSegmentLiabilities": { "description": "Segment label for liability-type accounts on the calendar." }, + + "calendarSelectAccount": "Select Account", + "@calendarSelectAccount": { "description": "Placeholder shown in the account dropdown when none is selected." }, + + "calendarMonthlyChange": "Monthly Change", + "@calendarMonthlyChange": { "description": "Label for the monthly-change metric on the calendar screen." }, + + "calendarNoTransactions": "No transactions on this day", + "@calendarNoTransactions": { "description": "Empty-state message when no transactions exist for the selected day." }, + + "moreCalendar": "Account Calendar", + "@moreCalendar": { "description": "Menu item title for Account Calendar in the More screen." }, + + "moreCalendarSubtitle": "View monthly balance changes by account", + "@moreCalendarSubtitle": { "description": "Subtitle for the Account Calendar menu item in the More screen." }, + + "moreRecentTransactions": "Recent Transactions", + "@moreRecentTransactions": { "description": "Menu item title for Recent Transactions in the More screen." }, + + "moreRecentTransactionsSubtitle": "View recent transactions across all accounts", + "@moreRecentTransactionsSubtitle": { "description": "Subtitle for the Recent Transactions menu item." }, + + "biometricTitle": "App Locked", + "@biometricTitle": { "description": "Heading shown on the biometric lock screen." }, + + "biometricSubtitle": "Authenticate to continue", + "@biometricSubtitle": { "description": "Subtitle shown on the biometric lock screen." }, + + "biometricUnlock": "Unlock", + "@biometricUnlock": { "description": "Button label when biometric prompt is ready." }, + + "biometricAuthenticating": "Authenticating…", + "@biometricAuthenticating": { "description": "Button label while the biometric prompt is in progress." }, + + "biometricLogOut": "Log out", + "@biometricLogOut": { "description": "Button to log out from the biometric lock screen." }, + + "backendConfigTitle": "Configuration", + "@backendConfigTitle": { "description": "Headline on the backend configuration screen." }, + + "backendConfigSubtitle": "Update your Sure server URL", + "@backendConfigSubtitle": { "description": "Subtitle below the headline on the backend configuration screen." }, + + "backendConfigExampleUrlsLabel": "Example URLs", + "@backendConfigExampleUrlsLabel": { "description": "Label for the example URLs info box on the config screen." }, + + "backendConfigUrlLabel": "Sure server URL", + "@backendConfigUrlLabel": { "description": "Label for the server URL field on the config screen." }, + + "backendConfigUrlHint": "https://app.sure.am", + "@backendConfigUrlHint": { "description": "Hint text for the server URL field." }, + + "backendConfigProxyHeadersLabel": "Custom proxy headers", + "@backendConfigProxyHeadersLabel": { "description": "Label for the custom proxy headers section on the config screen." }, + + "backendConfigProxyHeadersSubtitle": "Optional headers for a reverse proxy or auth gateway", + "@backendConfigProxyHeadersSubtitle": { "description": "Subtitle for the proxy headers expansion tile when no headers are configured." }, + + "backendConfigProxyHeadersCount": "{count} configured", + "@backendConfigProxyHeadersCount": { + "description": "Subtitle for the proxy headers expansion tile when headers are configured.", + "placeholders": { "count": { "type": "int" } } + }, + + "backendConfigTesting": "Testing…", + "@backendConfigTesting": { "description": "Label on the test-connection button while the test is in progress." }, + + "backendConfigTestButton": "Test Connection", + "@backendConfigTestButton": { "description": "Button label to test the server connection." }, + + "backendConfigContinueButton": "Continue", + "@backendConfigContinueButton": { "description": "Button label to proceed after saving configuration." }, + + "backendConfigChangeHint": "You can change this later in the settings.", + "@backendConfigChangeHint": { "description": "Helper text below the config form." }, + + "recentTransactionsTitle": "Recent Transactions", + "@recentTransactionsTitle": { "description": "App bar title for the recent transactions screen." }, + + "recentTransactionsEmpty": "No Transactions", + "@recentTransactionsEmpty": { "description": "Empty-state message on the recent transactions screen." }, + + "recentTransactionsDisplayLimit": "Display Limit", + "@recentTransactionsDisplayLimit": { "description": "Tooltip for the display-limit popup menu on the recent transactions screen." }, + + "recentTransactionsShowN": "Show {count}", + "@recentTransactionsShowN": { + "description": "Menu item label to show N recent transactions.", + "placeholders": { "count": { "type": "int" } } + }, + + "recentTransactionsPullToRefresh": "Pull to refresh", + "@recentTransactionsPullToRefresh": { "description": "Empty-state subtitle prompting the user to pull to refresh." }, + + "logViewerTitle": "Debug Logs", + "@logViewerTitle": { "description": "App bar title for the log viewer screen." }, + + "logViewerFilterAll": "All", + "@logViewerFilterAll": { "description": "Log level filter chip: show all log entries." }, + + "logViewerFilterInfo": "Info", + "@logViewerFilterInfo": { "description": "Log level filter chip: show info entries only." }, + + "logViewerFilterWarning": "Warning", + "@logViewerFilterWarning": { "description": "Log level filter chip: show warning entries only." }, + + "logViewerFilterError": "Error", + "@logViewerFilterError": { "description": "Log level filter chip: show error entries only." }, + + "logViewerFilterDebug": "Debug", + "@logViewerFilterDebug": { "description": "Log level filter chip: show debug entries only." }, + + "logViewerAutoScrollEnable": "Enable auto-scroll", + "@logViewerAutoScrollEnable": { "description": "Tooltip for the auto-scroll toggle button when auto-scroll is off." }, + + "logViewerAutoScrollDisable": "Disable auto-scroll", + "@logViewerAutoScrollDisable": { "description": "Tooltip for the auto-scroll toggle button when auto-scroll is on." }, + + "logViewerCopyLogs": "Copy logs", + "@logViewerCopyLogs": { "description": "Tooltip for the copy-logs action button." }, + + "logViewerClearLogs": "Clear logs", + "@logViewerClearLogs": { "description": "Tooltip for the clear-logs action button." }, + + "logViewerLogsCopied": "Logs copied to clipboard", + "@logViewerLogsCopied": { "description": "Snackbar message after logs are copied to the clipboard." }, + + "logViewerEmpty": "No logs yet", + "@logViewerEmpty": { "description": "Empty-state message shown when there are no log entries to display." }, + + "connectivityOffline": "You are offline", + "@connectivityOffline": { "description": "Message shown in the connectivity banner when the device has no network." }, + + "connectivityPendingSync": "{count, plural, one{{count} transaction pending sync} other{{count} transactions pending sync}}", + "@connectivityPendingSync": { + "description": "Connectivity banner message showing how many transactions are queued for sync.", + "placeholders": { "count": { "type": "int" } } + }, + + "connectivitySyncNow": "Sync Now", + "@connectivitySyncNow": { "description": "Button label in the connectivity banner to trigger an immediate sync." }, + + "proxyHeadersAddHeader": "Add header", + "@proxyHeadersAddHeader": { "description": "Button label to add a new custom proxy header." }, + + "proxyHeadersNameLabel": "Header name", + "@proxyHeadersNameLabel": { "description": "Label for the header-name input field." }, + + "proxyHeadersNameHint": "X-Auth-Token", + "@proxyHeadersNameHint": { "description": "Hint text for the header-name input (example header name)." }, + + "proxyHeadersValueLabel": "Header value", + "@proxyHeadersValueLabel": { "description": "Label for the header-value input field." }, + + "proxyHeadersRemove": "Remove header", + "@proxyHeadersRemove": { "description": "Tooltip for the remove-header icon button." }, + + "accountDetailRefreshTooltip": "Refresh account details", + "@accountDetailRefreshTooltip": { "description": "Tooltip for the refresh button on the account detail header." }, + + "accountDetailRecentBalanceHistory": "Recent balance history", + "@accountDetailRecentBalanceHistory": { "description": "Section heading for the balance history chart in the account detail header." }, + + "accountDetailTopHoldings": "Top holdings", + "@accountDetailTopHoldings": { "description": "Section heading for the top holdings list in the account detail header." }, + + "accountDetailHoldingFallback": "Holding", + "@accountDetailHoldingFallback": { "description": "Fallback label when a holding has no name." }, + + "accountDetailCashChip": "Cash {amount}", + "@accountDetailCashChip": { + "description": "Label for the cash position chip in the account detail header.", + "placeholders": { "amount": { "type": "String" } } + }, + + "biometricLockFailedRetry": "Authentication failed. Tap Unlock to try again.", + "@biometricLockFailedRetry": { "description": "Snackbar shown on the biometric lock screen when authentication fails." }, + + "settingsBiometricVerifyReason": "Verify biometric to enable app lock", + "@settingsBiometricVerifyReason": { "description": "Reason shown in the system biometric prompt when enabling app lock from settings." }, + + "settingsBiometricFailed": "Biometric authentication failed.", + "@settingsBiometricFailed": { "description": "Snackbar shown when biometric verification fails while enabling app lock." }, + + "settingsUpdateOpenStoreError": "Unable to open store link", + "@settingsUpdateOpenStoreError": { "description": "Snackbar shown when the app store update link cannot be opened." }, + + "settingsClearDataFailed": "Failed to clear local data.", + "@settingsClearDataFailed": { "description": "Snackbar shown when clearing local data fails." }, + + "settingsClearDataSuccessDetailed": "Local data cleared successfully. Pull to refresh to sync from server.", + "@settingsClearDataSuccessDetailed": { "description": "Snackbar shown after local data is cleared, prompting the user to pull to refresh." }, + + "settingsContactOpenLinkError": "Unable to open link", + "@settingsContactOpenLinkError": { "description": "Snackbar shown when the contact/Discord link cannot be opened." }, + + "settingsResetAccountContent": "Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact.\n\nThis action cannot be undone. Are you sure?", + "@settingsResetAccountContent": { "description": "Body text for the reset-account confirmation dialog." }, + + "settingsResetAccount": "Reset Account", + "@settingsResetAccount": { "description": "Title and confirm button label for the reset-account action." }, + + "settingsResetAccountInitiated": "Account reset has been initiated. This may take a moment.", + "@settingsResetAccountInitiated": { "description": "Snackbar shown after an account reset is initiated." }, + + "settingsResetAccountFailed": "Failed to reset account", + "@settingsResetAccountFailed": { "description": "Snackbar shown when resetting the account fails." }, + + "settingsDeleteAccountConfirmContent": "Deleting your account will permanently remove all your data and cannot be undone.\n\nAre you sure you want to delete your account?", + "@settingsDeleteAccountConfirmContent": { "description": "Body text for the delete-account confirmation dialog on the settings screen." }, + + "settingsDeleteAccountFailed": "Failed to delete account", + "@settingsDeleteAccountFailed": { "description": "Snackbar shown when deleting the account fails." }, + + "settingsProxyHeadersNote": "Headers are sent by the app with API requests. External browser SSO pages may not receive them.", + "@settingsProxyHeadersNote": { "description": "Explanatory note shown in the custom proxy headers dialog." }, + + "settingsProxyHeadersSaved": "Custom proxy headers saved", + "@settingsProxyHeadersSaved": { "description": "Snackbar shown after custom proxy headers are saved." }, + + "settingsProxyHeadersSaveFailed": "Failed to save custom proxy headers.", + "@settingsProxyHeadersSaveFailed": { "description": "Snackbar shown when saving custom proxy headers fails." }, + + "settingsAppVersion": "App Version: {version}", + "@settingsAppVersion": { + "description": "App version list tile title on the settings screen.", + "placeholders": { "version": { "type": "String" } } + }, + + "settingsCheckForUpdatesSubtitle": "See if a newer version is available", + "@settingsCheckForUpdatesSubtitle": { "description": "Subtitle for the check-for-updates list tile." }, + + "settingsContactUs": "Contact us", + "@settingsContactUs": { "description": "Title for the contact-us list tile on the settings screen." }, + + "settingsDebugLogsSemantics": "Open debug logs", + "@settingsDebugLogsSemantics": { "description": "Accessibility label for the debug logs list tile." }, + + "settingsDebugLogsSubtitle": "View app diagnostic logs", + "@settingsDebugLogsSubtitle": { "description": "Subtitle for the debug logs list tile." }, + + "settingsGroupByAccountType": "Group by Account Type", + "@settingsGroupByAccountType": { "description": "Title for the group-by-account-type toggle on the settings screen." }, + + "settingsGroupByAccountTypeSubtitle": "Group accounts by type (Crypto, Bank, etc.)", + "@settingsGroupByAccountTypeSubtitle": { "description": "Subtitle for the group-by-account-type toggle." }, + + "settingsProxyHeadersTileTitle": "Custom proxy headers", + "@settingsProxyHeadersTileTitle": { "description": "Title for the custom proxy headers list tile on the settings screen." }, + + "settingsProxyHeadersTileSubtitleEmpty": "Optional headers for a reverse proxy or auth gateway", + "@settingsProxyHeadersTileSubtitleEmpty": { "description": "Subtitle for the custom proxy headers list tile when no headers are configured." }, + + "settingsProxyHeadersTileSubtitleCount": "{count} configured", + "@settingsProxyHeadersTileSubtitleCount": { + "description": "Subtitle for the custom proxy headers list tile when headers are configured.", + "placeholders": { "count": { "type": "int" } } + }, + + "settingsClearDataTileSubtitle": "Remove all cached transactions and accounts", + "@settingsClearDataTileSubtitle": { "description": "Subtitle for the clear-local-data list tile." }, + + "settingsResetAccountTileSubtitle": "Delete all accounts, categories, merchants, and tags but keep your user account", + "@settingsResetAccountTileSubtitle": { "description": "Subtitle for the reset-account list tile in the danger zone." }, + + "settingsDeleteAccountTileSubtitle": "Permanently remove all your data. This cannot be undone.", + "@settingsDeleteAccountTileSubtitle": { "description": "Subtitle for the delete-account list tile in the danger zone." }, + + "settingsUserFallback": "User", + "@settingsUserFallback": { "description": "Fallback display name when the user has no name set." }, + + "chatListDeleteMultiContent": "{count, plural, one{Delete {count} chat? This cannot be undone.} other{Delete {count} chats? This cannot be undone.}}", + "@chatListDeleteMultiContent": { + "description": "Body text for the multi-chat delete confirmation dialog.", + "placeholders": { "count": { "type": "int" } } + }, + + "chatListDeletedSuccess": "Chats deleted", + "@chatListDeletedSuccess": { "description": "Snackbar shown after multiple chats are deleted." }, + + "chatListDeleteFailed": "Failed to delete chats", + "@chatListDeleteFailed": { "description": "Snackbar shown when deleting multiple chats fails." }, + + "chatListError": "Failed to load chats", + "@chatListError": { "description": "Error-state heading on the chat list screen." }, + + "chatListJustNow": "Just now", + "@chatListJustNow": { "description": "Relative time label for very recent chats." }, + + "chatListDeleteSingleContent": "Are you sure you want to delete \"{title}\"?", + "@chatListDeleteSingleContent": { + "description": "Body text for the single-chat delete confirmation dialog.", + "placeholders": { "title": { "type": "String" } } + }, + + "loginSignUpOpenError": "Unable to open sign up page", + "@loginSignUpOpenError": { "description": "Snackbar shown when the sign-up page cannot be opened." }, + + "loginApiKeyDialogTitle": "API Key Login", + "@loginApiKeyDialogTitle": { "description": "Title for the API key login dialog on the login screen." }, + + "loginApiKeyDialogBody": "Enter your API key to sign in.", + "@loginApiKeyDialogBody": { "description": "Body text for the API key login dialog." }, + + "loginApiKeyInvalid": "Invalid API key", + "@loginApiKeyInvalid": { "description": "Fallback error shown when an API key login fails." }, + + "loginApiKeySignIn": "Sign In", + "@loginApiKeySignIn": { "description": "Sign-in button label in the API key login dialog." }, + + "loginDemoOrSignUpPrefix": "Demo account or ", + "@loginDemoOrSignUpPrefix": { "description": "Leading text before the Sign Up link on the login screen." }, + + "loginSignUpLink": "Sign Up", + "@loginSignUpLink": { "description": "Tappable Sign Up link text on the login screen." }, + + "loginSignUpSuffix": "!", + "@loginSignUpSuffix": { "description": "Trailing punctuation after the Sign Up link on the login screen." }, + + "loginMfaInfo": "Two-factor authentication is enabled. Enter your code.", + "@loginMfaInfo": { "description": "Info banner shown when MFA is required during login." }, + + "loginMfaCodeRequired": "Please enter your authentication code", + "@loginMfaCodeRequired": { "description": "Validation error when the MFA code field is empty." }, + + "loginOrDivider": "or", + "@loginOrDivider": { "description": "Divider label between primary and alternate sign-in options." }, + + "loginServerUrlHeading": "Sure server URL:", + "@loginServerUrlHeading": { "description": "Heading above the displayed server URL on the login screen." }, + + "loginApiKeyLoginButton": "API-Key Login", + "@loginApiKeyLoginButton": { "description": "Button label to open the API key login dialog." }, + + "loginBackendSettingsTooltip": "Backend Settings", + "@loginBackendSettingsTooltip": { "description": "Tooltip for the backend settings button on the login screen." }, + + "transactionEditSessionExpired": "Session expired. Please login again.", + "@transactionEditSessionExpired": { "description": "Snackbar shown when the session expires while editing a transaction." }, + + "transactionEditUpdated": "Transaction updated", + "@transactionEditUpdated": { "description": "Snackbar shown after a transaction is updated successfully." }, + + "transactionEditUpdateFailed": "Failed to update transaction", + "@transactionEditUpdateFailed": { "description": "Fallback snackbar shown when updating a transaction fails." }, + + "transactionEditNameMaxLength": "Name must be {max} characters or fewer", + "@transactionEditNameMaxLength": { + "description": "Validation error when the transaction name exceeds the maximum length.", + "placeholders": { "max": { "type": "int" } } + }, + + "transactionEditNameInvalidChars": "Name contains unsupported characters", + "@transactionEditNameInvalidChars": { "description": "Validation error when the transaction name contains control characters." }, + + "transactionEditNotesMaxLength": "Notes must be {max} characters or fewer", + "@transactionEditNotesMaxLength": { + "description": "Validation error when the notes exceed the maximum length.", + "placeholders": { "max": { "type": "int" } } + }, + + "transactionEditNotesInvalidChars": "Notes contain unsupported characters", + "@transactionEditNotesInvalidChars": { "description": "Validation error when the notes contain control characters." }, + + "transactionEditNoCategory": "No category", + "@transactionEditNoCategory": { "description": "Dropdown option representing no selected category in the edit form." }, + + "transactionEditCurrentCategory": "Current category", + "@transactionEditCurrentCategory": { "description": "Fallback dropdown label for the currently selected category when its name is unknown." }, + + "transactionEditNoMerchant": "No merchant", + "@transactionEditNoMerchant": { "description": "Dropdown option representing no selected merchant in the edit form." }, + + "transactionEditCurrentMerchant": "Current merchant", + "@transactionEditCurrentMerchant": { "description": "Fallback dropdown label for the currently selected merchant when its name is unknown." }, + + "transactionEditNoTags": "No tags available", + "@transactionEditNoTags": { "description": "Message shown when no tags are available to select in the edit form." }, + + "transactionEditUnknownTag": "Unknown tag", + "@transactionEditUnknownTag": { "description": "Fallback label for a selected tag whose name is unknown." }, + + "transactionEditSyncedOnly": "Only synced transactions can be edited from mobile.", + "@transactionEditSyncedOnly": { "description": "Notice shown when a transaction cannot be edited because it is not yet synced." }, + + "transactionEditCategoryHelper": "Choose a replacement category", + "@transactionEditCategoryHelper": { "description": "Helper text under the category dropdown in the edit form." }, + + "transactionEditMerchantHelper": "Choose a replacement merchant", + "@transactionEditMerchantHelper": { "description": "Helper text under the merchant dropdown in the edit form." }, + + "transactionFormSessionExpired": "Session expired. Please login again.", + "@transactionFormSessionExpired": { "description": "Snackbar shown when the session expires while creating a transaction." }, + + "transactionFormAmountRequiredPrompt": "Please enter an amount", + "@transactionFormAmountRequiredPrompt": { "description": "Validation error when the amount field is empty in the create form." }, + + "transactionFormAmountInvalidNumber": "Please enter a valid number", + "@transactionFormAmountInvalidNumber": { "description": "Validation error when the amount is not a valid number." }, + + "transactionFormAmountTooSmall": "Amount must be greater than 0", + "@transactionFormAmountTooSmall": { "description": "Validation error when the amount is zero or negative." }, + + "transactionFormCreateSuccessOnline": "Transaction created successfully", + "@transactionFormCreateSuccessOnline": { "description": "Snackbar shown after a transaction is created while online." }, + + "transactionFormCreateSuccessOffline": "Transaction saved (will sync when online)", + "@transactionFormCreateSuccessOffline": { "description": "Snackbar shown after a transaction is saved while offline." }, + + "transactionFormCreateFailed": "Failed to create transaction", + "@transactionFormCreateFailed": { "description": "Snackbar shown when creating a transaction fails." }, + + "transactionFormGenericError": "Error: {error}", + "@transactionFormGenericError": { + "description": "Snackbar shown when an unexpected error occurs while creating a transaction.", + "placeholders": { "error": { "type": "String" } } + }, + + "transactionFormLess": "Less", + "@transactionFormLess": { "description": "Toggle label to hide the optional transaction fields." }, + + "transactionFormMore": "More", + "@transactionFormMore": { "description": "Toggle label to show the optional transaction fields." }, + + "transactionFormDateHelper": "Optional (default: today)", + "@transactionFormDateHelper": { "description": "Helper text for the date field in the create form." }, + + "transactionFormNameHelper": "Optional (default: SureApp)", + "@transactionFormNameHelper": { "description": "Helper text for the name field in the create form." }, + + "transactionFormCategoryLoading": "Loading categories…", + "@transactionFormCategoryLoading": { "description": "Placeholder shown while categories are loading in the create form." }, + + "transactionFormCategoryHelper": "Optional", + "@transactionFormCategoryHelper": { "description": "Helper text for the category dropdown in the create form." }, + + "transactionFormNoCategory": "No category", + "@transactionFormNoCategory": { "description": "Dropdown option representing no selected category in the create form." }, + + "transactionFormCreateButton": "Create Transaction", + "@transactionFormCreateButton": { "description": "Submit button label on the create-transaction form." }, + + "transactionFormAmountHelper": "Required", + "@transactionFormAmountHelper": { "description": "Helper text under the amount field indicating it is required." }, + + "logViewerClearConfirm": "Are you sure you want to clear all logs?", + "@logViewerClearConfirm": { "description": "Body text for the clear-logs confirmation dialog." }, + + "logViewerClear": "Clear", + "@logViewerClear": { "description": "Confirm button label in the clear-logs dialog." }, + + "chatConversationEditTitle": "Edit Title", + "@chatConversationEditTitle": { "description": "Title for the edit-chat-title dialog." }, + + "chatConversationTitleLabel": "Chat Title", + "@chatConversationTitleLabel": { "description": "Label for the chat title input field in the edit dialog." }, + + "chatConversationRefreshTooltip": "Refresh", + "@chatConversationRefreshTooltip": { "description": "Tooltip for the refresh button in the chat conversation screen." }, + + "chatConversationLoadError": "Failed to load chat", + "@chatConversationLoadError": { "description": "Error-state heading when a chat fails to load." }, + + "navEnableAiChatTitle": "Turn on AI Chat?", + "@navEnableAiChatTitle": { "description": "Title for the dialog prompting the user to enable AI chat." }, + + "navEnableAiChatContent": "AI Chat is currently disabled in your account settings. Would you like to turn it on now?", + "@navEnableAiChatContent": { "description": "Body text for the enable-AI-chat dialog." }, + + "navEnableAiChatNotNow": "Not now", + "@navEnableAiChatNotNow": { "description": "Dismiss button in the enable-AI-chat dialog." }, + + "navEnableAiChatConfirm": "Turn on AI", + "@navEnableAiChatConfirm": { "description": "Confirm button in the enable-AI-chat dialog." }, + + "navEnableAiChatFailed": "Unable to enable AI right now.", + "@navEnableAiChatFailed": { "description": "Snackbar shown when enabling AI chat fails." }, + + "transactionsListEditTooltip": "Edit transaction", + "@transactionsListEditTooltip": { "description": "Tooltip for the edit button on a transaction row." }, + + "connectivitySignInToSync": "Please sign in to sync transactions", + "@connectivitySignInToSync": { "description": "Snackbar shown when trying to sync without being signed in." }, + + "connectivitySyncSuccess": "Transactions synced successfully", + "@connectivitySyncSuccess": { "description": "Snackbar shown after transactions sync successfully." }, + + "connectivitySyncFailed": "Failed to sync transactions. Please try again.", + "@connectivitySyncFailed": { "description": "Snackbar shown when a manual sync fails." }, + + "connectivityAuthFailed": "Unable to authenticate. Please try again.", + "@connectivityAuthFailed": { "description": "Snackbar shown when authentication fails during a manual sync." }, + + "ssoOnboardingSignedInAs": "Signed in as {email}", + "@ssoOnboardingSignedInAs": { + "description": "Header text on the SSO onboarding screen showing the signed-in Google email.", + "placeholders": { "email": { "type": "String" } } + }, + + "ssoOnboardingGoogleVerified": "Google account verified", + "@ssoOnboardingGoogleVerified": { "description": "Header text on the SSO onboarding screen when the Google email is unknown." }, + + "ssoOnboardingLinkCredentialsNote": "Enter your existing account credentials to link with Google Sign-In.", + "@ssoOnboardingLinkCredentialsNote": { "description": "Explanatory note on the link-existing form prompting the user to enter their credentials." }, + + "ssoOnboardingPendingInvitationNote": "You have a pending invitation. Accept it to join an existing household.", + "@ssoOnboardingPendingInvitationNote": { "description": "Explanatory note on the create form when the user has a pending household invitation." }, + + "ssoOnboardingCreateIdentityNote": "Create a new account using your Google identity.", + "@ssoOnboardingCreateIdentityNote": { "description": "Explanatory note on the create form when creating a brand-new account via Google." }, + + "ssoOnboardingFirstNameRequired": "First name is required", + "@ssoOnboardingFirstNameRequired": { "description": "Validation error when the first-name field is empty on the SSO onboarding screen." }, + + "ssoOnboardingLastNameRequired": "Last name is required", + "@ssoOnboardingLastNameRequired": { "description": "Validation error when the last-name field is empty on the SSO onboarding screen." }, + + "backendConfigTimeout": "Connection timeout. Please check the URL and try again.", + "@backendConfigTimeout": { "description": "Error shown when the connection test times out." }, + + "backendConfigSuccess": "Connection successful!", + "@backendConfigSuccess": { "description": "Message shown when the connection test succeeds." }, + + "backendConfigServerError": "Server responded with status {code}. Please check if this is a Sure backend server.", + "@backendConfigServerError": { + "description": "Error shown when the server returns a non-success status during the connection test.", + "placeholders": { "code": { "type": "int" } } + }, + + "backendConfigConnectionFailed": "Connection failed: {error}", + "@backendConfigConnectionFailed": { + "description": "Error shown when the connection test fails with an exception.", + "placeholders": { "error": { "type": "String" } } + }, + + "backendConfigSaveFailed": "Failed to save URL: {error}", + "@backendConfigSaveFailed": { + "description": "Error shown when saving the backend URL fails.", + "placeholders": { "error": { "type": "String" } } + }, + + "backendConfigUrlRequired": "Please enter a backend URL", + "@backendConfigUrlRequired": { "description": "Validation error when the backend URL field is empty." }, + + "backendConfigUrlScheme": "URL must start with http:// or https://", + "@backendConfigUrlScheme": { "description": "Validation error when the backend URL is missing an http(s) scheme." }, + + "backendConfigUrlInvalid": "Please enter a valid URL", + "@backendConfigUrlInvalid": { "description": "Validation error when the backend URL is malformed." }, + + "backendConfigHeadersHelp": "Headers are sent by the app with API requests. External browser SSO pages may not receive them.", + "@backendConfigHeadersHelp": { "description": "Helper text below the custom proxy headers section on the backend config screen." }, + + "recentTransactionsUnknownAccount": "Unknown Account", + "@recentTransactionsUnknownAccount": { "description": "Fallback label when a transaction's account cannot be resolved." }, + + "accountDetailUnavailable": "Account details are temporarily unavailable", + "@accountDetailUnavailable": { "description": "Error message shown when account detail and balances both fail to load." }, + + "chatListMinutesAgo": "{minutes}m ago", + "@chatListMinutesAgo": { + "description": "Relative time label for a chat updated some minutes ago.", + "placeholders": { "minutes": { "type": "int" } } + }, + + "chatListHoursAgo": "{hours}h ago", + "@chatListHoursAgo": { + "description": "Relative time label for a chat updated some hours ago.", + "placeholders": { "hours": { "type": "int" } } + }, + + "chatListDaysAgo": "{days}d ago", + "@chatListDaysAgo": { + "description": "Relative time label for a chat updated some days ago.", + "placeholders": { "days": { "type": "int" } } + }, + + "chatConversationStartFailed": "Failed to start conversation. Please try again.", + "@chatConversationStartFailed": { "description": "Fallback snackbar shown when creating a new conversation from the first message fails." } +} diff --git a/mobile/lib/l10n/app_localizations.dart b/mobile/lib/l10n/app_localizations.dart new file mode 100644 index 000000000..513b00217 --- /dev/null +++ b/mobile/lib/l10n/app_localizations.dart @@ -0,0 +1,1933 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('en')]; + + /// Application title shown in the OS task switcher. + /// + /// In en, this message translates to: + /// **'Sure Finances'** + String get appTitle; + + /// Generic cancel action label. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get commonCancel; + + /// Generic save/confirm action label. + /// + /// In en, this message translates to: + /// **'Save'** + String get commonSave; + + /// Generic retry action label. + /// + /// In en, this message translates to: + /// **'Try Again'** + String get commonTryAgain; + + /// Generic delete action label. + /// + /// In en, this message translates to: + /// **'Delete'** + String get commonDelete; + + /// Label for the 'All' filter chip (currency filter, category filter, etc.). + /// + /// In en, this message translates to: + /// **'All'** + String get commonAll; + + /// Generic refresh action label. + /// + /// In en, this message translates to: + /// **'Refresh'** + String get commonRefresh; + + /// Generic close/dismiss action label. + /// + /// In en, this message translates to: + /// **'Close'** + String get commonClose; + + /// Generic undo action label. + /// + /// In en, this message translates to: + /// **'Undo'** + String get commonUndo; + + /// Suggested chat prompt on the empty assistant screen. + /// + /// In en, this message translates to: + /// **'What is my current net worth?'** + String get chatSuggestionNetWorth; + + /// Suggested chat prompt on the empty assistant screen. + /// + /// In en, this message translates to: + /// **'How has my spending changed this month?'** + String get chatSuggestionSpending; + + /// Suggested chat prompt on the empty assistant screen. + /// + /// In en, this message translates to: + /// **'How can I improve my savings rate?'** + String get chatSuggestionSavings; + + /// Suggested chat prompt on the empty assistant screen. + /// + /// In en, this message translates to: + /// **'What are my biggest expenses lately?'** + String get chatSuggestionExpenses; + + /// Label for the email text field on the login screen. + /// + /// In en, this message translates to: + /// **'Email'** + String get loginEmailLabel; + + /// Validation error when email field is empty. + /// + /// In en, this message translates to: + /// **'Email is required'** + String get loginEmailRequired; + + /// Validation error when email format is invalid. + /// + /// In en, this message translates to: + /// **'Please enter a valid email'** + String get loginEmailInvalid; + + /// Label for the password field on the login screen. + /// + /// In en, this message translates to: + /// **'Password'** + String get loginPasswordLabel; + + /// Validation error when password field is empty. + /// + /// In en, this message translates to: + /// **'Password is required'** + String get loginPasswordRequired; + + /// Primary sign-in button label. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get loginSignIn; + + /// Google SSO button label. + /// + /// In en, this message translates to: + /// **'Sign in with Google'** + String get loginSignInWithGoogle; + + /// Label for the MFA code input field. + /// + /// In en, this message translates to: + /// **'Authentication Code'** + String get loginMfaLabel; + + /// Label for the API key input field. + /// + /// In en, this message translates to: + /// **'API Key'** + String get loginApiKeyLabel; + + /// Bottom navigation label for the Home (dashboard) tab. + /// + /// In en, this message translates to: + /// **'Home'** + String get navHome; + + /// Bottom navigation label for the Intro tab. + /// + /// In en, this message translates to: + /// **'Intro'** + String get navIntro; + + /// Bottom navigation label for the AI assistant tab. + /// + /// In en, this message translates to: + /// **'Assistant'** + String get navAssistant; + + /// Bottom navigation label for the More tab. + /// + /// In en, this message translates to: + /// **'More'** + String get navMore; + + /// Snackbar message when a sync attempt fails. + /// + /// In en, this message translates to: + /// **'Sync failed'** + String get dashboardSyncError; + + /// Snackbar message when a sync attempt fails with retry instruction. + /// + /// In en, this message translates to: + /// **'Sync failed. Please try again.'** + String get dashboardSyncFailed; + + /// Snackbar message while accounts are being refreshed after a transaction. + /// + /// In en, this message translates to: + /// **'Refreshing accounts…'** + String get dashboardRefreshing; + + /// Snackbar message shown after accounts refresh completes. + /// + /// In en, this message translates to: + /// **'Accounts updated'** + String get dashboardAccountsUpdated; + + /// Snackbar message while sync is in progress. + /// + /// In en, this message translates to: + /// **'Syncing data from server…'** + String get dashboardSyncing; + + /// Label shown in the sync status indicator after a successful sync. + /// + /// In en, this message translates to: + /// **'Synced'** + String get dashboardSynced; + + /// Error state message when accounts fail to load. + /// + /// In en, this message translates to: + /// **'Failed to load accounts'** + String get dashboardErrorLoadingAccounts; + + /// Empty state message when no accounts exist. + /// + /// In en, this message translates to: + /// **'No accounts yet'** + String get dashboardNoAccounts; + + /// Empty state subtitle when no accounts exist. + /// + /// In en, this message translates to: + /// **'Add accounts in the web app to see them here.'** + String get dashboardNoAccountsSubtitle; + + /// Empty state when the active account filter returns no results. + /// + /// In en, this message translates to: + /// **'No accounts match the current filter'** + String get dashboardFilterEmpty; + + /// Title for the chat list screen / app bar. + /// + /// In en, this message translates to: + /// **'Chats'** + String get chatListTitle; + + /// FAB tooltip for creating a new chat. + /// + /// In en, this message translates to: + /// **'New chat'** + String get chatListNewChat; + + /// Empty-state heading on the chat list screen. + /// + /// In en, this message translates to: + /// **'No chats yet'** + String get chatListEmpty; + + /// Empty-state subtitle on the chat list screen. + /// + /// In en, this message translates to: + /// **'Start a conversation with your AI assistant'** + String get chatListEmptySubtitle; + + /// Title for the delete-chat confirmation dialog. + /// + /// In en, this message translates to: + /// **'Delete Chat'** + String get chatListDeleteTitle; + + /// Placeholder title for a new conversation before it is saved. + /// + /// In en, this message translates to: + /// **'New Conversation'** + String get chatConversationNewTitle; + + /// Hint text inside the chat message input field. + /// + /// In en, this message translates to: + /// **'Ask anything about your finances…'** + String get chatConversationMessageHint; + + /// Greeting shown in a new conversation when the user's first name is known. + /// + /// In en, this message translates to: + /// **'Hi {firstName}, how can I help?'** + String chatConversationGreetingWithName(String firstName); + + /// Greeting shown in a new conversation when the user's first name is not available. + /// + /// In en, this message translates to: + /// **'Hi there, how can I help?'** + String get chatConversationGreetingNoName; + + /// App bar title for the new-transaction form. + /// + /// In en, this message translates to: + /// **'New Transaction'** + String get transactionFormNewTitle; + + /// Label for the transaction type segmented control. + /// + /// In en, this message translates to: + /// **'Type'** + String get transactionFormTypeLabel; + + /// Segment label for an expense transaction. + /// + /// In en, this message translates to: + /// **'Expense'** + String get transactionFormTypeExpense; + + /// Segment label for an income transaction. + /// + /// In en, this message translates to: + /// **'Income'** + String get transactionFormTypeIncome; + + /// Label for the amount input field. + /// + /// In en, this message translates to: + /// **'Amount'** + String get transactionFormAmountLabel; + + /// Validation error when amount is empty. + /// + /// In en, this message translates to: + /// **'Amount is required'** + String get transactionFormAmountRequired; + + /// Validation error when amount is not a number. + /// + /// In en, this message translates to: + /// **'Please enter a valid amount'** + String get transactionFormAmountInvalid; + + /// Label for the date picker field. + /// + /// In en, this message translates to: + /// **'Date'** + String get transactionFormDateLabel; + + /// Label for the transaction name/payee field. + /// + /// In en, this message translates to: + /// **'Name'** + String get transactionFormNameLabel; + + /// Label for the category picker field. + /// + /// In en, this message translates to: + /// **'Category'** + String get transactionFormCategoryLabel; + + /// App bar title for the edit-transaction screen. + /// + /// In en, this message translates to: + /// **'Edit Transaction'** + String get transactionEditTitle; + + /// Label for the name field in the edit form. + /// + /// In en, this message translates to: + /// **'Name'** + String get transactionEditNameLabel; + + /// Validation error when name is empty in edit form. + /// + /// In en, this message translates to: + /// **'Name is required'** + String get transactionEditNameRequired; + + /// Label for the notes field in the edit form. + /// + /// In en, this message translates to: + /// **'Notes'** + String get transactionEditNotesLabel; + + /// Label for the category field in the edit form. + /// + /// In en, this message translates to: + /// **'Category'** + String get transactionEditCategoryLabel; + + /// Label for the merchant field in the edit form. + /// + /// In en, this message translates to: + /// **'Merchant'** + String get transactionEditMerchantLabel; + + /// Label for the tags field in the edit form. + /// + /// In en, this message translates to: + /// **'Tags'** + String get transactionEditTagsLabel; + + /// In-progress label shown on the save button while saving. + /// + /// In en, this message translates to: + /// **'Saving…'** + String get transactionEditSaving; + + /// Title for the single-transaction delete confirmation dialog. + /// + /// In en, this message translates to: + /// **'Delete Transaction'** + String get transactionsListDeleteTitle; + + /// Body of the delete confirmation dialog for a named transaction. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete \"{name}\"?'** + String transactionsListDeleteSingleContent(String name); + + /// Title for the multi-transaction delete confirmation dialog. + /// + /// In en, this message translates to: + /// **'Delete Transactions'** + String get transactionsListDeleteMultiTitle; + + /// Body of the multi-transaction delete confirmation dialog. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete the selected transactions?'** + String get transactionsListDeleteMultiContent; + + /// Empty-state message when no transactions exist. + /// + /// In en, this message translates to: + /// **'No transactions'** + String get transactionsListEmpty; + + /// Snackbar message when the API returns an auth failure. + /// + /// In en, this message translates to: + /// **'Authentication failed: Please log in again'** + String get transactionsListAuthFailed; + + /// Empty-state heading when an account has no transactions. + /// + /// In en, this message translates to: + /// **'No transactions yet'** + String get transactionsListNoTransactionsYet; + + /// Empty-state subtitle prompting the user to add their first transaction. + /// + /// In en, this message translates to: + /// **'Tap + to add your first transaction'** + String get transactionsListEmptyAddFirst; + + /// Empty state shown when category filter returns no transactions. + /// + /// In en, this message translates to: + /// **'No transactions match this category'** + String get transactionsListNoCategoryMatch; + + /// Button label on the transactions error state. + /// + /// In en, this message translates to: + /// **'Retry'** + String get transactionsListRetry; + + /// Snackbar after a single transaction is deleted. + /// + /// In en, this message translates to: + /// **'Transaction deleted'** + String get transactionsListDeletedSuccess; + + /// Snackbar when deleting a single transaction fails. + /// + /// In en, this message translates to: + /// **'Failed to delete transaction'** + String get transactionsListSingleDeleteFailed; + + /// Snackbar message after multiple transactions are deleted. + /// + /// In en, this message translates to: + /// **'{count, plural, one{Deleted {count} transaction} other{Deleted {count} transactions}}'** + String transactionsListDeletedMulti(int count); + + /// Snackbar when multi-delete fails. + /// + /// In en, this message translates to: + /// **'Failed to delete transactions'** + String get transactionsListDeleteFailed; + + /// Snackbar when delete fails due to missing token. + /// + /// In en, this message translates to: + /// **'Failed to delete: No access token'** + String get transactionsListDeleteNoToken; + + /// Title for the undo-transaction dialog. + /// + /// In en, this message translates to: + /// **'Undo Transaction'** + String get transactionsListUndoTitle; + + /// Confirmation question for removing a pending transaction. + /// + /// In en, this message translates to: + /// **'Remove this pending transaction?'** + String get transactionsListUndoRemovePending; + + /// Confirmation question for restoring a transaction. + /// + /// In en, this message translates to: + /// **'Restore this transaction?'** + String get transactionsListUndoRestoreConfirm; + + /// Snackbar after a pending transaction is removed. + /// + /// In en, this message translates to: + /// **'Pending transaction removed'** + String get transactionsListUndoPendingRemoved; + + /// Snackbar after a transaction is restored. + /// + /// In en, this message translates to: + /// **'Transaction restored'** + String get transactionsListUndoRestored; + + /// Snackbar when undoing a transaction fails. + /// + /// In en, this message translates to: + /// **'Failed to undo transaction'** + String get transactionsListUndoFailed; + + /// Settings section header for display/appearance options. + /// + /// In en, this message translates to: + /// **'Display'** + String get settingsSectionDisplay; + + /// Settings section header for server/connection options. + /// + /// In en, this message translates to: + /// **'Connection'** + String get settingsSectionConnection; + + /// Settings section header for data import/export options. + /// + /// In en, this message translates to: + /// **'Data Management'** + String get settingsSectionDataManagement; + + /// Settings section header for security options. + /// + /// In en, this message translates to: + /// **'Security'** + String get settingsSectionSecurity; + + /// Settings section header for destructive actions. + /// + /// In en, this message translates to: + /// **'Danger Zone'** + String get settingsSectionDangerZone; + + /// Label for the theme picker setting. + /// + /// In en, this message translates to: + /// **'Theme'** + String get settingsThemeLabel; + + /// Theme option: follow system setting. + /// + /// In en, this message translates to: + /// **'System'** + String get settingsThemeSystem; + + /// Theme option: light mode. + /// + /// In en, this message translates to: + /// **'Light'** + String get settingsThemeLight; + + /// Theme option: dark mode. + /// + /// In en, this message translates to: + /// **'Dark'** + String get settingsThemeDark; + + /// Label for the custom proxy headers setting. + /// + /// In en, this message translates to: + /// **'Custom Proxy Headers'** + String get settingsProxyHeadersLabel; + + /// Label for the biometric lock toggle. + /// + /// In en, this message translates to: + /// **'Biometric Lock'** + String get settingsBiometricLabel; + + /// Title of the dialog to enable biometric lock. + /// + /// In en, this message translates to: + /// **'Enable biometric lock?'** + String get settingsBiometricEnable; + + /// Body text of the enable-biometric dialog. + /// + /// In en, this message translates to: + /// **'Require biometric authentication when resuming the app.'** + String get settingsBiometricEnableContent; + + /// Label for the check-for-updates action. + /// + /// In en, this message translates to: + /// **'Check for Updates'** + String get settingsCheckForUpdates; + + /// Title for the update-available dialog. + /// + /// In en, this message translates to: + /// **'Update Available'** + String get settingsUpdateAvailableTitle; + + /// Body text for the update-available dialog. + /// + /// In en, this message translates to: + /// **'Version {version} is available. Update now?'** + String settingsUpdateAvailableContent(String version); + + /// Fallback used in the update-available dialog when the store version number is unknown (e.g. 'Version a newer version is available'). + /// + /// In en, this message translates to: + /// **'a newer version'** + String get settingsUpdateNewerVersionFallback; + + /// Confirm button in the update-available dialog. + /// + /// In en, this message translates to: + /// **'Update Now'** + String get settingsUpdateNow; + + /// Snackbar when no update is available. + /// + /// In en, this message translates to: + /// **'You\'re on the latest version.'** + String get settingsNoUpdateAvailable; + + /// Snackbar when the update check fails. + /// + /// In en, this message translates to: + /// **'Could not check for updates.'** + String get settingsUpdateError; + + /// Title for the clear-all-data confirmation dialog. + /// + /// In en, this message translates to: + /// **'Clear All Data'** + String get settingsClearDataTitle; + + /// Body text for the clear-all-data dialog. + /// + /// In en, this message translates to: + /// **'This will remove all locally cached data. Your data on the server will not be affected.'** + String get settingsClearDataContent; + + /// Confirm button in the clear-data dialog. + /// + /// In en, this message translates to: + /// **'Clear Data'** + String get settingsClearData; + + /// Snackbar after local data is cleared. + /// + /// In en, this message translates to: + /// **'Local data cleared'** + String get settingsClearDataSuccess; + + /// Title for the delete-account confirmation dialog. + /// + /// In en, this message translates to: + /// **'Delete Account'** + String get settingsDeleteAccountTitle; + + /// Confirm button in the delete-account dialog. + /// + /// In en, this message translates to: + /// **'Delete Account'** + String get settingsDeleteAccount; + + /// Title for the sign-out confirmation dialog. + /// + /// In en, this message translates to: + /// **'Sign Out'** + String get settingsSignOutTitle; + + /// Body text for the sign-out dialog. + /// + /// In en, this message translates to: + /// **'Are you sure you want to sign out?'** + String get settingsSignOutContent; + + /// Confirm button in the sign-out dialog and settings list tile label. + /// + /// In en, this message translates to: + /// **'Sign Out'** + String get settingsSignOut; + + /// Label for the debug logs navigation tile. + /// + /// In en, this message translates to: + /// **'Debug Logs'** + String get settingsDebugLogs; + + /// App bar title for the SSO onboarding screen. + /// + /// In en, this message translates to: + /// **'Link Your Account'** + String get ssoOnboardingTitle; + + /// Tab label for linking an existing Sure account via SSO. + /// + /// In en, this message translates to: + /// **'Link existing'** + String get ssoOnboardingTabLink; + + /// Tab label for creating a new Sure account via SSO. + /// + /// In en, this message translates to: + /// **'Create new'** + String get ssoOnboardingTabCreate; + + /// Label for the first-name field on the SSO onboarding screen. + /// + /// In en, this message translates to: + /// **'First Name'** + String get ssoOnboardingFirstNameLabel; + + /// Label for the last-name field on the SSO onboarding screen. + /// + /// In en, this message translates to: + /// **'Last Name'** + String get ssoOnboardingLastNameLabel; + + /// Button label to link an existing account. + /// + /// In en, this message translates to: + /// **'Link Account'** + String get ssoOnboardingLinkButton; + + /// Button label to create a new account. + /// + /// In en, this message translates to: + /// **'Create Account'** + String get ssoOnboardingCreateButton; + + /// Tab and submit button label when the user has a pending household invitation to accept. + /// + /// In en, this message translates to: + /// **'Accept Invitation'** + String get ssoOnboardingAcceptInvitation; + + /// App bar title for the account calendar screen. + /// + /// In en, this message translates to: + /// **'Account Calendar'** + String get calendarTitle; + + /// Section header for the account-type selector on the calendar screen. + /// + /// In en, this message translates to: + /// **'Account Type'** + String get calendarAccountTypeSection; + + /// Segment label for asset-type accounts on the calendar. + /// + /// In en, this message translates to: + /// **'Assets'** + String get calendarSegmentAssets; + + /// Segment label for liability-type accounts on the calendar. + /// + /// In en, this message translates to: + /// **'Liabilities'** + String get calendarSegmentLiabilities; + + /// Placeholder shown in the account dropdown when none is selected. + /// + /// In en, this message translates to: + /// **'Select Account'** + String get calendarSelectAccount; + + /// Label for the monthly-change metric on the calendar screen. + /// + /// In en, this message translates to: + /// **'Monthly Change'** + String get calendarMonthlyChange; + + /// Empty-state message when no transactions exist for the selected day. + /// + /// In en, this message translates to: + /// **'No transactions on this day'** + String get calendarNoTransactions; + + /// Menu item title for Account Calendar in the More screen. + /// + /// In en, this message translates to: + /// **'Account Calendar'** + String get moreCalendar; + + /// Subtitle for the Account Calendar menu item in the More screen. + /// + /// In en, this message translates to: + /// **'View monthly balance changes by account'** + String get moreCalendarSubtitle; + + /// Menu item title for Recent Transactions in the More screen. + /// + /// In en, this message translates to: + /// **'Recent Transactions'** + String get moreRecentTransactions; + + /// Subtitle for the Recent Transactions menu item. + /// + /// In en, this message translates to: + /// **'View recent transactions across all accounts'** + String get moreRecentTransactionsSubtitle; + + /// Heading shown on the biometric lock screen. + /// + /// In en, this message translates to: + /// **'App Locked'** + String get biometricTitle; + + /// Subtitle shown on the biometric lock screen. + /// + /// In en, this message translates to: + /// **'Authenticate to continue'** + String get biometricSubtitle; + + /// Button label when biometric prompt is ready. + /// + /// In en, this message translates to: + /// **'Unlock'** + String get biometricUnlock; + + /// Button label while the biometric prompt is in progress. + /// + /// In en, this message translates to: + /// **'Authenticating…'** + String get biometricAuthenticating; + + /// Button to log out from the biometric lock screen. + /// + /// In en, this message translates to: + /// **'Log out'** + String get biometricLogOut; + + /// Headline on the backend configuration screen. + /// + /// In en, this message translates to: + /// **'Configuration'** + String get backendConfigTitle; + + /// Subtitle below the headline on the backend configuration screen. + /// + /// In en, this message translates to: + /// **'Update your Sure server URL'** + String get backendConfigSubtitle; + + /// Label for the example URLs info box on the config screen. + /// + /// In en, this message translates to: + /// **'Example URLs'** + String get backendConfigExampleUrlsLabel; + + /// Label for the server URL field on the config screen. + /// + /// In en, this message translates to: + /// **'Sure server URL'** + String get backendConfigUrlLabel; + + /// Hint text for the server URL field. + /// + /// In en, this message translates to: + /// **'https://app.sure.am'** + String get backendConfigUrlHint; + + /// Label for the custom proxy headers section on the config screen. + /// + /// In en, this message translates to: + /// **'Custom proxy headers'** + String get backendConfigProxyHeadersLabel; + + /// Subtitle for the proxy headers expansion tile when no headers are configured. + /// + /// In en, this message translates to: + /// **'Optional headers for a reverse proxy or auth gateway'** + String get backendConfigProxyHeadersSubtitle; + + /// Subtitle for the proxy headers expansion tile when headers are configured. + /// + /// In en, this message translates to: + /// **'{count} configured'** + String backendConfigProxyHeadersCount(int count); + + /// Label on the test-connection button while the test is in progress. + /// + /// In en, this message translates to: + /// **'Testing…'** + String get backendConfigTesting; + + /// Button label to test the server connection. + /// + /// In en, this message translates to: + /// **'Test Connection'** + String get backendConfigTestButton; + + /// Button label to proceed after saving configuration. + /// + /// In en, this message translates to: + /// **'Continue'** + String get backendConfigContinueButton; + + /// Helper text below the config form. + /// + /// In en, this message translates to: + /// **'You can change this later in the settings.'** + String get backendConfigChangeHint; + + /// App bar title for the recent transactions screen. + /// + /// In en, this message translates to: + /// **'Recent Transactions'** + String get recentTransactionsTitle; + + /// Empty-state message on the recent transactions screen. + /// + /// In en, this message translates to: + /// **'No Transactions'** + String get recentTransactionsEmpty; + + /// Tooltip for the display-limit popup menu on the recent transactions screen. + /// + /// In en, this message translates to: + /// **'Display Limit'** + String get recentTransactionsDisplayLimit; + + /// Menu item label to show N recent transactions. + /// + /// In en, this message translates to: + /// **'Show {count}'** + String recentTransactionsShowN(int count); + + /// Empty-state subtitle prompting the user to pull to refresh. + /// + /// In en, this message translates to: + /// **'Pull to refresh'** + String get recentTransactionsPullToRefresh; + + /// App bar title for the log viewer screen. + /// + /// In en, this message translates to: + /// **'Debug Logs'** + String get logViewerTitle; + + /// Log level filter chip: show all log entries. + /// + /// In en, this message translates to: + /// **'All'** + String get logViewerFilterAll; + + /// Log level filter chip: show info entries only. + /// + /// In en, this message translates to: + /// **'Info'** + String get logViewerFilterInfo; + + /// Log level filter chip: show warning entries only. + /// + /// In en, this message translates to: + /// **'Warning'** + String get logViewerFilterWarning; + + /// Log level filter chip: show error entries only. + /// + /// In en, this message translates to: + /// **'Error'** + String get logViewerFilterError; + + /// Log level filter chip: show debug entries only. + /// + /// In en, this message translates to: + /// **'Debug'** + String get logViewerFilterDebug; + + /// Tooltip for the auto-scroll toggle button when auto-scroll is off. + /// + /// In en, this message translates to: + /// **'Enable auto-scroll'** + String get logViewerAutoScrollEnable; + + /// Tooltip for the auto-scroll toggle button when auto-scroll is on. + /// + /// In en, this message translates to: + /// **'Disable auto-scroll'** + String get logViewerAutoScrollDisable; + + /// Tooltip for the copy-logs action button. + /// + /// In en, this message translates to: + /// **'Copy logs'** + String get logViewerCopyLogs; + + /// Tooltip for the clear-logs action button. + /// + /// In en, this message translates to: + /// **'Clear logs'** + String get logViewerClearLogs; + + /// Snackbar message after logs are copied to the clipboard. + /// + /// In en, this message translates to: + /// **'Logs copied to clipboard'** + String get logViewerLogsCopied; + + /// Empty-state message shown when there are no log entries to display. + /// + /// In en, this message translates to: + /// **'No logs yet'** + String get logViewerEmpty; + + /// Message shown in the connectivity banner when the device has no network. + /// + /// In en, this message translates to: + /// **'You are offline'** + String get connectivityOffline; + + /// Connectivity banner message showing how many transactions are queued for sync. + /// + /// In en, this message translates to: + /// **'{count, plural, one{{count} transaction pending sync} other{{count} transactions pending sync}}'** + String connectivityPendingSync(int count); + + /// Button label in the connectivity banner to trigger an immediate sync. + /// + /// In en, this message translates to: + /// **'Sync Now'** + String get connectivitySyncNow; + + /// Button label to add a new custom proxy header. + /// + /// In en, this message translates to: + /// **'Add header'** + String get proxyHeadersAddHeader; + + /// Label for the header-name input field. + /// + /// In en, this message translates to: + /// **'Header name'** + String get proxyHeadersNameLabel; + + /// Hint text for the header-name input (example header name). + /// + /// In en, this message translates to: + /// **'X-Auth-Token'** + String get proxyHeadersNameHint; + + /// Label for the header-value input field. + /// + /// In en, this message translates to: + /// **'Header value'** + String get proxyHeadersValueLabel; + + /// Tooltip for the remove-header icon button. + /// + /// In en, this message translates to: + /// **'Remove header'** + String get proxyHeadersRemove; + + /// Tooltip for the refresh button on the account detail header. + /// + /// In en, this message translates to: + /// **'Refresh account details'** + String get accountDetailRefreshTooltip; + + /// Section heading for the balance history chart in the account detail header. + /// + /// In en, this message translates to: + /// **'Recent balance history'** + String get accountDetailRecentBalanceHistory; + + /// Section heading for the top holdings list in the account detail header. + /// + /// In en, this message translates to: + /// **'Top holdings'** + String get accountDetailTopHoldings; + + /// Fallback label when a holding has no name. + /// + /// In en, this message translates to: + /// **'Holding'** + String get accountDetailHoldingFallback; + + /// Label for the cash position chip in the account detail header. + /// + /// In en, this message translates to: + /// **'Cash {amount}'** + String accountDetailCashChip(String amount); + + /// Snackbar shown on the biometric lock screen when authentication fails. + /// + /// In en, this message translates to: + /// **'Authentication failed. Tap Unlock to try again.'** + String get biometricLockFailedRetry; + + /// Reason shown in the system biometric prompt when enabling app lock from settings. + /// + /// In en, this message translates to: + /// **'Verify biometric to enable app lock'** + String get settingsBiometricVerifyReason; + + /// Snackbar shown when biometric verification fails while enabling app lock. + /// + /// In en, this message translates to: + /// **'Biometric authentication failed.'** + String get settingsBiometricFailed; + + /// Snackbar shown when the app store update link cannot be opened. + /// + /// In en, this message translates to: + /// **'Unable to open store link'** + String get settingsUpdateOpenStoreError; + + /// Snackbar shown when clearing local data fails. + /// + /// In en, this message translates to: + /// **'Failed to clear local data.'** + String get settingsClearDataFailed; + + /// Snackbar shown after local data is cleared, prompting the user to pull to refresh. + /// + /// In en, this message translates to: + /// **'Local data cleared successfully. Pull to refresh to sync from server.'** + String get settingsClearDataSuccessDetailed; + + /// Snackbar shown when the contact/Discord link cannot be opened. + /// + /// In en, this message translates to: + /// **'Unable to open link'** + String get settingsContactOpenLinkError; + + /// Body text for the reset-account confirmation dialog. + /// + /// In en, this message translates to: + /// **'Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact.\n\nThis action cannot be undone. Are you sure?'** + String get settingsResetAccountContent; + + /// Title and confirm button label for the reset-account action. + /// + /// In en, this message translates to: + /// **'Reset Account'** + String get settingsResetAccount; + + /// Snackbar shown after an account reset is initiated. + /// + /// In en, this message translates to: + /// **'Account reset has been initiated. This may take a moment.'** + String get settingsResetAccountInitiated; + + /// Snackbar shown when resetting the account fails. + /// + /// In en, this message translates to: + /// **'Failed to reset account'** + String get settingsResetAccountFailed; + + /// Body text for the delete-account confirmation dialog on the settings screen. + /// + /// In en, this message translates to: + /// **'Deleting your account will permanently remove all your data and cannot be undone.\n\nAre you sure you want to delete your account?'** + String get settingsDeleteAccountConfirmContent; + + /// Snackbar shown when deleting the account fails. + /// + /// In en, this message translates to: + /// **'Failed to delete account'** + String get settingsDeleteAccountFailed; + + /// Explanatory note shown in the custom proxy headers dialog. + /// + /// In en, this message translates to: + /// **'Headers are sent by the app with API requests. External browser SSO pages may not receive them.'** + String get settingsProxyHeadersNote; + + /// Snackbar shown after custom proxy headers are saved. + /// + /// In en, this message translates to: + /// **'Custom proxy headers saved'** + String get settingsProxyHeadersSaved; + + /// Snackbar shown when saving custom proxy headers fails. + /// + /// In en, this message translates to: + /// **'Failed to save custom proxy headers.'** + String get settingsProxyHeadersSaveFailed; + + /// App version list tile title on the settings screen. + /// + /// In en, this message translates to: + /// **'App Version: {version}'** + String settingsAppVersion(String version); + + /// Subtitle for the check-for-updates list tile. + /// + /// In en, this message translates to: + /// **'See if a newer version is available'** + String get settingsCheckForUpdatesSubtitle; + + /// Title for the contact-us list tile on the settings screen. + /// + /// In en, this message translates to: + /// **'Contact us'** + String get settingsContactUs; + + /// Accessibility label for the debug logs list tile. + /// + /// In en, this message translates to: + /// **'Open debug logs'** + String get settingsDebugLogsSemantics; + + /// Subtitle for the debug logs list tile. + /// + /// In en, this message translates to: + /// **'View app diagnostic logs'** + String get settingsDebugLogsSubtitle; + + /// Title for the group-by-account-type toggle on the settings screen. + /// + /// In en, this message translates to: + /// **'Group by Account Type'** + String get settingsGroupByAccountType; + + /// Subtitle for the group-by-account-type toggle. + /// + /// In en, this message translates to: + /// **'Group accounts by type (Crypto, Bank, etc.)'** + String get settingsGroupByAccountTypeSubtitle; + + /// Title for the custom proxy headers list tile on the settings screen. + /// + /// In en, this message translates to: + /// **'Custom proxy headers'** + String get settingsProxyHeadersTileTitle; + + /// Subtitle for the custom proxy headers list tile when no headers are configured. + /// + /// In en, this message translates to: + /// **'Optional headers for a reverse proxy or auth gateway'** + String get settingsProxyHeadersTileSubtitleEmpty; + + /// Subtitle for the custom proxy headers list tile when headers are configured. + /// + /// In en, this message translates to: + /// **'{count} configured'** + String settingsProxyHeadersTileSubtitleCount(int count); + + /// Subtitle for the clear-local-data list tile. + /// + /// In en, this message translates to: + /// **'Remove all cached transactions and accounts'** + String get settingsClearDataTileSubtitle; + + /// Subtitle for the reset-account list tile in the danger zone. + /// + /// In en, this message translates to: + /// **'Delete all accounts, categories, merchants, and tags but keep your user account'** + String get settingsResetAccountTileSubtitle; + + /// Subtitle for the delete-account list tile in the danger zone. + /// + /// In en, this message translates to: + /// **'Permanently remove all your data. This cannot be undone.'** + String get settingsDeleteAccountTileSubtitle; + + /// Fallback display name when the user has no name set. + /// + /// In en, this message translates to: + /// **'User'** + String get settingsUserFallback; + + /// Body text for the multi-chat delete confirmation dialog. + /// + /// In en, this message translates to: + /// **'{count, plural, one{Delete {count} chat? This cannot be undone.} other{Delete {count} chats? This cannot be undone.}}'** + String chatListDeleteMultiContent(int count); + + /// Snackbar shown after multiple chats are deleted. + /// + /// In en, this message translates to: + /// **'Chats deleted'** + String get chatListDeletedSuccess; + + /// Snackbar shown when deleting multiple chats fails. + /// + /// In en, this message translates to: + /// **'Failed to delete chats'** + String get chatListDeleteFailed; + + /// Error-state heading on the chat list screen. + /// + /// In en, this message translates to: + /// **'Failed to load chats'** + String get chatListError; + + /// Relative time label for very recent chats. + /// + /// In en, this message translates to: + /// **'Just now'** + String get chatListJustNow; + + /// Body text for the single-chat delete confirmation dialog. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete \"{title}\"?'** + String chatListDeleteSingleContent(String title); + + /// Snackbar shown when the sign-up page cannot be opened. + /// + /// In en, this message translates to: + /// **'Unable to open sign up page'** + String get loginSignUpOpenError; + + /// Title for the API key login dialog on the login screen. + /// + /// In en, this message translates to: + /// **'API Key Login'** + String get loginApiKeyDialogTitle; + + /// Body text for the API key login dialog. + /// + /// In en, this message translates to: + /// **'Enter your API key to sign in.'** + String get loginApiKeyDialogBody; + + /// Fallback error shown when an API key login fails. + /// + /// In en, this message translates to: + /// **'Invalid API key'** + String get loginApiKeyInvalid; + + /// Sign-in button label in the API key login dialog. + /// + /// In en, this message translates to: + /// **'Sign In'** + String get loginApiKeySignIn; + + /// Leading text before the Sign Up link on the login screen. + /// + /// In en, this message translates to: + /// **'Demo account or '** + String get loginDemoOrSignUpPrefix; + + /// Tappable Sign Up link text on the login screen. + /// + /// In en, this message translates to: + /// **'Sign Up'** + String get loginSignUpLink; + + /// Trailing punctuation after the Sign Up link on the login screen. + /// + /// In en, this message translates to: + /// **'!'** + String get loginSignUpSuffix; + + /// Info banner shown when MFA is required during login. + /// + /// In en, this message translates to: + /// **'Two-factor authentication is enabled. Enter your code.'** + String get loginMfaInfo; + + /// Validation error when the MFA code field is empty. + /// + /// In en, this message translates to: + /// **'Please enter your authentication code'** + String get loginMfaCodeRequired; + + /// Divider label between primary and alternate sign-in options. + /// + /// In en, this message translates to: + /// **'or'** + String get loginOrDivider; + + /// Heading above the displayed server URL on the login screen. + /// + /// In en, this message translates to: + /// **'Sure server URL:'** + String get loginServerUrlHeading; + + /// Button label to open the API key login dialog. + /// + /// In en, this message translates to: + /// **'API-Key Login'** + String get loginApiKeyLoginButton; + + /// Tooltip for the backend settings button on the login screen. + /// + /// In en, this message translates to: + /// **'Backend Settings'** + String get loginBackendSettingsTooltip; + + /// Snackbar shown when the session expires while editing a transaction. + /// + /// In en, this message translates to: + /// **'Session expired. Please login again.'** + String get transactionEditSessionExpired; + + /// Snackbar shown after a transaction is updated successfully. + /// + /// In en, this message translates to: + /// **'Transaction updated'** + String get transactionEditUpdated; + + /// Fallback snackbar shown when updating a transaction fails. + /// + /// In en, this message translates to: + /// **'Failed to update transaction'** + String get transactionEditUpdateFailed; + + /// Validation error when the transaction name exceeds the maximum length. + /// + /// In en, this message translates to: + /// **'Name must be {max} characters or fewer'** + String transactionEditNameMaxLength(int max); + + /// Validation error when the transaction name contains control characters. + /// + /// In en, this message translates to: + /// **'Name contains unsupported characters'** + String get transactionEditNameInvalidChars; + + /// Validation error when the notes exceed the maximum length. + /// + /// In en, this message translates to: + /// **'Notes must be {max} characters or fewer'** + String transactionEditNotesMaxLength(int max); + + /// Validation error when the notes contain control characters. + /// + /// In en, this message translates to: + /// **'Notes contain unsupported characters'** + String get transactionEditNotesInvalidChars; + + /// Dropdown option representing no selected category in the edit form. + /// + /// In en, this message translates to: + /// **'No category'** + String get transactionEditNoCategory; + + /// Fallback dropdown label for the currently selected category when its name is unknown. + /// + /// In en, this message translates to: + /// **'Current category'** + String get transactionEditCurrentCategory; + + /// Dropdown option representing no selected merchant in the edit form. + /// + /// In en, this message translates to: + /// **'No merchant'** + String get transactionEditNoMerchant; + + /// Fallback dropdown label for the currently selected merchant when its name is unknown. + /// + /// In en, this message translates to: + /// **'Current merchant'** + String get transactionEditCurrentMerchant; + + /// Message shown when no tags are available to select in the edit form. + /// + /// In en, this message translates to: + /// **'No tags available'** + String get transactionEditNoTags; + + /// Fallback label for a selected tag whose name is unknown. + /// + /// In en, this message translates to: + /// **'Unknown tag'** + String get transactionEditUnknownTag; + + /// Notice shown when a transaction cannot be edited because it is not yet synced. + /// + /// In en, this message translates to: + /// **'Only synced transactions can be edited from mobile.'** + String get transactionEditSyncedOnly; + + /// Helper text under the category dropdown in the edit form. + /// + /// In en, this message translates to: + /// **'Choose a replacement category'** + String get transactionEditCategoryHelper; + + /// Helper text under the merchant dropdown in the edit form. + /// + /// In en, this message translates to: + /// **'Choose a replacement merchant'** + String get transactionEditMerchantHelper; + + /// Snackbar shown when the session expires while creating a transaction. + /// + /// In en, this message translates to: + /// **'Session expired. Please login again.'** + String get transactionFormSessionExpired; + + /// Validation error when the amount field is empty in the create form. + /// + /// In en, this message translates to: + /// **'Please enter an amount'** + String get transactionFormAmountRequiredPrompt; + + /// Validation error when the amount is not a valid number. + /// + /// In en, this message translates to: + /// **'Please enter a valid number'** + String get transactionFormAmountInvalidNumber; + + /// Validation error when the amount is zero or negative. + /// + /// In en, this message translates to: + /// **'Amount must be greater than 0'** + String get transactionFormAmountTooSmall; + + /// Snackbar shown after a transaction is created while online. + /// + /// In en, this message translates to: + /// **'Transaction created successfully'** + String get transactionFormCreateSuccessOnline; + + /// Snackbar shown after a transaction is saved while offline. + /// + /// In en, this message translates to: + /// **'Transaction saved (will sync when online)'** + String get transactionFormCreateSuccessOffline; + + /// Snackbar shown when creating a transaction fails. + /// + /// In en, this message translates to: + /// **'Failed to create transaction'** + String get transactionFormCreateFailed; + + /// Snackbar shown when an unexpected error occurs while creating a transaction. + /// + /// In en, this message translates to: + /// **'Error: {error}'** + String transactionFormGenericError(String error); + + /// Toggle label to hide the optional transaction fields. + /// + /// In en, this message translates to: + /// **'Less'** + String get transactionFormLess; + + /// Toggle label to show the optional transaction fields. + /// + /// In en, this message translates to: + /// **'More'** + String get transactionFormMore; + + /// Helper text for the date field in the create form. + /// + /// In en, this message translates to: + /// **'Optional (default: today)'** + String get transactionFormDateHelper; + + /// Helper text for the name field in the create form. + /// + /// In en, this message translates to: + /// **'Optional (default: SureApp)'** + String get transactionFormNameHelper; + + /// Placeholder shown while categories are loading in the create form. + /// + /// In en, this message translates to: + /// **'Loading categories…'** + String get transactionFormCategoryLoading; + + /// Helper text for the category dropdown in the create form. + /// + /// In en, this message translates to: + /// **'Optional'** + String get transactionFormCategoryHelper; + + /// Dropdown option representing no selected category in the create form. + /// + /// In en, this message translates to: + /// **'No category'** + String get transactionFormNoCategory; + + /// Submit button label on the create-transaction form. + /// + /// In en, this message translates to: + /// **'Create Transaction'** + String get transactionFormCreateButton; + + /// Helper text under the amount field indicating it is required. + /// + /// In en, this message translates to: + /// **'Required'** + String get transactionFormAmountHelper; + + /// Body text for the clear-logs confirmation dialog. + /// + /// In en, this message translates to: + /// **'Are you sure you want to clear all logs?'** + String get logViewerClearConfirm; + + /// Confirm button label in the clear-logs dialog. + /// + /// In en, this message translates to: + /// **'Clear'** + String get logViewerClear; + + /// Title for the edit-chat-title dialog. + /// + /// In en, this message translates to: + /// **'Edit Title'** + String get chatConversationEditTitle; + + /// Label for the chat title input field in the edit dialog. + /// + /// In en, this message translates to: + /// **'Chat Title'** + String get chatConversationTitleLabel; + + /// Tooltip for the refresh button in the chat conversation screen. + /// + /// In en, this message translates to: + /// **'Refresh'** + String get chatConversationRefreshTooltip; + + /// Error-state heading when a chat fails to load. + /// + /// In en, this message translates to: + /// **'Failed to load chat'** + String get chatConversationLoadError; + + /// Title for the dialog prompting the user to enable AI chat. + /// + /// In en, this message translates to: + /// **'Turn on AI Chat?'** + String get navEnableAiChatTitle; + + /// Body text for the enable-AI-chat dialog. + /// + /// In en, this message translates to: + /// **'AI Chat is currently disabled in your account settings. Would you like to turn it on now?'** + String get navEnableAiChatContent; + + /// Dismiss button in the enable-AI-chat dialog. + /// + /// In en, this message translates to: + /// **'Not now'** + String get navEnableAiChatNotNow; + + /// Confirm button in the enable-AI-chat dialog. + /// + /// In en, this message translates to: + /// **'Turn on AI'** + String get navEnableAiChatConfirm; + + /// Snackbar shown when enabling AI chat fails. + /// + /// In en, this message translates to: + /// **'Unable to enable AI right now.'** + String get navEnableAiChatFailed; + + /// Tooltip for the edit button on a transaction row. + /// + /// In en, this message translates to: + /// **'Edit transaction'** + String get transactionsListEditTooltip; + + /// Snackbar shown when trying to sync without being signed in. + /// + /// In en, this message translates to: + /// **'Please sign in to sync transactions'** + String get connectivitySignInToSync; + + /// Snackbar shown after transactions sync successfully. + /// + /// In en, this message translates to: + /// **'Transactions synced successfully'** + String get connectivitySyncSuccess; + + /// Snackbar shown when a manual sync fails. + /// + /// In en, this message translates to: + /// **'Failed to sync transactions. Please try again.'** + String get connectivitySyncFailed; + + /// Snackbar shown when authentication fails during a manual sync. + /// + /// In en, this message translates to: + /// **'Unable to authenticate. Please try again.'** + String get connectivityAuthFailed; + + /// Header text on the SSO onboarding screen showing the signed-in Google email. + /// + /// In en, this message translates to: + /// **'Signed in as {email}'** + String ssoOnboardingSignedInAs(String email); + + /// Header text on the SSO onboarding screen when the Google email is unknown. + /// + /// In en, this message translates to: + /// **'Google account verified'** + String get ssoOnboardingGoogleVerified; + + /// Explanatory note on the link-existing form prompting the user to enter their credentials. + /// + /// In en, this message translates to: + /// **'Enter your existing account credentials to link with Google Sign-In.'** + String get ssoOnboardingLinkCredentialsNote; + + /// Explanatory note on the create form when the user has a pending household invitation. + /// + /// In en, this message translates to: + /// **'You have a pending invitation. Accept it to join an existing household.'** + String get ssoOnboardingPendingInvitationNote; + + /// Explanatory note on the create form when creating a brand-new account via Google. + /// + /// In en, this message translates to: + /// **'Create a new account using your Google identity.'** + String get ssoOnboardingCreateIdentityNote; + + /// Validation error when the first-name field is empty on the SSO onboarding screen. + /// + /// In en, this message translates to: + /// **'First name is required'** + String get ssoOnboardingFirstNameRequired; + + /// Validation error when the last-name field is empty on the SSO onboarding screen. + /// + /// In en, this message translates to: + /// **'Last name is required'** + String get ssoOnboardingLastNameRequired; + + /// Error shown when the connection test times out. + /// + /// In en, this message translates to: + /// **'Connection timeout. Please check the URL and try again.'** + String get backendConfigTimeout; + + /// Message shown when the connection test succeeds. + /// + /// In en, this message translates to: + /// **'Connection successful!'** + String get backendConfigSuccess; + + /// Error shown when the server returns a non-success status during the connection test. + /// + /// In en, this message translates to: + /// **'Server responded with status {code}. Please check if this is a Sure backend server.'** + String backendConfigServerError(int code); + + /// Error shown when the connection test fails with an exception. + /// + /// In en, this message translates to: + /// **'Connection failed: {error}'** + String backendConfigConnectionFailed(String error); + + /// Error shown when saving the backend URL fails. + /// + /// In en, this message translates to: + /// **'Failed to save URL: {error}'** + String backendConfigSaveFailed(String error); + + /// Validation error when the backend URL field is empty. + /// + /// In en, this message translates to: + /// **'Please enter a backend URL'** + String get backendConfigUrlRequired; + + /// Validation error when the backend URL is missing an http(s) scheme. + /// + /// In en, this message translates to: + /// **'URL must start with http:// or https://'** + String get backendConfigUrlScheme; + + /// Validation error when the backend URL is malformed. + /// + /// In en, this message translates to: + /// **'Please enter a valid URL'** + String get backendConfigUrlInvalid; + + /// Helper text below the custom proxy headers section on the backend config screen. + /// + /// In en, this message translates to: + /// **'Headers are sent by the app with API requests. External browser SSO pages may not receive them.'** + String get backendConfigHeadersHelp; + + /// Fallback label when a transaction's account cannot be resolved. + /// + /// In en, this message translates to: + /// **'Unknown Account'** + String get recentTransactionsUnknownAccount; + + /// Error message shown when account detail and balances both fail to load. + /// + /// In en, this message translates to: + /// **'Account details are temporarily unavailable'** + String get accountDetailUnavailable; + + /// Relative time label for a chat updated some minutes ago. + /// + /// In en, this message translates to: + /// **'{minutes}m ago'** + String chatListMinutesAgo(int minutes); + + /// Relative time label for a chat updated some hours ago. + /// + /// In en, this message translates to: + /// **'{hours}h ago'** + String chatListHoursAgo(int hours); + + /// Relative time label for a chat updated some days ago. + /// + /// In en, this message translates to: + /// **'{days}d ago'** + String chatListDaysAgo(int days); + + /// Fallback snackbar shown when creating a new conversation from the first message fails. + /// + /// In en, this message translates to: + /// **'Failed to start conversation. Please try again.'** + String get chatConversationStartFailed; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/mobile/lib/l10n/app_localizations_en.dart b/mobile/lib/l10n/app_localizations_en.dart new file mode 100644 index 000000000..b34be6abb --- /dev/null +++ b/mobile/lib/l10n/app_localizations_en.dart @@ -0,0 +1,1026 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'Sure Finances'; + + @override + String get commonCancel => 'Cancel'; + + @override + String get commonSave => 'Save'; + + @override + String get commonTryAgain => 'Try Again'; + + @override + String get commonDelete => 'Delete'; + + @override + String get commonAll => 'All'; + + @override + String get commonRefresh => 'Refresh'; + + @override + String get commonClose => 'Close'; + + @override + String get commonUndo => 'Undo'; + + @override + String get chatSuggestionNetWorth => 'What is my current net worth?'; + + @override + String get chatSuggestionSpending => + 'How has my spending changed this month?'; + + @override + String get chatSuggestionSavings => 'How can I improve my savings rate?'; + + @override + String get chatSuggestionExpenses => 'What are my biggest expenses lately?'; + + @override + String get loginEmailLabel => 'Email'; + + @override + String get loginEmailRequired => 'Email is required'; + + @override + String get loginEmailInvalid => 'Please enter a valid email'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginPasswordRequired => 'Password is required'; + + @override + String get loginSignIn => 'Sign in'; + + @override + String get loginSignInWithGoogle => 'Sign in with Google'; + + @override + String get loginMfaLabel => 'Authentication Code'; + + @override + String get loginApiKeyLabel => 'API Key'; + + @override + String get navHome => 'Home'; + + @override + String get navIntro => 'Intro'; + + @override + String get navAssistant => 'Assistant'; + + @override + String get navMore => 'More'; + + @override + String get dashboardSyncError => 'Sync failed'; + + @override + String get dashboardSyncFailed => 'Sync failed. Please try again.'; + + @override + String get dashboardRefreshing => 'Refreshing accounts…'; + + @override + String get dashboardAccountsUpdated => 'Accounts updated'; + + @override + String get dashboardSyncing => 'Syncing data from server…'; + + @override + String get dashboardSynced => 'Synced'; + + @override + String get dashboardErrorLoadingAccounts => 'Failed to load accounts'; + + @override + String get dashboardNoAccounts => 'No accounts yet'; + + @override + String get dashboardNoAccountsSubtitle => + 'Add accounts in the web app to see them here.'; + + @override + String get dashboardFilterEmpty => 'No accounts match the current filter'; + + @override + String get chatListTitle => 'Chats'; + + @override + String get chatListNewChat => 'New chat'; + + @override + String get chatListEmpty => 'No chats yet'; + + @override + String get chatListEmptySubtitle => + 'Start a conversation with your AI assistant'; + + @override + String get chatListDeleteTitle => 'Delete Chat'; + + @override + String get chatConversationNewTitle => 'New Conversation'; + + @override + String get chatConversationMessageHint => 'Ask anything about your finances…'; + + @override + String chatConversationGreetingWithName(String firstName) { + return 'Hi $firstName, how can I help?'; + } + + @override + String get chatConversationGreetingNoName => 'Hi there, how can I help?'; + + @override + String get transactionFormNewTitle => 'New Transaction'; + + @override + String get transactionFormTypeLabel => 'Type'; + + @override + String get transactionFormTypeExpense => 'Expense'; + + @override + String get transactionFormTypeIncome => 'Income'; + + @override + String get transactionFormAmountLabel => 'Amount'; + + @override + String get transactionFormAmountRequired => 'Amount is required'; + + @override + String get transactionFormAmountInvalid => 'Please enter a valid amount'; + + @override + String get transactionFormDateLabel => 'Date'; + + @override + String get transactionFormNameLabel => 'Name'; + + @override + String get transactionFormCategoryLabel => 'Category'; + + @override + String get transactionEditTitle => 'Edit Transaction'; + + @override + String get transactionEditNameLabel => 'Name'; + + @override + String get transactionEditNameRequired => 'Name is required'; + + @override + String get transactionEditNotesLabel => 'Notes'; + + @override + String get transactionEditCategoryLabel => 'Category'; + + @override + String get transactionEditMerchantLabel => 'Merchant'; + + @override + String get transactionEditTagsLabel => 'Tags'; + + @override + String get transactionEditSaving => 'Saving…'; + + @override + String get transactionsListDeleteTitle => 'Delete Transaction'; + + @override + String transactionsListDeleteSingleContent(String name) { + return 'Are you sure you want to delete \"$name\"?'; + } + + @override + String get transactionsListDeleteMultiTitle => 'Delete Transactions'; + + @override + String get transactionsListDeleteMultiContent => + 'Are you sure you want to delete the selected transactions?'; + + @override + String get transactionsListEmpty => 'No transactions'; + + @override + String get transactionsListAuthFailed => + 'Authentication failed: Please log in again'; + + @override + String get transactionsListNoTransactionsYet => 'No transactions yet'; + + @override + String get transactionsListEmptyAddFirst => + 'Tap + to add your first transaction'; + + @override + String get transactionsListNoCategoryMatch => + 'No transactions match this category'; + + @override + String get transactionsListRetry => 'Retry'; + + @override + String get transactionsListDeletedSuccess => 'Transaction deleted'; + + @override + String get transactionsListSingleDeleteFailed => + 'Failed to delete transaction'; + + @override + String transactionsListDeletedMulti(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Deleted $count transactions', + one: 'Deleted $count transaction', + ); + return '$_temp0'; + } + + @override + String get transactionsListDeleteFailed => 'Failed to delete transactions'; + + @override + String get transactionsListDeleteNoToken => + 'Failed to delete: No access token'; + + @override + String get transactionsListUndoTitle => 'Undo Transaction'; + + @override + String get transactionsListUndoRemovePending => + 'Remove this pending transaction?'; + + @override + String get transactionsListUndoRestoreConfirm => 'Restore this transaction?'; + + @override + String get transactionsListUndoPendingRemoved => + 'Pending transaction removed'; + + @override + String get transactionsListUndoRestored => 'Transaction restored'; + + @override + String get transactionsListUndoFailed => 'Failed to undo transaction'; + + @override + String get settingsSectionDisplay => 'Display'; + + @override + String get settingsSectionConnection => 'Connection'; + + @override + String get settingsSectionDataManagement => 'Data Management'; + + @override + String get settingsSectionSecurity => 'Security'; + + @override + String get settingsSectionDangerZone => 'Danger Zone'; + + @override + String get settingsThemeLabel => 'Theme'; + + @override + String get settingsThemeSystem => 'System'; + + @override + String get settingsThemeLight => 'Light'; + + @override + String get settingsThemeDark => 'Dark'; + + @override + String get settingsProxyHeadersLabel => 'Custom Proxy Headers'; + + @override + String get settingsBiometricLabel => 'Biometric Lock'; + + @override + String get settingsBiometricEnable => 'Enable biometric lock?'; + + @override + String get settingsBiometricEnableContent => + 'Require biometric authentication when resuming the app.'; + + @override + String get settingsCheckForUpdates => 'Check for Updates'; + + @override + String get settingsUpdateAvailableTitle => 'Update Available'; + + @override + String settingsUpdateAvailableContent(String version) { + return 'Version $version is available. Update now?'; + } + + @override + String get settingsUpdateNewerVersionFallback => 'a newer version'; + + @override + String get settingsUpdateNow => 'Update Now'; + + @override + String get settingsNoUpdateAvailable => 'You\'re on the latest version.'; + + @override + String get settingsUpdateError => 'Could not check for updates.'; + + @override + String get settingsClearDataTitle => 'Clear All Data'; + + @override + String get settingsClearDataContent => + 'This will remove all locally cached data. Your data on the server will not be affected.'; + + @override + String get settingsClearData => 'Clear Data'; + + @override + String get settingsClearDataSuccess => 'Local data cleared'; + + @override + String get settingsDeleteAccountTitle => 'Delete Account'; + + @override + String get settingsDeleteAccount => 'Delete Account'; + + @override + String get settingsSignOutTitle => 'Sign Out'; + + @override + String get settingsSignOutContent => 'Are you sure you want to sign out?'; + + @override + String get settingsSignOut => 'Sign Out'; + + @override + String get settingsDebugLogs => 'Debug Logs'; + + @override + String get ssoOnboardingTitle => 'Link Your Account'; + + @override + String get ssoOnboardingTabLink => 'Link existing'; + + @override + String get ssoOnboardingTabCreate => 'Create new'; + + @override + String get ssoOnboardingFirstNameLabel => 'First Name'; + + @override + String get ssoOnboardingLastNameLabel => 'Last Name'; + + @override + String get ssoOnboardingLinkButton => 'Link Account'; + + @override + String get ssoOnboardingCreateButton => 'Create Account'; + + @override + String get ssoOnboardingAcceptInvitation => 'Accept Invitation'; + + @override + String get calendarTitle => 'Account Calendar'; + + @override + String get calendarAccountTypeSection => 'Account Type'; + + @override + String get calendarSegmentAssets => 'Assets'; + + @override + String get calendarSegmentLiabilities => 'Liabilities'; + + @override + String get calendarSelectAccount => 'Select Account'; + + @override + String get calendarMonthlyChange => 'Monthly Change'; + + @override + String get calendarNoTransactions => 'No transactions on this day'; + + @override + String get moreCalendar => 'Account Calendar'; + + @override + String get moreCalendarSubtitle => 'View monthly balance changes by account'; + + @override + String get moreRecentTransactions => 'Recent Transactions'; + + @override + String get moreRecentTransactionsSubtitle => + 'View recent transactions across all accounts'; + + @override + String get biometricTitle => 'App Locked'; + + @override + String get biometricSubtitle => 'Authenticate to continue'; + + @override + String get biometricUnlock => 'Unlock'; + + @override + String get biometricAuthenticating => 'Authenticating…'; + + @override + String get biometricLogOut => 'Log out'; + + @override + String get backendConfigTitle => 'Configuration'; + + @override + String get backendConfigSubtitle => 'Update your Sure server URL'; + + @override + String get backendConfigExampleUrlsLabel => 'Example URLs'; + + @override + String get backendConfigUrlLabel => 'Sure server URL'; + + @override + String get backendConfigUrlHint => 'https://app.sure.am'; + + @override + String get backendConfigProxyHeadersLabel => 'Custom proxy headers'; + + @override + String get backendConfigProxyHeadersSubtitle => + 'Optional headers for a reverse proxy or auth gateway'; + + @override + String backendConfigProxyHeadersCount(int count) { + return '$count configured'; + } + + @override + String get backendConfigTesting => 'Testing…'; + + @override + String get backendConfigTestButton => 'Test Connection'; + + @override + String get backendConfigContinueButton => 'Continue'; + + @override + String get backendConfigChangeHint => + 'You can change this later in the settings.'; + + @override + String get recentTransactionsTitle => 'Recent Transactions'; + + @override + String get recentTransactionsEmpty => 'No Transactions'; + + @override + String get recentTransactionsDisplayLimit => 'Display Limit'; + + @override + String recentTransactionsShowN(int count) { + return 'Show $count'; + } + + @override + String get recentTransactionsPullToRefresh => 'Pull to refresh'; + + @override + String get logViewerTitle => 'Debug Logs'; + + @override + String get logViewerFilterAll => 'All'; + + @override + String get logViewerFilterInfo => 'Info'; + + @override + String get logViewerFilterWarning => 'Warning'; + + @override + String get logViewerFilterError => 'Error'; + + @override + String get logViewerFilterDebug => 'Debug'; + + @override + String get logViewerAutoScrollEnable => 'Enable auto-scroll'; + + @override + String get logViewerAutoScrollDisable => 'Disable auto-scroll'; + + @override + String get logViewerCopyLogs => 'Copy logs'; + + @override + String get logViewerClearLogs => 'Clear logs'; + + @override + String get logViewerLogsCopied => 'Logs copied to clipboard'; + + @override + String get logViewerEmpty => 'No logs yet'; + + @override + String get connectivityOffline => 'You are offline'; + + @override + String connectivityPendingSync(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count transactions pending sync', + one: '$count transaction pending sync', + ); + return '$_temp0'; + } + + @override + String get connectivitySyncNow => 'Sync Now'; + + @override + String get proxyHeadersAddHeader => 'Add header'; + + @override + String get proxyHeadersNameLabel => 'Header name'; + + @override + String get proxyHeadersNameHint => 'X-Auth-Token'; + + @override + String get proxyHeadersValueLabel => 'Header value'; + + @override + String get proxyHeadersRemove => 'Remove header'; + + @override + String get accountDetailRefreshTooltip => 'Refresh account details'; + + @override + String get accountDetailRecentBalanceHistory => 'Recent balance history'; + + @override + String get accountDetailTopHoldings => 'Top holdings'; + + @override + String get accountDetailHoldingFallback => 'Holding'; + + @override + String accountDetailCashChip(String amount) { + return 'Cash $amount'; + } + + @override + String get biometricLockFailedRetry => + 'Authentication failed. Tap Unlock to try again.'; + + @override + String get settingsBiometricVerifyReason => + 'Verify biometric to enable app lock'; + + @override + String get settingsBiometricFailed => 'Biometric authentication failed.'; + + @override + String get settingsUpdateOpenStoreError => 'Unable to open store link'; + + @override + String get settingsClearDataFailed => 'Failed to clear local data.'; + + @override + String get settingsClearDataSuccessDetailed => + 'Local data cleared successfully. Pull to refresh to sync from server.'; + + @override + String get settingsContactOpenLinkError => 'Unable to open link'; + + @override + String get settingsResetAccountContent => + 'Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact.\n\nThis action cannot be undone. Are you sure?'; + + @override + String get settingsResetAccount => 'Reset Account'; + + @override + String get settingsResetAccountInitiated => + 'Account reset has been initiated. This may take a moment.'; + + @override + String get settingsResetAccountFailed => 'Failed to reset account'; + + @override + String get settingsDeleteAccountConfirmContent => + 'Deleting your account will permanently remove all your data and cannot be undone.\n\nAre you sure you want to delete your account?'; + + @override + String get settingsDeleteAccountFailed => 'Failed to delete account'; + + @override + String get settingsProxyHeadersNote => + 'Headers are sent by the app with API requests. External browser SSO pages may not receive them.'; + + @override + String get settingsProxyHeadersSaved => 'Custom proxy headers saved'; + + @override + String get settingsProxyHeadersSaveFailed => + 'Failed to save custom proxy headers.'; + + @override + String settingsAppVersion(String version) { + return 'App Version: $version'; + } + + @override + String get settingsCheckForUpdatesSubtitle => + 'See if a newer version is available'; + + @override + String get settingsContactUs => 'Contact us'; + + @override + String get settingsDebugLogsSemantics => 'Open debug logs'; + + @override + String get settingsDebugLogsSubtitle => 'View app diagnostic logs'; + + @override + String get settingsGroupByAccountType => 'Group by Account Type'; + + @override + String get settingsGroupByAccountTypeSubtitle => + 'Group accounts by type (Crypto, Bank, etc.)'; + + @override + String get settingsProxyHeadersTileTitle => 'Custom proxy headers'; + + @override + String get settingsProxyHeadersTileSubtitleEmpty => + 'Optional headers for a reverse proxy or auth gateway'; + + @override + String settingsProxyHeadersTileSubtitleCount(int count) { + return '$count configured'; + } + + @override + String get settingsClearDataTileSubtitle => + 'Remove all cached transactions and accounts'; + + @override + String get settingsResetAccountTileSubtitle => + 'Delete all accounts, categories, merchants, and tags but keep your user account'; + + @override + String get settingsDeleteAccountTileSubtitle => + 'Permanently remove all your data. This cannot be undone.'; + + @override + String get settingsUserFallback => 'User'; + + @override + String chatListDeleteMultiContent(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Delete $count chats? This cannot be undone.', + one: 'Delete $count chat? This cannot be undone.', + ); + return '$_temp0'; + } + + @override + String get chatListDeletedSuccess => 'Chats deleted'; + + @override + String get chatListDeleteFailed => 'Failed to delete chats'; + + @override + String get chatListError => 'Failed to load chats'; + + @override + String get chatListJustNow => 'Just now'; + + @override + String chatListDeleteSingleContent(String title) { + return 'Are you sure you want to delete \"$title\"?'; + } + + @override + String get loginSignUpOpenError => 'Unable to open sign up page'; + + @override + String get loginApiKeyDialogTitle => 'API Key Login'; + + @override + String get loginApiKeyDialogBody => 'Enter your API key to sign in.'; + + @override + String get loginApiKeyInvalid => 'Invalid API key'; + + @override + String get loginApiKeySignIn => 'Sign In'; + + @override + String get loginDemoOrSignUpPrefix => 'Demo account or '; + + @override + String get loginSignUpLink => 'Sign Up'; + + @override + String get loginSignUpSuffix => '!'; + + @override + String get loginMfaInfo => + 'Two-factor authentication is enabled. Enter your code.'; + + @override + String get loginMfaCodeRequired => 'Please enter your authentication code'; + + @override + String get loginOrDivider => 'or'; + + @override + String get loginServerUrlHeading => 'Sure server URL:'; + + @override + String get loginApiKeyLoginButton => 'API-Key Login'; + + @override + String get loginBackendSettingsTooltip => 'Backend Settings'; + + @override + String get transactionEditSessionExpired => + 'Session expired. Please login again.'; + + @override + String get transactionEditUpdated => 'Transaction updated'; + + @override + String get transactionEditUpdateFailed => 'Failed to update transaction'; + + @override + String transactionEditNameMaxLength(int max) { + return 'Name must be $max characters or fewer'; + } + + @override + String get transactionEditNameInvalidChars => + 'Name contains unsupported characters'; + + @override + String transactionEditNotesMaxLength(int max) { + return 'Notes must be $max characters or fewer'; + } + + @override + String get transactionEditNotesInvalidChars => + 'Notes contain unsupported characters'; + + @override + String get transactionEditNoCategory => 'No category'; + + @override + String get transactionEditCurrentCategory => 'Current category'; + + @override + String get transactionEditNoMerchant => 'No merchant'; + + @override + String get transactionEditCurrentMerchant => 'Current merchant'; + + @override + String get transactionEditNoTags => 'No tags available'; + + @override + String get transactionEditUnknownTag => 'Unknown tag'; + + @override + String get transactionEditSyncedOnly => + 'Only synced transactions can be edited from mobile.'; + + @override + String get transactionEditCategoryHelper => 'Choose a replacement category'; + + @override + String get transactionEditMerchantHelper => 'Choose a replacement merchant'; + + @override + String get transactionFormSessionExpired => + 'Session expired. Please login again.'; + + @override + String get transactionFormAmountRequiredPrompt => 'Please enter an amount'; + + @override + String get transactionFormAmountInvalidNumber => + 'Please enter a valid number'; + + @override + String get transactionFormAmountTooSmall => 'Amount must be greater than 0'; + + @override + String get transactionFormCreateSuccessOnline => + 'Transaction created successfully'; + + @override + String get transactionFormCreateSuccessOffline => + 'Transaction saved (will sync when online)'; + + @override + String get transactionFormCreateFailed => 'Failed to create transaction'; + + @override + String transactionFormGenericError(String error) { + return 'Error: $error'; + } + + @override + String get transactionFormLess => 'Less'; + + @override + String get transactionFormMore => 'More'; + + @override + String get transactionFormDateHelper => 'Optional (default: today)'; + + @override + String get transactionFormNameHelper => 'Optional (default: SureApp)'; + + @override + String get transactionFormCategoryLoading => 'Loading categories…'; + + @override + String get transactionFormCategoryHelper => 'Optional'; + + @override + String get transactionFormNoCategory => 'No category'; + + @override + String get transactionFormCreateButton => 'Create Transaction'; + + @override + String get transactionFormAmountHelper => 'Required'; + + @override + String get logViewerClearConfirm => + 'Are you sure you want to clear all logs?'; + + @override + String get logViewerClear => 'Clear'; + + @override + String get chatConversationEditTitle => 'Edit Title'; + + @override + String get chatConversationTitleLabel => 'Chat Title'; + + @override + String get chatConversationRefreshTooltip => 'Refresh'; + + @override + String get chatConversationLoadError => 'Failed to load chat'; + + @override + String get navEnableAiChatTitle => 'Turn on AI Chat?'; + + @override + String get navEnableAiChatContent => + 'AI Chat is currently disabled in your account settings. Would you like to turn it on now?'; + + @override + String get navEnableAiChatNotNow => 'Not now'; + + @override + String get navEnableAiChatConfirm => 'Turn on AI'; + + @override + String get navEnableAiChatFailed => 'Unable to enable AI right now.'; + + @override + String get transactionsListEditTooltip => 'Edit transaction'; + + @override + String get connectivitySignInToSync => 'Please sign in to sync transactions'; + + @override + String get connectivitySyncSuccess => 'Transactions synced successfully'; + + @override + String get connectivitySyncFailed => + 'Failed to sync transactions. Please try again.'; + + @override + String get connectivityAuthFailed => + 'Unable to authenticate. Please try again.'; + + @override + String ssoOnboardingSignedInAs(String email) { + return 'Signed in as $email'; + } + + @override + String get ssoOnboardingGoogleVerified => 'Google account verified'; + + @override + String get ssoOnboardingLinkCredentialsNote => + 'Enter your existing account credentials to link with Google Sign-In.'; + + @override + String get ssoOnboardingPendingInvitationNote => + 'You have a pending invitation. Accept it to join an existing household.'; + + @override + String get ssoOnboardingCreateIdentityNote => + 'Create a new account using your Google identity.'; + + @override + String get ssoOnboardingFirstNameRequired => 'First name is required'; + + @override + String get ssoOnboardingLastNameRequired => 'Last name is required'; + + @override + String get backendConfigTimeout => + 'Connection timeout. Please check the URL and try again.'; + + @override + String get backendConfigSuccess => 'Connection successful!'; + + @override + String backendConfigServerError(int code) { + return 'Server responded with status $code. Please check if this is a Sure backend server.'; + } + + @override + String backendConfigConnectionFailed(String error) { + return 'Connection failed: $error'; + } + + @override + String backendConfigSaveFailed(String error) { + return 'Failed to save URL: $error'; + } + + @override + String get backendConfigUrlRequired => 'Please enter a backend URL'; + + @override + String get backendConfigUrlScheme => + 'URL must start with http:// or https://'; + + @override + String get backendConfigUrlInvalid => 'Please enter a valid URL'; + + @override + String get backendConfigHeadersHelp => + 'Headers are sent by the app with API requests. External browser SSO pages may not receive them.'; + + @override + String get recentTransactionsUnknownAccount => 'Unknown Account'; + + @override + String get accountDetailUnavailable => + 'Account details are temporarily unavailable'; + + @override + String chatListMinutesAgo(int minutes) { + return '${minutes}m ago'; + } + + @override + String chatListHoursAgo(int hours) { + return '${hours}h ago'; + } + + @override + String chatListDaysAgo(int days) { + return '${days}d ago'; + } + + @override + String get chatConversationStartFailed => + 'Failed to start conversation. Please try again.'; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index a806310d6..e5674b49a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -21,6 +21,7 @@ import 'services/log_service.dart'; import 'services/preferences_service.dart'; import 'services/telemetry_service.dart'; import 'theme/sure_theme.dart'; +import 'l10n/app_localizations.dart'; import 'package:upgrader/upgrader.dart'; void main() async { @@ -79,7 +80,9 @@ class SureApp extends StatelessWidget { ], child: Consumer( builder: (context, themeProvider, _) => MaterialApp( - title: 'Sure Finances', + onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, debugShowCheckedModeBanner: false, navigatorObservers: TelemetryService.instance.navigatorObservers, theme: SureTheme.light, diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart index f6e90bfa4..5fb20dea7 100644 --- a/mobile/lib/screens/backend_config_screen.dart +++ b/mobile/lib/screens/backend_config_screen.dart @@ -6,6 +6,7 @@ import '../services/api_config.dart'; import '../services/custom_proxy_headers_service.dart'; import '../services/log_service.dart'; import '../widgets/custom_proxy_headers_editor.dart'; +import '../l10n/app_localizations.dart'; class BackendConfigScreen extends StatefulWidget { final VoidCallback? onConfigSaved; @@ -69,6 +70,8 @@ class _BackendConfigScreenState extends State { Future _testConnection() async { if (!_formKey.currentState!.validate()) return; + final l = AppLocalizations.of(context); + setState(() { _isTesting = true; _errorMessage = null; @@ -93,9 +96,7 @@ class _BackendConfigScreenState extends State { await http.get(sessionsUrl, headers: ApiConfig.htmlHeaders()).timeout( const Duration(seconds: 10), onTimeout: () { - throw Exception( - 'Connection timeout. Please check the URL and try again.', - ); + throw Exception(l.backendConfigTimeout); }, ); @@ -103,21 +104,21 @@ class _BackendConfigScreenState extends State { sessionsResponse.statusCode < 400) { if (mounted) { setState(() { - _successMessage = 'Connection successful!'; + _successMessage = l.backendConfigSuccess; }); } } else { if (mounted) { setState(() { _errorMessage = - 'Server responded with status ${sessionsResponse.statusCode}. Please check if this is a Sure backend server.'; + l.backendConfigServerError(sessionsResponse.statusCode); }); } } } catch (e) { if (mounted) { setState(() { - _errorMessage = 'Connection failed: ${e.toString()}'; + _errorMessage = l.backendConfigConnectionFailed(e.toString()); }); } } finally { @@ -133,6 +134,8 @@ class _BackendConfigScreenState extends State { Future _saveAndContinue() async { if (!_formKey.currentState!.validate()) return; + final l = AppLocalizations.of(context); + setState(() { _isLoading = true; _errorMessage = null; @@ -163,7 +166,7 @@ class _BackendConfigScreenState extends State { } catch (e) { if (mounted) { setState(() { - _errorMessage = 'Failed to save URL: ${e.toString()}'; + _errorMessage = l.backendConfigSaveFailed(e.toString()); }); } } finally { @@ -175,9 +178,9 @@ class _BackendConfigScreenState extends State { } } - String? _validateUrl(String? value) { + String? _validateUrl(String? value, AppLocalizations l) { if (value == null || value.isEmpty) { - return 'Please enter a backend URL'; + return l.backendConfigUrlRequired; } final trimmedValue = value.trim(); @@ -185,17 +188,17 @@ class _BackendConfigScreenState extends State { // Check if it starts with http:// or https:// if (!trimmedValue.startsWith('http://') && !trimmedValue.startsWith('https://')) { - return 'URL must start with http:// or https://'; + return l.backendConfigUrlScheme; } // Basic URL validation try { final uri = Uri.parse(trimmedValue); if (!uri.hasScheme || uri.host.isEmpty) { - return 'Please enter a valid URL'; + return l.backendConfigUrlInvalid; } } catch (e) { - return 'Please enter a valid URL'; + return l.backendConfigUrlInvalid; } return null; @@ -203,6 +206,7 @@ class _BackendConfigScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -223,7 +227,7 @@ class _BackendConfigScreenState extends State { ), const SizedBox(height: 16), Text( - 'Configuration', + l.backendConfigTitle, style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, color: colorScheme.primary, @@ -232,7 +236,7 @@ class _BackendConfigScreenState extends State { ), const SizedBox(height: 8), Text( - 'Update your Sure server URL', + l.backendConfigSubtitle, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -255,7 +259,7 @@ class _BackendConfigScreenState extends State { Icon(Icons.info_outline, color: colorScheme.primary), const SizedBox(width: 12), Text( - 'Example URLs', + l.backendConfigExampleUrlsLabel, style: TextStyle( color: colorScheme.primary, fontWeight: FontWeight.bold, @@ -354,23 +358,23 @@ class _BackendConfigScreenState extends State { keyboardType: TextInputType.url, autocorrect: false, textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Sure server URL', - prefixIcon: Icon(Icons.cloud_outlined), - hintText: 'https://app.sure.am', + decoration: InputDecoration( + labelText: l.backendConfigUrlLabel, + prefixIcon: const Icon(Icons.cloud_outlined), + hintText: l.backendConfigUrlHint, ), - validator: _validateUrl, + validator: (value) => _validateUrl(value, l), onFieldSubmitted: (_) => _saveAndContinue(), ), const SizedBox(height: 24), ExpansionTile( tilePadding: EdgeInsets.zero, leading: const Icon(Icons.http_outlined), - title: const Text('Custom proxy headers'), + title: Text(l.backendConfigProxyHeadersLabel), subtitle: Text( _customHeaders.isEmpty - ? 'Optional headers for a reverse proxy or auth gateway' - : '${_customHeaders.length} configured', + ? l.backendConfigProxyHeadersSubtitle + : l.backendConfigProxyHeadersCount(_customHeaders.length), ), children: [ const SizedBox(height: 8), @@ -390,7 +394,7 @@ class _BackendConfigScreenState extends State { ), const SizedBox(height: 8), Text( - 'Headers are sent by the app with API requests. External browser SSO pages may not receive them.', + l.backendConfigHeadersHelp, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -409,7 +413,7 @@ class _BackendConfigScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.cable), - label: Text(_isTesting ? 'Testing...' : 'Test Connection'), + label: Text(_isTesting ? l.backendConfigTesting : l.backendConfigTestButton), ), const SizedBox(height: 12), @@ -423,14 +427,14 @@ class _BackendConfigScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Continue'), + : Text(l.backendConfigContinueButton), ), const SizedBox(height: 24), // Info text Text( - 'You can change this later in the settings.', + l.backendConfigChangeHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/mobile/lib/screens/biometric_lock_screen.dart b/mobile/lib/screens/biometric_lock_screen.dart index 536e0a9c0..5ebb88e01 100644 --- a/mobile/lib/screens/biometric_lock_screen.dart +++ b/mobile/lib/screens/biometric_lock_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../services/biometric_service.dart'; class BiometricLockScreen extends StatefulWidget { @@ -38,13 +39,14 @@ class _BiometricLockScreenState extends State { widget.onUnlocked(); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Authentication failed. Tap Unlock to try again.')), + SnackBar(content: Text(AppLocalizations.of(context).biometricLockFailedRetry)), ); } } @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -61,21 +63,21 @@ class _BiometricLockScreenState extends State { ), const SizedBox(height: 24), Text( - 'App Locked', + l.biometricTitle, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Text( - 'Authenticate to continue', + l.biometricSubtitle, style: TextStyle(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 40), FilledButton.icon( onPressed: _isAuthenticating ? null : _authenticate, icon: const Icon(Icons.fingerprint), - label: Text(_isAuthenticating ? 'Authenticating…' : 'Unlock'), + label: Text(_isAuthenticating ? l.biometricAuthenticating : l.biometricUnlock), style: FilledButton.styleFrom( minimumSize: const Size(200, 50), shape: RoundedRectangleBorder( @@ -87,7 +89,7 @@ class _BiometricLockScreenState extends State { const SizedBox(height: 16), TextButton( onPressed: widget.onLogout, - child: const Text('Log out'), + child: Text(l.biometricLogOut), ), ], ], diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index 2487f9e9c..dd7624c37 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -8,6 +8,7 @@ import '../providers/transactions_provider.dart'; import '../providers/auth_provider.dart'; import '../services/log_service.dart'; import '../utils/amount_parser.dart'; +import '../l10n/app_localizations.dart'; class CalendarScreen extends StatefulWidget { const CalendarScreen({super.key}); @@ -190,8 +191,11 @@ class _CalendarScreenState extends State { void _showTransactionsDialog(DateTime date) { final transactions = _getTransactionsForDate(date); - final formattedDate = DateFormat('yyyy-MM-dd').format(date); + final formattedDate = DateFormat.yMMMd( + Localizations.localeOf(context).toString(), + ).format(date); final colorScheme = Theme.of(context).colorScheme; + final l = AppLocalizations.of(context); showDialog( context: context, @@ -208,7 +212,7 @@ class _CalendarScreenState extends State { child: Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'No transactions on this day', + l.calendarNoTransactions, style: TextStyle( color: colorScheme.onSurfaceVariant, ), @@ -227,7 +231,7 @@ class _CalendarScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), + child: Text(l.commonClose), ), ], ); @@ -303,12 +307,13 @@ class _CalendarScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final accountsProvider = context.watch(); return Scaffold( appBar: AppBar( - title: const Text('Account Calendar'), + title: Text(l.calendarTitle), ), body: Column( children: [ @@ -328,23 +333,23 @@ class _CalendarScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Account Type', + l.calendarAccountTypeSection, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: 'asset', - label: Text('Assets'), - icon: Icon(Icons.account_balance_wallet), + label: Text(l.calendarSegmentAssets), + icon: const Icon(Icons.account_balance_wallet), ), ButtonSegment( value: 'liability', - label: Text('Liabilities'), - icon: Icon(Icons.credit_card), + label: Text(l.calendarSegmentLiabilities), + icon: const Icon(Icons.credit_card), ), ], selected: {_accountType}, @@ -386,7 +391,7 @@ class _CalendarScreenState extends State { child: DropdownButtonFormField( value: _selectedAccount, decoration: InputDecoration( - labelText: 'Select Account', + labelText: l.calendarSelectAccount, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), @@ -434,7 +439,9 @@ class _CalendarScreenState extends State { onPressed: _previousMonth, ), Text( - DateFormat('yyyy-MM').format(_currentMonth), + DateFormat.yMMM( + Localizations.localeOf(context).toString(), + ).format(_currentMonth), style: Theme.of(context).textTheme.titleLarge, ), IconButton( @@ -461,7 +468,7 @@ class _CalendarScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Monthly Change', + l.calendarMonthlyChange, style: Theme.of(context).textTheme.titleMedium, ), Text( @@ -496,6 +503,17 @@ class _CalendarScreenState extends State { final daysInMonth = lastDayOfMonth.day; final startWeekday = firstDayOfMonth.weekday % 7; // 0 = Sunday + // Localized narrow weekday labels, Sunday→Saturday to match the + // Sunday-anchored grid math above. 2024-01-07 is a Sunday, so indices + // 0..6 map Sunday..Saturday. + final localeName = Localizations.localeOf(context).toString(); + final weekdayLabels = List.generate( + 7, + (i) => DateFormat.E(localeName) + .format(DateTime(2024, 1, 7 + i)) + .substring(0, 1), + ); + return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8), @@ -505,7 +523,7 @@ class _CalendarScreenState extends State { SizedBox( height: 40, child: Row( - children: ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day) { + children: weekdayLabels.map((day) { return Expanded( child: Center( child: Text( diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index e521eb4c6..36b180826 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -8,6 +8,7 @@ import '../providers/chat_provider.dart'; import '../models/message.dart'; import '../constants/suggested_questions.dart'; import '../widgets/typing_indicator.dart'; +import '../l10n/app_localizations.dart'; class _SendMessageIntent extends Intent { const _SendMessageIntent(); @@ -135,6 +136,8 @@ class _ChatConversationScreenState extends State { if (content.isEmpty) return; setState(() => _isSendInFlight = true); + final l = AppLocalizations.of(context); + try { final authProvider = Provider.of(context, listen: false); final chatProvider = Provider.of(context, listen: false); @@ -160,7 +163,7 @@ class _ChatConversationScreenState extends State { _messageController.text = content; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(chatProvider.errorMessage ?? 'Failed to start conversation. Please try again.'), + content: Text(chatProvider.errorMessage ?? l.chatConversationStartFailed), backgroundColor: Colors.red, ), ); @@ -207,25 +210,26 @@ class _ChatConversationScreenState extends State { final newTitle = await showDialog( context: context, builder: (context) { + final dl = AppLocalizations.of(context); final controller = TextEditingController(text: currentTitle); return AlertDialog( - title: const Text('Edit Title'), + title: Text(dl.chatConversationEditTitle), content: TextField( controller: controller, - decoration: const InputDecoration( - labelText: 'Chat Title', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: dl.chatConversationTitleLabel, + border: const OutlineInputBorder(), ), autofocus: true, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(dl.commonCancel), ), TextButton( onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('Save'), + child: Text(dl.commonSave), ), ], ); @@ -258,12 +262,13 @@ class _ChatConversationScreenState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final l = AppLocalizations.of(context); return Scaffold( appBar: AppBar( title: Consumer( builder: (context, chatProvider, _) { - final title = chatProvider.currentChat?.title ?? 'New Conversation'; + final title = chatProvider.currentChat?.title ?? AppLocalizations.of(context).chatConversationNewTitle; return GestureDetector( onTap: _chatId != null ? _editTitle : null, child: Row( @@ -289,7 +294,7 @@ class _ChatConversationScreenState extends State { IconButton( icon: const Icon(Icons.refresh), onPressed: () => _loadChat(forceRefresh: true), - tooltip: 'Refresh', + tooltip: l.chatConversationRefreshTooltip, ), ], ), @@ -311,7 +316,7 @@ class _ChatConversationScreenState extends State { Icon(Icons.error_outline, size: 64, color: colorScheme.error), const SizedBox(height: 16), - Text('Failed to load chat', + Text(l.chatConversationLoadError, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), Text( @@ -323,7 +328,7 @@ class _ChatConversationScreenState extends State { ElevatedButton.icon( onPressed: _loadChat, icon: const Icon(Icons.refresh), - label: const Text('Try Again'), + label: Text(l.commonTryAgain), ), ], ), @@ -410,7 +415,7 @@ class _ChatConversationScreenState extends State { child: TextField( controller: _messageController, decoration: InputDecoration( - hintText: 'Type a message...', + hintText: l.chatConversationMessageHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), @@ -598,8 +603,9 @@ class _EmptyState extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final l = AppLocalizations.of(context); final name = (firstName ?? '').trim(); - final greeting = name.isNotEmpty ? 'Hi $name, how can I help?' : 'How can I help?'; + final greeting = name.isNotEmpty ? l.chatConversationGreetingWithName(name) : l.chatConversationGreetingNoName; return ListView( padding: const EdgeInsets.all(24), @@ -613,7 +619,7 @@ class _EmptyState extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 32), - ...suggestedQuestions.map( + ...suggestedQuestions(context).map( (q) => Padding( padding: const EdgeInsets.only(bottom: 12), child: OutlinedButton.icon( diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index 7868a30f5..75934b3e2 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import 'chat_conversation_screen.dart'; +import '../l10n/app_localizations.dart'; class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -68,27 +70,31 @@ class _ChatListScreenState extends State { } Future _deleteSelectedChats() async { + final l = AppLocalizations.of(context); final authProvider = Provider.of(context, listen: false); final chatProvider = Provider.of(context, listen: false); final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Chats'), - content: Text( - 'Delete ${_selectedChatIds.length} chat(s)? This cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.chatListDeleteTitle), + content: Text( + dl.chatListDeleteMultiContent(_selectedChatIds.length), ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Delete', style: TextStyle(color: Colors.red)), - ), - ], - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(dl.commonCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(dl.commonDelete, style: const TextStyle(color: Colors.red)), + ), + ], + ); + }, ); if (confirmed != true || !mounted) return; @@ -114,7 +120,7 @@ class _ChatListScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - success ? 'Chats deleted' : 'Failed to delete chats', + success ? l.chatListDeletedSuccess : l.chatListDeleteFailed, ), backgroundColor: success ? Colors.green : Colors.red, ), @@ -138,26 +144,29 @@ class _ChatListScreenState extends State { final now = DateTime.now(); final difference = now.difference(dateTime); + final l = AppLocalizations.of(context); if (difference.inMinutes < 1) { - return 'Just now'; + return l.chatListJustNow; } else if (difference.inHours < 1) { - return '${difference.inMinutes}m ago'; + return l.chatListMinutesAgo(difference.inMinutes); } else if (difference.inDays < 1) { - return '${difference.inHours}h ago'; + return l.chatListHoursAgo(difference.inHours); } else if (difference.inDays < 7) { - return '${difference.inDays}d ago'; + return l.chatListDaysAgo(difference.inDays); } else { - return '${dateTime.day}/${dateTime.month}/${dateTime.year}'; + return DateFormat.yMd(Localizations.localeOf(context).toString()) + .format(dateTime); } } @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( - title: const Text('Chats'), + title: Text(l.chatListTitle), centerTitle: false, actions: [ if (_isSelectionMode) ...[ @@ -216,7 +225,7 @@ class _ChatListScreenState extends State { ), const SizedBox(height: 16), Text( - 'Failed to load chats', + l.chatListError, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), @@ -229,7 +238,7 @@ class _ChatListScreenState extends State { ElevatedButton.icon( onPressed: _handleRefresh, icon: const Icon(Icons.refresh), - label: const Text('Try Again'), + label: Text(l.commonTryAgain), ), ], ), @@ -251,12 +260,12 @@ class _ChatListScreenState extends State { ), const SizedBox(height: 16), Text( - 'No chats yet', + l.chatListEmpty, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - 'Start a new conversation with the AI assistant.', + l.chatListEmptySubtitle, style: TextStyle(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), @@ -291,20 +300,23 @@ class _ChatListScreenState extends State { confirmDismiss: (direction) async { return await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Chat'), - content: Text('Are you sure you want to delete "${chat.title}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Delete', style: TextStyle(color: Colors.red)), - ), - ], - ), + builder: (context) { + final l = AppLocalizations.of(context); + return AlertDialog( + title: Text(l.chatListDeleteTitle), + content: Text(l.chatListDeleteSingleContent(chat.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.commonCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.commonDelete, style: const TextStyle(color: Colors.red)), + ), + ], + ); + }, ); }, onDismissed: (direction) async { @@ -385,7 +397,7 @@ class _ChatListScreenState extends State { ), floatingActionButton: FloatingActionButton( onPressed: _openNewChat, - tooltip: 'New Chat', + tooltip: l.chatListNewChat, child: const Icon(Icons.add), ), ); diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 86a3265fd..dee76d574 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -12,8 +12,10 @@ import '../widgets/connectivity_banner.dart'; import '../widgets/net_worth_card.dart'; import '../widgets/currency_filter.dart'; import '../widgets/sure_icon.dart'; +import '../theme/sure_tokens.dart'; import 'transaction_form_screen.dart'; import 'transactions_list_screen.dart'; +import '../l10n/app_localizations.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -130,6 +132,7 @@ class DashboardScreenState extends State { Future _performManualSync() async { final authProvider = Provider.of(context, listen: false); final transactionsProvider = Provider.of(context, listen: false); + final l = AppLocalizations.of(context); final accessToken = await authProvider.getValidAccessToken(); if (accessToken == null) { @@ -140,10 +143,10 @@ class DashboardScreenState extends State { // Show syncing indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Row( children: [ - SizedBox( + const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( @@ -151,11 +154,11 @@ class DashboardScreenState extends State { valueColor: AlwaysStoppedAnimation(Colors.white), ), ), - SizedBox(width: 12), - Text('Syncing data from server...'), + const SizedBox(width: 12), + Expanded(child: Text(l.dashboardSyncing)), ], ), - duration: Duration(seconds: 30), + duration: const Duration(seconds: 30), ), ); } @@ -176,7 +179,7 @@ class DashboardScreenState extends State { children: [ const SureIcon(SureIcons.circleAlert, color: Colors.white), const SizedBox(width: 12), - const Expanded(child: Text('Sync failed. Please try again.')), + Expanded(child: Text(l.dashboardSyncFailed)), ], ), backgroundColor: Colors.red, @@ -192,16 +195,16 @@ class DashboardScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Row( children: [ - SureIcon(SureIcons.circleAlert, color: Colors.white), - SizedBox(width: 12), - Expanded(child: Text('Sync failed. Please try again.')), + const SureIcon(SureIcons.circleAlert, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(l.dashboardSyncError)), ], ), backgroundColor: Colors.red, - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), ), ); } @@ -281,6 +284,7 @@ class DashboardScreenState extends State { } Future _handleAccountTap(Account account) async { + final l = AppLocalizations.of(context); final result = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -292,10 +296,10 @@ class DashboardScreenState extends State { if (result == true && mounted) { // Show loading indicator ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Row( children: [ - SizedBox( + const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( @@ -303,11 +307,11 @@ class DashboardScreenState extends State { valueColor: AlwaysStoppedAnimation(Colors.white), ), ), - SizedBox(width: 12), - Text('Refreshing accounts...'), + const SizedBox(width: 12), + Expanded(child: Text(l.dashboardRefreshing)), ], ), - duration: Duration(seconds: 2), + duration: const Duration(seconds: 2), ), ); @@ -321,16 +325,16 @@ class DashboardScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Row( children: [ - SureIcon(SureIcons.circleCheck, color: Colors.white), - SizedBox(width: 12), - Text('Accounts updated'), + const SureIcon(SureIcons.circleCheck, color: Colors.white), + const SizedBox(width: 12), + Text(l.dashboardAccountsUpdated), ], ), backgroundColor: Colors.green, - duration: Duration(seconds: 1), + duration: const Duration(seconds: 1), ), ); } @@ -353,6 +357,7 @@ class DashboardScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -366,18 +371,18 @@ class DashboardScreenState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: Colors.green.withValues(alpha: 0.1), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SureIcon( + const SureIcon( SureIcons.cloudCheck, color: Colors.green, size: 18, ), - SizedBox(width: 8), + const SizedBox(width: 8), Text( - 'Synced', - style: TextStyle(color: Colors.green, fontSize: 13), + l.dashboardSynced, + style: const TextStyle(color: Colors.green, fontSize: 13), ), ], ), @@ -409,7 +414,7 @@ class DashboardScreenState extends State { ), const SizedBox(height: 16), Text( - 'Failed to load accounts', + l.dashboardErrorLoadingAccounts, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), @@ -422,7 +427,7 @@ class DashboardScreenState extends State { ElevatedButton.icon( onPressed: _handleRefresh, icon: const SureIcon(SureIcons.refresh), - label: const Text('Try Again'), + label: Text(l.commonTryAgain), ), ], ), @@ -445,12 +450,12 @@ class DashboardScreenState extends State { ), const SizedBox(height: 16), Text( - 'No accounts yet', + l.dashboardNoAccounts, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - 'Add accounts in the web app to see them here.', + l.dashboardNoAccountsSubtitle, style: TextStyle(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), @@ -458,7 +463,7 @@ class DashboardScreenState extends State { ElevatedButton.icon( onPressed: _handleRefresh, icon: const SureIcon(SureIcons.refresh), - label: const Text('Refresh'), + label: Text(l.commonRefresh), ), ], ), @@ -526,6 +531,7 @@ class DashboardScreenState extends State { List _buildFilteredAccountsSection(AccountsProvider accountsProvider) { final filteredAccounts = _getFilteredAccounts(accountsProvider); + final l = AppLocalizations.of(context); if (filteredAccounts.isEmpty) { return [ @@ -542,7 +548,7 @@ class DashboardScreenState extends State { ), const SizedBox(height: 16), Text( - 'No accounts match the current filter', + l.dashboardFilterEmpty, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -715,7 +721,7 @@ class _CollapsibleTypeHeader extends StatelessWidget { Text( title, style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), ), const SizedBox(width: 8), @@ -729,7 +735,7 @@ class _CollapsibleTypeHeader extends StatelessWidget { count.toString(), style: TextStyle( color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, fontSize: 11, ), ), diff --git a/mobile/lib/screens/log_viewer_screen.dart b/mobile/lib/screens/log_viewer_screen.dart index cbd162793..3f83cd746 100644 --- a/mobile/lib/screens/log_viewer_screen.dart +++ b/mobile/lib/screens/log_viewer_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../l10n/app_localizations.dart'; import '../services/log_service.dart'; class LogViewerScreen extends StatefulWidget { @@ -74,9 +75,10 @@ class _LogViewerScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); return Scaffold( appBar: AppBar( - title: const Text('Debug Logs'), + title: Text(l.logViewerTitle), actions: [ PopupMenuButton( initialValue: _selectedLevel, @@ -86,11 +88,11 @@ class _LogViewerScreenState extends State { }); }, itemBuilder: (context) => [ - const PopupMenuItem(value: 'ALL', child: Text('All Levels')), - const PopupMenuItem(value: 'ERROR', child: Text('Errors Only')), - const PopupMenuItem(value: 'WARNING', child: Text('Warnings Only')), - const PopupMenuItem(value: 'INFO', child: Text('Info Only')), - const PopupMenuItem(value: 'DEBUG', child: Text('Debug Only')), + PopupMenuItem(value: 'ALL', child: Text(l.logViewerFilterAll)), + PopupMenuItem(value: 'ERROR', child: Text(l.logViewerFilterError)), + PopupMenuItem(value: 'WARNING', child: Text(l.logViewerFilterWarning)), + PopupMenuItem(value: 'INFO', child: Text(l.logViewerFilterInfo)), + PopupMenuItem(value: 'DEBUG', child: Text(l.logViewerFilterDebug)), ], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -110,7 +112,7 @@ class _LogViewerScreenState extends State { _autoScroll = !_autoScroll; }); }, - tooltip: _autoScroll ? 'Disable Auto-scroll' : 'Enable Auto-scroll', + tooltip: _autoScroll ? l.logViewerAutoScrollDisable : l.logViewerAutoScrollEnable, ), IconButton( icon: const Icon(Icons.copy), @@ -118,13 +120,13 @@ class _LogViewerScreenState extends State { final logs = LogService.instance.exportLogs(); Clipboard.setData(ClipboardData(text: logs)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Logs copied to clipboard'), - duration: Duration(seconds: 2), + SnackBar( + content: Text(l.logViewerLogsCopied), + duration: const Duration(seconds: 2), ), ); }, - tooltip: 'Copy Logs', + tooltip: l.logViewerCopyLogs, ), IconButton( icon: const Icon(Icons.delete), @@ -132,12 +134,12 @@ class _LogViewerScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear Logs'), - content: const Text('Are you sure you want to clear all logs?'), + title: Text(l.logViewerClearLogs), + content: Text(l.logViewerClearConfirm), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(l.commonCancel), ), TextButton( onPressed: () { @@ -145,13 +147,13 @@ class _LogViewerScreenState extends State { Navigator.pop(context); }, style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Clear'), + child: Text(l.logViewerClear), ), ], ), ); }, - tooltip: 'Clear Logs', + tooltip: l.logViewerClearLogs, ), ], ), @@ -164,15 +166,15 @@ class _LogViewerScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); if (logs.isEmpty) { - return const Center( + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.text_snippet_outlined, size: 64, color: Colors.grey), - SizedBox(height: 16), + const Icon(Icons.text_snippet_outlined, size: 64, color: Colors.grey), + const SizedBox(height: 16), Text( - 'No logs yet', - style: TextStyle(fontSize: 16, color: Colors.grey), + AppLocalizations.of(context).logViewerEmpty, + style: const TextStyle(fontSize: 16, color: Colors.grey), ), ], ), diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index b16f053c6..32841ae76 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -8,6 +8,7 @@ import '../services/api_config.dart'; import '../widgets/sure_button.dart'; import '../widgets/sure_logo.dart'; import 'backend_config_screen.dart'; +import '../l10n/app_localizations.dart'; class LoginScreen extends StatefulWidget { final VoidCallback? onGoToSettings; @@ -73,6 +74,7 @@ class _LoginScreenState extends State { } Future _openSignUpPage() async { + final l = AppLocalizations.of(context); final signUpUrl = Uri.parse('${ApiConfig.defaultBaseUrl}/registration/new'); final launched = await launchUrl( signUpUrl, @@ -81,7 +83,7 @@ class _LoginScreenState extends State { if (!launched && mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unable to open sign up page')), + SnackBar(content: Text(l.loginSignUpOpenError)), ); } } @@ -94,15 +96,16 @@ class _LoginScreenState extends State { showDialog( context: context, builder: (dialogContext) { + final dl = AppLocalizations.of(dialogContext); return StatefulBuilder( builder: (_, setDialogState) { return AlertDialog( - title: const Text('API Key Login'), + title: Text(dl.loginApiKeyDialogTitle), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( - 'Enter your API key to sign in.', + dl.loginApiKeyDialogBody, style: Theme.of(outerContext).textTheme.bodyMedium?.copyWith( color: Theme.of(outerContext) @@ -113,9 +116,9 @@ class _LoginScreenState extends State { const SizedBox(height: 16), TextField( controller: apiKeyController, - decoration: const InputDecoration( - labelText: 'API Key', - prefixIcon: Icon(Icons.vpn_key_outlined), + decoration: InputDecoration( + labelText: dl.loginApiKeyLabel, + prefixIcon: const Icon(Icons.vpn_key_outlined), ), obscureText: true, maxLines: 1, @@ -131,7 +134,7 @@ class _LoginScreenState extends State { apiKeyController.dispose(); Navigator.of(dialogContext).pop(); }, - child: const Text('Cancel'), + child: Text(dl.commonCancel), ), ElevatedButton( onPressed: isLoading @@ -162,7 +165,7 @@ class _LoginScreenState extends State { ScaffoldMessenger.of(outerContext).showSnackBar( SnackBar( content: Text( - errorMsg ?? 'Invalid API key', + errorMsg ?? dl.loginApiKeyInvalid, ), backgroundColor: Theme.of(outerContext).colorScheme.error, @@ -176,7 +179,7 @@ class _LoginScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Sign In'), + : Text(dl.loginApiKeySignIn), ), ], ); @@ -212,6 +215,7 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final l = AppLocalizations.of(context); return Scaffold( body: SafeArea( @@ -234,16 +238,16 @@ class _LoginScreenState extends State { color: colorScheme.onSurfaceVariant, ), children: [ - const TextSpan(text: 'Demo account or '), + TextSpan(text: l.loginDemoOrSignUpPrefix), TextSpan( - text: 'Sign Up', + text: l.loginSignUpLink, style: TextStyle( color: colorScheme.primary, fontWeight: FontWeight.w600, ), recognizer: _signUpTapRecognizer, ), - const TextSpan(text: '!'), + TextSpan(text: l.loginSignUpSuffix), ], ), textAlign: TextAlign.center, @@ -295,16 +299,16 @@ class _LoginScreenState extends State { keyboardType: TextInputType.emailAddress, autocorrect: false, textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: 'Email', - prefixIcon: Icon(Icons.email_outlined), + decoration: InputDecoration( + labelText: l.loginEmailLabel, + prefixIcon: const Icon(Icons.email_outlined), ), validator: (value) { if (value == null || value.isEmpty) { - return 'Please enter your email'; + return l.loginEmailRequired; } if (!value.contains('@')) { - return 'Please enter a valid email'; + return l.loginEmailInvalid; } return null; }, @@ -328,7 +332,7 @@ class _LoginScreenState extends State { ? TextInputAction.next : TextInputAction.done, decoration: InputDecoration( - labelText: 'Password', + labelText: l.loginPasswordLabel, prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( icon: Icon( @@ -345,7 +349,7 @@ class _LoginScreenState extends State { ), validator: (value) { if (value == null || value.isEmpty) { - return 'Please enter your password'; + return l.loginPasswordRequired; } return null; }, @@ -372,7 +376,7 @@ class _LoginScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Two-factor authentication is enabled. Enter your code.', + l.loginMfaInfo, style: TextStyle( color: colorScheme.onSurface), ), @@ -385,14 +389,14 @@ class _LoginScreenState extends State { controller: _otpController, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Authentication Code', - prefixIcon: Icon(Icons.pin_outlined), + decoration: InputDecoration( + labelText: l.loginMfaLabel, + prefixIcon: const Icon(Icons.pin_outlined), ), validator: (value) { if (showOtp && (value == null || value.isEmpty)) { - return 'Please enter your authentication code'; + return l.loginMfaCodeRequired; } return null; }, @@ -410,7 +414,7 @@ class _LoginScreenState extends State { Consumer( builder: (context, authProvider, _) { return SureButton( - label: 'Sign In', + label: l.loginSignIn, size: SureButtonSize.lg, fullWidth: true, loading: authProvider.isLoading, @@ -429,7 +433,7 @@ class _LoginScreenState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'or', + l.loginOrDivider, style: TextStyle(color: colorScheme.onSurfaceVariant), ), @@ -445,7 +449,7 @@ class _LoginScreenState extends State { Consumer( builder: (context, authProvider, _) { return SureButton( - label: 'Sign in with Google', + label: l.loginSignInWithGoogle, variant: SureButtonVariant.outline, size: SureButtonSize.lg, fullWidth: true, @@ -478,7 +482,7 @@ class _LoginScreenState extends State { child: Column( children: [ Text( - 'Sure server URL:', + l.loginServerUrlHeading, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, @@ -506,7 +510,7 @@ class _LoginScreenState extends State { Consumer( builder: (context, authProvider, _) { return SureButton( - label: 'API-Key Login', + label: l.loginApiKeyLoginButton, variant: SureButtonVariant.ghost, onPressed: authProvider.isLoading ? null : _showApiKeyDialog, @@ -524,7 +528,7 @@ class _LoginScreenState extends State { top: 8, child: IconButton( icon: const Icon(Icons.settings_outlined), - tooltip: 'Backend Settings', + tooltip: l.loginBackendSettingsTooltip, onPressed: () => widget._openSettings(context), ), ), diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart index 50cef6d92..0e5e25d04 100644 --- a/mobile/lib/screens/main_navigation_screen.dart +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -8,6 +8,7 @@ import 'dashboard_screen.dart'; import 'intro_screen.dart'; import 'more_screen.dart'; import 'settings_screen.dart'; +import '../l10n/app_localizations.dart'; class MainNavigationScreen extends StatefulWidget { const MainNavigationScreen({super.key}); @@ -72,43 +73,43 @@ class _MainNavigationScreenState extends State { await _handleDestinationSelected(settingsIndex, authProvider, introLayout); } - List _buildDestinations(bool introLayout) { + List _buildDestinations(bool introLayout, AppLocalizations l) { final destinations = []; if (!introLayout) { destinations.add( - const NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', + NavigationDestination( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: l.navHome, ), ); } if (introLayout) { destinations.add( - const NavigationDestination( - icon: Icon(Icons.auto_awesome_outlined), - selectedIcon: Icon(Icons.auto_awesome), - label: 'Intro', + NavigationDestination( + icon: const Icon(Icons.auto_awesome_outlined), + selectedIcon: const Icon(Icons.auto_awesome), + label: l.navIntro, ), ); } destinations.add( - const NavigationDestination( - icon: Icon(Icons.chat_bubble_outline), - selectedIcon: Icon(Icons.chat_bubble), - label: 'Assistant', + NavigationDestination( + icon: const Icon(Icons.chat_bubble_outline), + selectedIcon: const Icon(Icons.chat_bubble), + label: l.navAssistant, ), ); if (!introLayout) { destinations.add( - const NavigationDestination( - icon: Icon(Icons.more_horiz), - selectedIcon: Icon(Icons.more_horiz), - label: 'More', + NavigationDestination( + icon: const Icon(Icons.more_horiz), + selectedIcon: const Icon(Icons.more_horiz), + label: l.navMore, ), ); } @@ -154,24 +155,28 @@ class _MainNavigationScreenState extends State { } Future _showEnableAiPrompt() async { + final l = AppLocalizations.of(context); final authProvider = Provider.of(context, listen: false); final shouldEnable = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Turn on AI Chat?'), - content: const Text('AI Chat is currently disabled in your account settings. Would you like to turn it on now?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Not now'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Turn on AI'), - ), - ], - ), + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.navEnableAiChatTitle), + content: Text(dl.navEnableAiChatContent), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(dl.navEnableAiChatNotNow), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(dl.navEnableAiChatConfirm), + ), + ], + ); + }, ); if (shouldEnable != true) { @@ -183,7 +188,7 @@ class _MainNavigationScreenState extends State { if (!enabled && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(authProvider.errorMessage ?? 'Unable to enable AI right now.'), + content: Text(authProvider.errorMessage ?? l.navEnableAiChatFailed), backgroundColor: Colors.red, ), ); @@ -210,6 +215,7 @@ class _MainNavigationScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); return Consumer( builder: (context, authProvider, _) { final introLayout = authProvider.isIntroLayout; @@ -218,7 +224,7 @@ class _MainNavigationScreenState extends State { introLayout, () => _handleDestinationSelected(chatIndex, authProvider, introLayout), ); - final destinations = _buildDestinations(introLayout); + final destinations = _buildDestinations(introLayout, l); final bottomNavIndex = _resolveBottomSelectedIndex(destinations); if (_currentIndex >= screens.length) { diff --git a/mobile/lib/screens/more_screen.dart b/mobile/lib/screens/more_screen.dart index 3c41c9f81..ae4e594d5 100644 --- a/mobile/lib/screens/more_screen.dart +++ b/mobile/lib/screens/more_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../theme/sure_colors.dart'; import '../theme/sure_tokens.dart'; import '../widgets/sure_list_group.dart'; @@ -10,6 +11,7 @@ class MoreScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); return Scaffold( // Setting an explicit ListView padding opts out of the scroll view's // automatic safe-area inset, so restore it with SafeArea (keeps the group @@ -22,8 +24,8 @@ class MoreScreen extends StatelessWidget { children: [ SureListRow( leading: _iconBadge(context, Icons.calendar_month), - title: 'Account Calendar', - subtitle: 'View monthly balance changes by account', + title: l.moreCalendar, + subtitle: l.moreCalendarSubtitle, showChevron: true, onTap: () { Navigator.push( @@ -36,8 +38,8 @@ class MoreScreen extends StatelessWidget { ), SureListRow( leading: _iconBadge(context, Icons.receipt_long), - title: 'Recent Transactions', - subtitle: 'View recent transactions across all accounts', + title: l.moreRecentTransactions, + subtitle: l.moreRecentTransactionsSubtitle, showChevron: true, onTap: () { Navigator.push( diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index 0dd7518dd..b5af0ba30 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -6,8 +6,10 @@ import '../models/account.dart'; import '../providers/transactions_provider.dart'; import '../providers/accounts_provider.dart'; import '../providers/auth_provider.dart'; +import '../theme/sure_tokens.dart'; import '../utils/amount_parser.dart'; import '../widgets/money_text.dart'; +import '../l10n/app_localizations.dart'; class RecentTransactionsScreen extends StatefulWidget { const RecentTransactionsScreen({super.key}); @@ -84,6 +86,7 @@ class _RecentTransactionsScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final transactionsProvider = context.watch(); @@ -93,12 +96,12 @@ class _RecentTransactionsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Recent Transactions'), + title: Text(l.recentTransactionsTitle), actions: [ PopupMenuButton( initialValue: _transactionLimit, icon: const Icon(Icons.filter_list), - tooltip: 'Display Limit', + tooltip: l.recentTransactionsDisplayLimit, onSelected: (int value) { setState(() { _transactionLimit = value; @@ -114,7 +117,7 @@ class _RecentTransactionsScreenState extends State { else const SizedBox(width: 20), const SizedBox(width: 8), - Text('Show $limit'), + Text(l.recentTransactionsShowN(limit)), ], ), ); @@ -148,6 +151,7 @@ class _RecentTransactionsScreenState extends State { } Widget _buildEmptyState(ColorScheme colorScheme) { + final l = AppLocalizations.of(context); return Center( child: Padding( padding: const EdgeInsets.all(24), @@ -161,12 +165,12 @@ class _RecentTransactionsScreenState extends State { ), const SizedBox(height: 16), Text( - 'No Transactions', + l.recentTransactionsEmpty, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - 'Pull to refresh', + l.recentTransactionsPullToRefresh, style: TextStyle(color: colorScheme.onSurfaceVariant), ), ], @@ -178,7 +182,8 @@ class _RecentTransactionsScreenState extends State { Widget _buildTransactionItem( BuildContext context, Transaction transaction, ColorScheme colorScheme) { final account = _getAccount(transaction.accountId); - final accountName = account?.name ?? 'Unknown Account'; + final accountName = account?.name ?? + AppLocalizations.of(context).recentTransactionsUnknownAccount; double? amount; try { @@ -208,7 +213,9 @@ class _RecentTransactionsScreenState extends State { String formattedDate; try { final date = DateTime.parse(transaction.date); - formattedDate = DateFormat('yyyy-MM-dd HH:mm').format(date); + formattedDate = DateFormat('yyyy-MM-dd HH:mm', + Localizations.localeOf(context).toString()) + .format(date); } catch (e) { formattedDate = transaction.date; } @@ -232,7 +239,7 @@ class _RecentTransactionsScreenState extends State { ), title: Text( transaction.name, - style: const TextStyle(fontWeight: FontWeight.w500), + style: const TextStyle(fontWeight: SureTokens.weightMedium), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -274,7 +281,7 @@ class _RecentTransactionsScreenState extends State { : '$sign${transaction.currency} ${_formatAmount(amount.abs())}', trend: moneyTrend, style: const TextStyle( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, fontSize: 16, ), ), diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 38517eb93..818fd7471 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -18,6 +18,7 @@ import '../models/custom_proxy_header.dart'; import '../services/api_config.dart'; import '../services/custom_proxy_headers_service.dart'; import '../widgets/custom_proxy_headers_editor.dart'; +import '../l10n/app_localizations.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -73,16 +74,20 @@ class _SettingsScreenState extends State { final storeUrl = _manualUpgrader.versionInfo?.appStoreListingURL; if (available) { - await _showUpdateDialog(storeVersion ?? 'a newer version', storeUrl); + await _showUpdateDialog( + storeVersion ?? + AppLocalizations.of(context).settingsUpdateNewerVersionFallback, + storeUrl, + ); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("You're up to date!")), + SnackBar(content: Text(AppLocalizations.of(context).settingsNoUpdateAvailable)), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unable to check for updates')), + SnackBar(content: Text(AppLocalizations.of(context).settingsUpdateError)), ); } } finally { @@ -94,16 +99,16 @@ class _SettingsScreenState extends State { final launch = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Update available'), - content: Text('Version $version is available. Update now?'), + title: Text(AppLocalizations.of(ctx).settingsUpdateAvailableTitle), + content: Text(AppLocalizations.of(ctx).settingsUpdateAvailableContent(version)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text('Later'), + child: Text(AppLocalizations.of(ctx).commonCancel), ), TextButton( onPressed: storeUrl == null ? null : () => Navigator.pop(ctx, true), - child: const Text('Update now'), + child: Text(AppLocalizations.of(ctx).settingsUpdateNow), ), ], ), @@ -116,7 +121,7 @@ class _SettingsScreenState extends State { : false; if (!opened && mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unable to open store link')), + SnackBar(content: Text(AppLocalizations.of(context).settingsUpdateOpenStoreError)), ); } } @@ -148,12 +153,12 @@ class _SettingsScreenState extends State { try { if (value) { final success = await BiometricService.instance.authenticate( - reason: 'Verify biometric to enable app lock', + reason: AppLocalizations.of(context).settingsBiometricVerifyReason, ); if (!mounted) return; if (!success) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Biometric authentication failed.')), + SnackBar(content: Text(AppLocalizations.of(context).settingsBiometricFailed)), ); return; } @@ -201,28 +206,28 @@ class _SettingsScreenState extends State { } Future _handleClearLocalData(BuildContext context) async { + final l = AppLocalizations.of(context); final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Clear Local Data'), - content: const Text( - 'This will delete all locally cached transactions and accounts. ' - 'Your data on the server will not be affected. ' - 'Are you sure you want to continue?'), + builder: (context) { + final l = AppLocalizations.of(context); + return AlertDialog( + title: Text(l.settingsClearDataTitle), + content: Text(l.settingsClearDataContent), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(l.commonCancel), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), - child: const Text('Clear Data'), + child: Text(l.settingsClearData), ), ], - ), + );}, ); if (confirmed == true && context.mounted) { @@ -241,11 +246,10 @@ class _SettingsScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Local data cleared successfully. Pull to refresh to sync from server.'), + SnackBar( + content: Text(l.settingsClearDataSuccessDetailed), backgroundColor: Colors.green, - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), ), ); } @@ -258,10 +262,10 @@ class _SettingsScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to clear local data.'), + SnackBar( + content: Text(l.settingsClearDataFailed), backgroundColor: Colors.red, - duration: Duration(seconds: 3), + duration: const Duration(seconds: 3), ), ); } @@ -270,39 +274,40 @@ class _SettingsScreenState extends State { } Future _launchContactUrl(BuildContext context) async { + final l = AppLocalizations.of(context); final uri = Uri.parse('https://discord.com/invite/36ZGBsxYEK'); final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!launched && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unable to open link')), + SnackBar(content: Text(l.settingsContactOpenLinkError)), ); } } Future _handleResetAccount(BuildContext context) async { + final l = AppLocalizations.of(context); final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Reset Account'), - content: const Text( - 'Resetting your account will delete all your accounts, categories, ' - 'merchants, tags, and other data, but keep your user account intact.\n\n' - 'This action cannot be undone. Are you sure?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.settingsResetAccount), + content: Text(dl.settingsResetAccountContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(dl.commonCancel), ), - child: const Text('Reset Account'), - ), - ], - ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(dl.settingsResetAccount), + ), + ], + ); + }, ); if (confirmed != true || !context.mounted) return; @@ -331,9 +336,8 @@ class _SettingsScreenState extends State { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Account reset has been initiated. This may take a moment.'), + SnackBar( + content: Text(l.settingsResetAccountInitiated), backgroundColor: Colors.green, ), ); @@ -342,7 +346,7 @@ class _SettingsScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['error'] ?? 'Failed to reset account'), + content: Text(result['error'] ?? l.settingsResetAccountFailed), backgroundColor: Colors.red, ), ); @@ -353,29 +357,29 @@ class _SettingsScreenState extends State { } Future _handleDeleteAccount(BuildContext context) async { + final l = AppLocalizations.of(context); final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Account'), - content: const Text( - 'Deleting your account will permanently remove all your data ' - 'and cannot be undone.\n\n' - 'Are you sure you want to delete your account?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.settingsDeleteAccountTitle), + content: Text(dl.settingsDeleteAccountConfirmContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(dl.commonCancel), ), - child: const Text('Delete Account'), - ), - ], - ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(dl.settingsDeleteAccount), + ), + ], + ); + }, ); if (confirmed != true || !context.mounted) return; @@ -399,7 +403,7 @@ class _SettingsScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['error'] ?? 'Failed to delete account'), + content: Text(result['error'] ?? l.settingsDeleteAccountFailed), backgroundColor: Colors.red, ), ); @@ -412,20 +416,22 @@ class _SettingsScreenState extends State { Future _handleLogout(BuildContext context) async { final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Sign Out'), - content: const Text('Are you sure you want to sign out?'), + builder: (context) { + final l = AppLocalizations.of(context); + return AlertDialog( + title: Text(l.settingsSignOutTitle), + content: Text(l.settingsSignOutContent), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(l.commonCancel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: const Text('Sign Out'), + child: Text(l.settingsSignOut), ), ], - ), + );}, ); if (confirmed == true && context.mounted) { @@ -435,6 +441,7 @@ class _SettingsScreenState extends State { } Future _showCustomHeadersDialog() async { + final l = AppLocalizations.of(context); final formKey = GlobalKey(); final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders(); @@ -446,8 +453,9 @@ class _SettingsScreenState extends State { final saved = await showDialog( context: context, builder: (context) { + final l = AppLocalizations.of(context); return AlertDialog( - title: const Text('Custom proxy headers'), + title: Text(l.settingsProxyHeadersLabel), content: SingleChildScrollView( child: Form( key: formKey, @@ -461,7 +469,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 12), Text( - 'Headers are sent by the app with API requests. External browser SSO pages may not receive them.', + l.settingsProxyHeadersNote, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -473,14 +481,14 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(l.commonCancel), ), ElevatedButton( onPressed: () { if (formKey.currentState?.validate() != true) return; Navigator.pop(context, true); }, - child: const Text('Save'), + child: Text(l.commonSave), ), ], ); @@ -495,7 +503,7 @@ class _SettingsScreenState extends State { if (!mounted) return; setState(() => _customHeaders = draftHeaders); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Custom proxy headers saved')), + SnackBar(content: Text(l.settingsProxyHeadersSaved)), ); } catch (e) { if (!mounted) return; @@ -505,7 +513,7 @@ class _SettingsScreenState extends State { ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Failed to save custom proxy headers.'), + content: Text(l.settingsProxyHeadersSaveFailed), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -516,6 +524,7 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final authProvider = Provider.of(context); + final l = AppLocalizations.of(context); return Scaffold( body: ListView( @@ -549,7 +558,7 @@ class _SettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - authProvider.user?.displayName ?? 'User', + authProvider.user?.displayName ?? l.settingsUserFallback, style: Theme.of(context) .textTheme .titleLarge @@ -578,7 +587,7 @@ class _SettingsScreenState extends State { // App version ListTile( leading: const Icon(Icons.info_outline), - title: Text('App Version: ${_appVersion ?? '…'}'), + title: Text(l.settingsAppVersion(_appVersion ?? '…')), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -591,8 +600,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.system_update_outlined), - title: const Text('Check for updates'), - subtitle: const Text('See if a newer version is available'), + title: Text(l.settingsCheckForUpdates), + subtitle: Text(l.settingsCheckForUpdatesSubtitle), trailing: _isCheckingForUpdate ? const SizedBox( width: 20, @@ -605,7 +614,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.chat_bubble_outline), - title: const Text('Contact us'), + title: Text(l.settingsContactUs), subtitle: Text( 'https://discord.com/invite/36ZGBsxYEK', style: TextStyle( @@ -617,12 +626,12 @@ class _SettingsScreenState extends State { ), Semantics( - label: 'Open debug logs', + label: l.settingsDebugLogsSemantics, button: true, child: ListTile( leading: const Icon(Icons.bug_report), - title: const Text('Debug Logs'), - subtitle: const Text('View app diagnostic logs'), + title: Text(l.settingsDebugLogs), + subtitle: Text(l.settingsDebugLogsSubtitle), onTap: () { Navigator.push( context, @@ -636,11 +645,11 @@ class _SettingsScreenState extends State { const Divider(), // Display Settings Section - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Display', - style: TextStyle( + l.settingsSectionDisplay, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey, @@ -650,8 +659,8 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.view_list), - title: const Text('Group by Account Type'), - subtitle: const Text('Group accounts by type (Crypto, Bank, etc.)'), + title: Text(l.settingsGroupByAccountType), + subtitle: Text(l.settingsGroupByAccountTypeSubtitle), value: _groupByType, onChanged: (value) async { await PreferencesService.instance.setGroupByType(value); @@ -665,23 +674,23 @@ class _SettingsScreenState extends State { builder: (context, themeProvider, _) { return ListTile( leading: const Icon(Icons.brightness_6_outlined), - title: const Text('Theme'), + title: Text(l.settingsThemeLabel), trailing: SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: ThemeMode.light, - icon: Icon(Icons.light_mode, size: 18), - tooltip: 'Light', + icon: const Icon(Icons.light_mode, size: 18), + tooltip: l.settingsThemeLight, ), ButtonSegment( value: ThemeMode.system, - icon: Icon(Icons.brightness_auto, size: 18), - tooltip: 'System', + icon: const Icon(Icons.brightness_auto, size: 18), + tooltip: l.settingsThemeSystem, ), ButtonSegment( value: ThemeMode.dark, - icon: Icon(Icons.dark_mode, size: 18), - tooltip: 'Dark', + icon: const Icon(Icons.dark_mode, size: 18), + tooltip: l.settingsThemeDark, ), ], selected: {themeProvider.themeMode}, @@ -695,11 +704,11 @@ class _SettingsScreenState extends State { const Divider(), - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Connection', - style: TextStyle( + l.settingsSectionConnection, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey, @@ -709,11 +718,11 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.http_outlined), - title: const Text('Custom proxy headers'), + title: Text(l.settingsProxyHeadersTileTitle), subtitle: Text( _customHeaders.isEmpty - ? 'Optional headers for a reverse proxy or auth gateway' - : '${_customHeaders.length} configured', + ? l.settingsProxyHeadersTileSubtitleEmpty + : l.settingsProxyHeadersTileSubtitleCount(_customHeaders.length), ), onTap: _showCustomHeadersDialog, ), @@ -721,11 +730,11 @@ class _SettingsScreenState extends State { const Divider(), // Data Management Section - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Data Management', - style: TextStyle( + l.settingsSectionDataManagement, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey, @@ -736,18 +745,18 @@ class _SettingsScreenState extends State { // Clear local data button ListTile( leading: const Icon(Icons.delete_outline), - title: const Text('Clear Local Data'), - subtitle: const Text('Remove all cached transactions and accounts'), + title: Text(l.settingsClearDataTitle), + subtitle: Text(l.settingsClearDataTileSubtitle), onTap: () => _handleClearLocalData(context), ), if (_biometricSupported) ...[ const Divider(), - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Security', - style: TextStyle( + l.settingsSectionSecurity, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey, @@ -756,9 +765,8 @@ class _SettingsScreenState extends State { ), SwitchListTile( secondary: const Icon(Icons.fingerprint), - title: const Text('Biometric Lock'), - subtitle: const Text( - 'Require biometric authentication when resuming the app'), + title: Text(l.settingsBiometricLabel), + subtitle: Text(l.settingsBiometricEnableContent), value: _biometricEnabled, onChanged: _isTogglingBiometric ? null : _toggleBiometric, ), @@ -767,11 +775,11 @@ class _SettingsScreenState extends State { const Divider(), // Danger Zone Section - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Danger Zone', - style: TextStyle( + l.settingsSectionDangerZone, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.red, @@ -781,10 +789,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.restart_alt, color: Colors.red), - title: const Text('Reset Account'), - subtitle: const Text( - 'Delete all accounts, categories, merchants, and tags but keep your user account', - ), + title: Text(l.settingsResetAccount), + subtitle: Text(l.settingsResetAccountTileSubtitle), trailing: _isResettingAccount ? const SizedBox( width: 20, @@ -799,10 +805,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.delete_forever, color: Colors.red), - title: const Text('Delete Account'), - subtitle: const Text( - 'Permanently remove all your data. This cannot be undone.', - ), + title: Text(l.settingsDeleteAccount), + subtitle: Text(l.settingsDeleteAccountTileSubtitle), trailing: _isDeletingAccount ? const SizedBox( width: 20, @@ -823,7 +827,7 @@ class _SettingsScreenState extends State { child: ElevatedButton.icon( onPressed: () => _handleLogout(context), icon: const Icon(Icons.logout), - label: const Text('Sign Out'), + label: Text(l.settingsSignOut), style: ElevatedButton.styleFrom( backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError, diff --git a/mobile/lib/screens/sso_onboarding_screen.dart b/mobile/lib/screens/sso_onboarding_screen.dart index 3fee35077..6c26ccfe8 100644 --- a/mobile/lib/screens/sso_onboarding_screen.dart +++ b/mobile/lib/screens/sso_onboarding_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../providers/auth_provider.dart'; +import '../l10n/app_localizations.dart'; class SsoOnboardingScreen extends StatefulWidget { const SsoOnboardingScreen({super.key}); @@ -71,7 +72,7 @@ class _SsoOnboardingScreenState extends State { .cancelSsoOnboarding(); }, ), - title: const Text('Link Your Account'), + title: Text(AppLocalizations.of(context).ssoOnboardingTitle), ), body: SafeArea( child: SingleChildScrollView( @@ -90,8 +91,10 @@ class _SsoOnboardingScreenState extends State { const SizedBox(height: 16), Text( authProvider.ssoEmail != null - ? 'Signed in as ${authProvider.ssoEmail}' - : 'Google account verified', + ? AppLocalizations.of(context) + .ssoOnboardingSignedInAs(authProvider.ssoEmail!) + : AppLocalizations.of(context) + .ssoOnboardingGoogleVerified, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant, @@ -140,7 +143,7 @@ class _SsoOnboardingScreenState extends State { children: [ Expanded( child: _TabButton( - label: 'Link Existing', + label: AppLocalizations.of(context).ssoOnboardingTabLink, isSelected: _showLinkForm, onTap: () => setState(() => _showLinkForm = true), @@ -149,8 +152,8 @@ class _SsoOnboardingScreenState extends State { Expanded( child: _TabButton( label: authProvider.ssoHasPendingInvitation - ? 'Accept Invitation' - : 'Create New', + ? AppLocalizations.of(context).ssoOnboardingAcceptInvitation + : AppLocalizations.of(context).ssoOnboardingTabCreate, isSelected: !_showLinkForm, onTap: () => setState(() => _showLinkForm = false), @@ -195,7 +198,8 @@ class _SsoOnboardingScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Enter your existing account credentials to link with Google Sign-In.', + AppLocalizations.of(context) + .ssoOnboardingLinkCredentialsNote, style: TextStyle(color: colorScheme.onSurface), ), ), @@ -208,13 +212,13 @@ class _SsoOnboardingScreenState extends State { keyboardType: TextInputType.emailAddress, autocorrect: false, textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: 'Email', - prefixIcon: Icon(Icons.email_outlined), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).loginEmailLabel, + prefixIcon: const Icon(Icons.email_outlined), ), validator: (value) { - if (value == null || value.isEmpty) return 'Please enter your email'; - if (!value.contains('@')) return 'Please enter a valid email'; + if (value == null || value.isEmpty) return AppLocalizations.of(context).loginEmailRequired; + if (!value.contains('@')) return AppLocalizations.of(context).loginEmailInvalid; return null; }, ), @@ -224,7 +228,7 @@ class _SsoOnboardingScreenState extends State { obscureText: _obscurePassword, textInputAction: TextInputAction.done, decoration: InputDecoration( - labelText: 'Password', + labelText: AppLocalizations.of(context).loginPasswordLabel, prefixIcon: const Icon(Icons.lock_outlined), suffixIcon: IconButton( icon: Icon( @@ -238,7 +242,7 @@ class _SsoOnboardingScreenState extends State { ), ), validator: (value) { - if (value == null || value.isEmpty) return 'Please enter your password'; + if (value == null || value.isEmpty) return AppLocalizations.of(context).loginPasswordRequired; return null; }, onFieldSubmitted: (_) => _handleLinkAccount(), @@ -252,7 +256,7 @@ class _SsoOnboardingScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Link Account'), + : Text(AppLocalizations.of(context).ssoOnboardingLinkButton), ), ], ), @@ -282,8 +286,10 @@ class _SsoOnboardingScreenState extends State { Expanded( child: Text( hasPendingInvitation - ? 'You have a pending invitation. Accept it to join an existing household.' - : 'Create a new account using your Google identity.', + ? AppLocalizations.of(context) + .ssoOnboardingPendingInvitationNote + : AppLocalizations.of(context) + .ssoOnboardingCreateIdentityNote, style: TextStyle(color: colorScheme.onSurface), ), ), @@ -294,12 +300,12 @@ class _SsoOnboardingScreenState extends State { TextFormField( controller: _firstNameController, textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: 'First Name', - prefixIcon: Icon(Icons.person_outlined), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).ssoOnboardingFirstNameLabel, + prefixIcon: const Icon(Icons.person_outlined), ), validator: (value) { - if (value == null || value.isEmpty) return 'Please enter your first name'; + if (value == null || value.isEmpty) return AppLocalizations.of(context).ssoOnboardingFirstNameRequired; return null; }, ), @@ -307,12 +313,12 @@ class _SsoOnboardingScreenState extends State { TextFormField( controller: _lastNameController, textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Last Name', - prefixIcon: Icon(Icons.person_outlined), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).ssoOnboardingLastNameLabel, + prefixIcon: const Icon(Icons.person_outlined), ), validator: (value) { - if (value == null || value.isEmpty) return 'Please enter your last name'; + if (value == null || value.isEmpty) return AppLocalizations.of(context).ssoOnboardingLastNameRequired; return null; }, onFieldSubmitted: (_) => _handleCreateAccount(), @@ -327,8 +333,8 @@ class _SsoOnboardingScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : Text(hasPendingInvitation - ? 'Accept Invitation' - : 'Create Account'), + ? AppLocalizations.of(context).ssoOnboardingAcceptInvitation + : AppLocalizations.of(context).ssoOnboardingCreateButton), ), ], ), diff --git a/mobile/lib/screens/transaction_edit_screen.dart b/mobile/lib/screens/transaction_edit_screen.dart index b7ccf8954..97da47f6a 100644 --- a/mobile/lib/screens/transaction_edit_screen.dart +++ b/mobile/lib/screens/transaction_edit_screen.dart @@ -9,6 +9,7 @@ import '../providers/categories_provider.dart'; import '../providers/merchants_provider.dart'; import '../providers/tags_provider.dart'; import '../providers/transactions_provider.dart'; +import '../l10n/app_localizations.dart'; class TransactionEditScreen extends StatefulWidget { final OfflineTransaction transaction; @@ -77,6 +78,7 @@ class _TransactionEditScreenState extends State { } Future _save() async { + final l = AppLocalizations.of(context); if (!_formKey.currentState!.validate() || widget.transaction.id == null) { return; } @@ -96,8 +98,8 @@ class _TransactionEditScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Session expired. Please login again.'), + SnackBar( + content: Text(l.transactionEditSessionExpired), backgroundColor: Colors.red, ), ); @@ -130,8 +132,8 @@ class _TransactionEditScreenState extends State { SnackBar( content: Text( success - ? 'Transaction updated' - : transactionsProvider.error ?? 'Failed to update transaction', + ? l.transactionEditUpdated + : transactionsProvider.error ?? l.transactionEditUpdateFailed, ), backgroundColor: success ? Colors.green : Colors.red, ), @@ -143,32 +145,34 @@ class _TransactionEditScreenState extends State { } String? _validateName(String? value) { + final l = AppLocalizations.of(context); if (value == null || value.trim().isEmpty) { - return 'Name is required'; + return l.transactionEditNameRequired; } if (value.trim().length > _maxNameLength) { - return 'Name must be $_maxNameLength characters or fewer'; + return l.transactionEditNameMaxLength(_maxNameLength); } if (_containsControlCharacter(value)) { - return 'Name contains unsupported characters'; + return l.transactionEditNameInvalidChars; } return null; } String? _validateNotes(String? value) { + final l = AppLocalizations.of(context); if (value == null || value.trim().isEmpty) { return null; } if (value.trim().length > _maxNotesLength) { - return 'Notes must be $_maxNotesLength characters or fewer'; + return l.transactionEditNotesMaxLength(_maxNotesLength); } if (_containsControlCharacter(value, allowWhitespace: true)) { - return 'Notes contain unsupported characters'; + return l.transactionEditNotesInvalidChars; } return null; @@ -193,12 +197,13 @@ class _TransactionEditScreenState extends State { List> _categoryItems( List categories, ) { + final l = AppLocalizations.of(context); final items = >[]; if (_selectedCategoryId == null) { items.add( - const DropdownMenuItem( + DropdownMenuItem( value: null, - child: Text('No category'), + child: Text(l.transactionEditNoCategory), ), ); } @@ -209,7 +214,7 @@ class _TransactionEditScreenState extends State { items.add( DropdownMenuItem( value: _selectedCategoryId, - child: Text(widget.transaction.categoryName ?? 'Current category'), + child: Text(widget.transaction.categoryName ?? l.transactionEditCurrentCategory), ), ); } @@ -227,12 +232,13 @@ class _TransactionEditScreenState extends State { } List> _merchantItems(List merchants) { + final l = AppLocalizations.of(context); final items = >[]; if (_selectedMerchantId == null) { items.add( - const DropdownMenuItem( + DropdownMenuItem( value: null, - child: Text('No merchant'), + child: Text(l.transactionEditNoMerchant), ), ); } @@ -243,7 +249,7 @@ class _TransactionEditScreenState extends State { items.add( DropdownMenuItem( value: _selectedMerchantId, - child: Text(widget.transaction.merchantName ?? 'Current merchant'), + child: Text(widget.transaction.merchantName ?? l.transactionEditCurrentMerchant), ), ); } @@ -261,8 +267,9 @@ class _TransactionEditScreenState extends State { } Widget _buildTags(List tags, {required bool enabled}) { + final l = AppLocalizations.of(context); if (tags.isEmpty && _selectedTagIds.isEmpty) { - return const Text('No tags available'); + return Text(l.transactionEditNoTags); } final tagById = {for (final tag in tags) tag.id: tag}; @@ -277,7 +284,7 @@ class _TransactionEditScreenState extends State { combinedTags.add( TransactionTag( id: selectedId, - name: fallbackName.isNotEmpty ? fallbackName : 'Unknown tag', + name: fallbackName.isNotEmpty ? fallbackName : l.transactionEditUnknownTag, ), ); } @@ -313,8 +320,10 @@ class _TransactionEditScreenState extends State { final canEdit = widget.transaction.id != null && widget.transaction.syncStatus == SyncStatus.synced; + final l = AppLocalizations.of(context); + return Scaffold( - appBar: AppBar(title: const Text('Edit Transaction')), + appBar: AppBar(title: Text(l.transactionEditTitle)), body: Form( key: _formKey, child: ListView( @@ -326,7 +335,7 @@ class _TransactionEditScreenState extends State { child: Padding( padding: const EdgeInsets.all(16), child: Text( - 'Only synced transactions can be edited from mobile.', + l.transactionEditSyncedOnly, style: TextStyle(color: colorScheme.onErrorContainer), ), ), @@ -338,9 +347,9 @@ class _TransactionEditScreenState extends State { enabled: canEdit && !_isSaving, validator: _validateName, maxLength: _maxNameLength, - decoration: const InputDecoration( - labelText: 'Name', - prefixIcon: Icon(Icons.label), + decoration: InputDecoration( + labelText: l.transactionEditNameLabel, + prefixIcon: const Icon(Icons.label), ), ), const SizedBox(height: 16), @@ -351,9 +360,9 @@ class _TransactionEditScreenState extends State { maxLength: _maxNotesLength, minLines: 2, maxLines: 4, - decoration: const InputDecoration( - labelText: 'Notes', - prefixIcon: Icon(Icons.notes), + decoration: InputDecoration( + labelText: l.transactionEditNotesLabel, + prefixIcon: const Icon(Icons.notes), ), ), const SizedBox(height: 16), @@ -361,10 +370,10 @@ class _TransactionEditScreenState extends State { builder: (context, categoriesProvider, _) { return DropdownButtonFormField( value: _selectedCategoryId, - decoration: const InputDecoration( - labelText: 'Category', - prefixIcon: Icon(Icons.category), - helperText: 'Choose a replacement category', + decoration: InputDecoration( + labelText: l.transactionEditCategoryLabel, + prefixIcon: const Icon(Icons.category), + helperText: l.transactionEditCategoryHelper, ), isExpanded: true, items: _categoryItems(categoriesProvider.categories), @@ -379,10 +388,10 @@ class _TransactionEditScreenState extends State { builder: (context, merchantsProvider, _) { return DropdownButtonFormField( value: _selectedMerchantId, - decoration: const InputDecoration( - labelText: 'Merchant', - prefixIcon: Icon(Icons.storefront), - helperText: 'Choose a replacement merchant', + decoration: InputDecoration( + labelText: l.transactionEditMerchantLabel, + prefixIcon: const Icon(Icons.storefront), + helperText: l.transactionEditMerchantHelper, ), isExpanded: true, items: _merchantItems(merchantsProvider.merchants), @@ -393,7 +402,7 @@ class _TransactionEditScreenState extends State { }, ), const SizedBox(height: 24), - Text('Tags', style: Theme.of(context).textTheme.titleMedium), + Text(l.transactionEditTagsLabel, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), Consumer( builder: (context, tagsProvider, _) => @@ -409,7 +418,7 @@ class _TransactionEditScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save), - label: Text(_isSaving ? 'Saving...' : 'Save Changes'), + label: Text(_isSaving ? l.transactionEditSaving : l.commonSave), ), ], ), diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index ef54cd6e6..66111ec0b 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -10,6 +10,7 @@ import '../services/log_service.dart'; import '../services/connectivity_service.dart'; import '../utils/amount_parser.dart'; import '../widgets/sure_segmented_control.dart'; +import '../l10n/app_localizations.dart'; class TransactionFormScreen extends StatefulWidget { final Account account; @@ -64,19 +65,20 @@ class _TransactionFormScreenState extends State { } String? _validateAmount(String? value) { + final l = AppLocalizations.of(context); if (value == null || value.trim().isEmpty) { - return 'Please enter an amount'; + return l.transactionFormAmountRequiredPrompt; } final double amount; try { amount = AmountParser.parse(value, locale: _currentLocaleName()).value; } on FormatException { - return 'Please enter a valid number'; + return l.transactionFormAmountInvalidNumber; } if (amount <= 0) { - return 'Amount must be greater than 0'; + return l.transactionFormAmountTooSmall; } return null; @@ -113,6 +115,8 @@ class _TransactionFormScreenState extends State { _log.info('TransactionForm', 'Starting transaction creation...'); + final l = AppLocalizations.of(context); + try { final authProvider = Provider.of(context, listen: false); final transactionsProvider = Provider.of( @@ -128,8 +132,8 @@ class _TransactionFormScreenState extends State { ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Session expired. Please login again.'), + SnackBar( + content: Text(l.transactionFormSessionExpired), backgroundColor: Colors.red, ), ); @@ -183,8 +187,8 @@ class _TransactionFormScreenState extends State { SnackBar( content: Text( isOnline - ? 'Transaction created successfully' - : 'Transaction saved (will sync when online)', + ? l.transactionFormCreateSuccessOnline + : l.transactionFormCreateSuccessOffline, ), backgroundColor: Colors.green, ), @@ -193,8 +197,8 @@ class _TransactionFormScreenState extends State { } else { _log.error('TransactionForm', 'Failed to create transaction'); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to create transaction'), + SnackBar( + content: Text(l.transactionFormCreateFailed), backgroundColor: Colors.red, ), ); @@ -208,7 +212,7 @@ class _TransactionFormScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error: ${e.toString()}'), + content: Text(l.transactionFormGenericError(e.toString())), backgroundColor: Colors.red, ), ); @@ -224,6 +228,7 @@ class _TransactionFormScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Container( @@ -262,7 +267,7 @@ class _TransactionFormScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'New Transaction', + l.transactionFormNewTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -337,7 +342,7 @@ class _TransactionFormScreenState extends State { // Transaction type selection Text( - 'Type', + l.transactionFormTypeLabel, style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.w600), ), @@ -349,16 +354,16 @@ class _TransactionFormScreenState extends State { _nature = value; }); }, - segments: const [ + segments: [ SureSegment( value: 'expense', - label: 'Expense', - icon: Icon(Icons.arrow_downward), + label: l.transactionFormTypeExpense, + icon: const Icon(Icons.arrow_downward), ), SureSegment( value: 'income', - label: 'Income', - icon: Icon(Icons.arrow_upward), + label: l.transactionFormTypeIncome, + icon: const Icon(Icons.arrow_upward), ), ], ), @@ -371,10 +376,10 @@ class _TransactionFormScreenState extends State { decimal: true, ), decoration: InputDecoration( - labelText: 'Amount *', + labelText: '${l.transactionFormAmountLabel} *', prefixIcon: const Icon(Icons.attach_money), suffixText: widget.account.currency, - helperText: 'Required', + helperText: l.transactionFormAmountHelper, ), validator: _validateAmount, ), @@ -392,7 +397,11 @@ class _TransactionFormScreenState extends State { ? Icons.expand_less : Icons.expand_more, ), - label: Text(_showMoreFields ? 'Less' : 'More'), + label: Text( + _showMoreFields + ? l.transactionFormLess + : l.transactionFormMore, + ), ), // Optional fields (shown when More is clicked) @@ -403,10 +412,10 @@ class _TransactionFormScreenState extends State { TextFormField( controller: _dateController, readOnly: true, - decoration: const InputDecoration( - labelText: 'Date', - prefixIcon: Icon(Icons.calendar_today), - helperText: 'Optional (default: today)', + decoration: InputDecoration( + labelText: l.transactionFormDateLabel, + prefixIcon: const Icon(Icons.calendar_today), + helperText: l.transactionFormDateHelper, ), onTap: _selectDate, ), @@ -415,10 +424,10 @@ class _TransactionFormScreenState extends State { // Name field TextFormField( controller: _nameController, - decoration: const InputDecoration( - labelText: 'Name', - prefixIcon: Icon(Icons.label), - helperText: 'Optional (default: SureApp)', + decoration: InputDecoration( + labelText: l.transactionFormNameLabel, + prefixIcon: const Icon(Icons.label), + helperText: l.transactionFormNameHelper, ), ), const SizedBox(height: 16), @@ -427,12 +436,14 @@ class _TransactionFormScreenState extends State { Consumer( builder: (context, categoriesProvider, _) { if (categoriesProvider.isLoading) { - return const InputDecorator( + return InputDecorator( decoration: InputDecoration( - labelText: 'Category', - prefixIcon: Icon(Icons.category), + labelText: l.transactionFormCategoryLabel, + prefixIcon: const Icon(Icons.category), + ), + child: Text( + l.transactionFormCategoryLoading, ), - child: Text('Loading categories...'), ); } @@ -440,16 +451,16 @@ class _TransactionFormScreenState extends State { return DropdownButtonFormField( value: _selectedCategory?.id, - decoration: const InputDecoration( - labelText: 'Category', - prefixIcon: Icon(Icons.category), - helperText: 'Optional', + decoration: InputDecoration( + labelText: l.transactionFormCategoryLabel, + prefixIcon: const Icon(Icons.category), + helperText: l.transactionFormCategoryHelper, ), isExpanded: true, items: [ - const DropdownMenuItem( + DropdownMenuItem( value: null, - child: Text('No category'), + child: Text(l.transactionFormNoCategory), ), ...categories.map((category) { return DropdownMenuItem( @@ -487,7 +498,7 @@ class _TransactionFormScreenState extends State { strokeWidth: 2, ), ) - : const Text('Create Transaction'), + : Text(l.transactionFormCreateButton), ), ], ), diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 010f974eb..7d4c2593c 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -12,8 +12,10 @@ import '../widgets/account_detail_header.dart'; import '../widgets/category_filter.dart'; import '../widgets/sync_status_badge.dart'; import '../services/log_service.dart'; +import '../theme/sure_tokens.dart'; import '../utils/amount_parser.dart'; import '../widgets/money_text.dart'; +import '../l10n/app_localizations.dart'; class TransactionsListScreen extends StatefulWidget { final Account account; @@ -115,8 +117,8 @@ class _TransactionsListScreenState extends State { if (accessToken == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Authentication failed: Please log in again'), + SnackBar( + content: Text(AppLocalizations.of(context).transactionsListAuthFailed), backgroundColor: Colors.red, ), ); @@ -151,28 +153,33 @@ class _TransactionsListScreenState extends State { Future _deleteSelectedTransactions() async { if (_selectedTransactions.isEmpty) return; + final l = AppLocalizations.of(context); final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Transactions'), - content: Text('Are you sure you want to delete ${_selectedTransactions.length} transaction(s)?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Delete'), - ), - ], - ), + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.transactionsListDeleteMultiTitle), + content: Text(dl.transactionsListDeleteMultiContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(dl.commonCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(dl.commonDelete), + ), + ], + ); + }, ); if (confirmed != true || !mounted) return; + final count = _selectedTransactions.length; final authProvider = Provider.of(context, listen: false); final transactionsProvider = Provider.of(context, listen: false); @@ -187,7 +194,7 @@ class _TransactionsListScreenState extends State { if (success) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Deleted ${_selectedTransactions.length} transaction(s)'), + content: Text(l.transactionsListDeletedMulti(count)), backgroundColor: Colors.green, ), ); @@ -197,8 +204,8 @@ class _TransactionsListScreenState extends State { }); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to delete transactions'), + SnackBar( + content: Text(l.transactionsListDeleteFailed), backgroundColor: Colors.red, ), ); @@ -208,29 +215,34 @@ class _TransactionsListScreenState extends State { } Future _undoTransaction(OfflineTransaction transaction) async { + final l = AppLocalizations.of(context); final transactionsProvider = Provider.of(context, listen: false); final scaffoldMessenger = ScaffoldMessenger.of(context); + final isPending = transaction.syncStatus == SyncStatus.pending; final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Undo Transaction'), - content: Text( - transaction.syncStatus == SyncStatus.pending - ? 'Remove this pending transaction?' - : 'Restore this transaction?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.transactionsListUndoTitle), + content: Text( + isPending + ? dl.transactionsListUndoRemovePending + : dl.transactionsListUndoRestoreConfirm, ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Undo'), - ), - ], - ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(dl.commonCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(dl.commonUndo), + ), + ], + ); + }, ); if (confirmed != true) return; @@ -245,10 +257,10 @@ class _TransactionsListScreenState extends State { SnackBar( content: Text( success - ? (transaction.syncStatus == SyncStatus.pending - ? 'Pending transaction removed' - : 'Transaction restored') - : 'Failed to undo transaction', + ? (isPending + ? l.transactionsListUndoPendingRemoved + : l.transactionsListUndoRestored) + : l.transactionsListUndoFailed, ), backgroundColor: success ? Colors.green : Colors.red, ), @@ -273,28 +285,32 @@ class _TransactionsListScreenState extends State { if (transaction.id == null) return false; // Show confirmation dialog - // Capture providers before async gap + // Capture providers and localizations before async gap + final l = AppLocalizations.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); final authProvider = Provider.of(context, listen: false); final transactionsProvider = Provider.of(context, listen: false); final confirmed = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Transaction'), - content: Text('Are you sure you want to delete "${transaction.name}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Delete'), - ), - ], - ), + builder: (context) { + final dl = AppLocalizations.of(context); + return AlertDialog( + title: Text(dl.transactionsListDeleteTitle), + content: Text(dl.transactionsListDeleteSingleContent(transaction.name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(dl.commonCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(dl.commonDelete), + ), + ], + ); + }, ); if (confirmed != true) return false; @@ -304,8 +320,8 @@ class _TransactionsListScreenState extends State { if (accessToken == null) { scaffoldMessenger.showSnackBar( - const SnackBar( - content: Text('Failed to delete: No access token'), + SnackBar( + content: Text(l.transactionsListDeleteNoToken), backgroundColor: Colors.red, ), ); @@ -320,7 +336,11 @@ class _TransactionsListScreenState extends State { if (mounted) { scaffoldMessenger.showSnackBar( SnackBar( - content: Text(success ? 'Transaction deleted' : 'Failed to delete transaction'), + content: Text( + success + ? l.transactionsListDeletedSuccess + : l.transactionsListSingleDeleteFailed, + ), backgroundColor: success ? Colors.green : Colors.red, ), ); @@ -344,6 +364,7 @@ class _TransactionsListScreenState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -390,7 +411,7 @@ class _TransactionsListScreenState extends State { const SizedBox(height: 16), ElevatedButton( onPressed: _loadTransactions, - child: const Text('Retry'), + child: Text(l.transactionsListRetry), ), ], ), @@ -420,7 +441,7 @@ class _TransactionsListScreenState extends State { ), const SizedBox(height: 16), Text( - 'No transactions yet', + l.transactionsListNoTransactionsYet, style: TextStyle( fontSize: 16, color: colorScheme.onSurfaceVariant, @@ -428,7 +449,7 @@ class _TransactionsListScreenState extends State { ), const SizedBox(height: 8), Text( - 'Tap + to add your first transaction', + l.transactionsListEmptyAddFirst, style: TextStyle( fontSize: 14, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), @@ -477,7 +498,7 @@ class _TransactionsListScreenState extends State { height: MediaQuery.of(context).size.height * 0.4, child: Center( child: Text( - 'No transactions match this category', + l.transactionsListNoCategoryMatch, style: TextStyle(color: colorScheme.onSurfaceVariant), ), ), @@ -564,7 +585,7 @@ class _TransactionsListScreenState extends State { child: Text( transaction.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), overflow: TextOverflow.ellipsis, ), @@ -586,7 +607,7 @@ class _TransactionsListScreenState extends State { _getCategoryDisplayName(transaction.categoryId, transaction.categoryName) ?? '', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), overflow: TextOverflow.ellipsis, ), @@ -651,7 +672,7 @@ class _TransactionsListScreenState extends State { height: 36, child: IconButton( icon: const Icon(Icons.edit), - tooltip: 'Edit transaction', + tooltip: l.transactionsListEditTooltip, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, onPressed: () => @@ -664,7 +685,7 @@ class _TransactionsListScreenState extends State { trend: displayInfo['trend'] as MoneyTrend, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), ), ), @@ -689,7 +710,7 @@ class _TransactionsListScreenState extends State { style: TextStyle( color: Colors.blue, fontSize: 11, - fontWeight: FontWeight.w600, + fontWeight: SureTokens.weightSemibold, ), ), ), diff --git a/mobile/lib/theme/sure_tokens.dart b/mobile/lib/theme/sure_tokens.dart index 4ec1173dd..60ad0d441 100644 --- a/mobile/lib/theme/sure_tokens.dart +++ b/mobile/lib/theme/sure_tokens.dart @@ -22,6 +22,9 @@ class SureTokens { static const double radiusMd = 8.0; static const double radiusLg = 10.0; + static const FontWeight weightMedium = FontWeight.w500; + static const FontWeight weightSemibold = FontWeight.w600; + static const light = SureTokenPalette( surface: Color(0xFFF7F7F7), surfaceHover: Color(0xFFF0F0F0), diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart index bb8e13137..1c04162bd 100644 --- a/mobile/lib/widgets/account_card.dart +++ b/mobile/lib/widgets/account_card.dart @@ -88,7 +88,7 @@ class AccountCard extends StatelessWidget { Text( account.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -112,7 +112,7 @@ class AccountCard extends StatelessWidget { account.balance, style: SureMoney.tabular( Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: account.isLiability ? SureColors.of(context).palette.destructive : null, diff --git a/mobile/lib/widgets/account_detail_header.dart b/mobile/lib/widgets/account_detail_header.dart index 5264efba8..ad13285df 100644 --- a/mobile/lib/widgets/account_detail_header.dart +++ b/mobile/lib/widgets/account_detail_header.dart @@ -7,6 +7,7 @@ import '../models/account_balance.dart'; import '../models/account_holding.dart'; import '../providers/auth_provider.dart'; import '../services/account_detail_service.dart'; +import '../l10n/app_localizations.dart'; class AccountDetailHeader extends StatefulWidget { final Account account; @@ -29,7 +30,7 @@ class _AccountDetailHeaderState extends State { widget.accountDetailService == null; late Account _account; bool _isLoading = false; - String? _error; + bool _detailsUnavailable = false; List _balances = []; List _holdings = []; bool _disposed = false; @@ -46,6 +47,9 @@ class _AccountDetailHeaderState extends State { Future _loadDetails() async { if (_disposed) return; + // NOTE: this runs synchronously from initState(); do not touch inherited + // widgets (e.g. AppLocalizations.of) here. The failure message is localized + // at render time in build() via the _detailsUnavailable flag. final authProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); if (_disposed) return; @@ -60,7 +64,7 @@ class _AccountDetailHeaderState extends State { if (mounted) { setState(() { _isLoading = true; - _error = null; + _detailsUnavailable = false; }); } @@ -117,7 +121,7 @@ class _AccountDetailHeaderState extends State { } if (accountResult['success'] != true && balancesResult['success'] != true) { - _error = 'Account details are temporarily unavailable'; + _detailsUnavailable = true; } _isLoading = false; }); @@ -151,6 +155,7 @@ class _AccountDetailHeaderState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; final latestBalance = _balances.isNotEmpty ? _balances.first : null; @@ -196,7 +201,7 @@ class _AccountDetailHeaderState extends State { else IconButton( icon: const Icon(Icons.refresh), - tooltip: 'Refresh account details', + tooltip: l.accountDetailRefreshTooltip, onPressed: _loadDetails, ), ], @@ -222,7 +227,7 @@ class _AccountDetailHeaderState extends State { ), if (_account.cashBalance != null) _DetailChip( - label: 'Cash ${_account.cashBalance}', + label: l.accountDetailCashChip(_account.cashBalance!), icon: Icons.payments_outlined, ), if (_account.status != null) @@ -239,7 +244,7 @@ class _AccountDetailHeaderState extends State { children: [ Expanded( child: Text( - 'Recent balance history', + l.accountDetailRecentBalanceHistory, style: Theme.of(context).textTheme.titleSmall, ), ), @@ -264,7 +269,7 @@ class _AccountDetailHeaderState extends State { if (_holdings.isNotEmpty) ...[ const SizedBox(height: 16), Text( - 'Top holdings', + l.accountDetailTopHoldings, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), @@ -277,7 +282,9 @@ class _AccountDetailHeaderState extends State { child: Text( holding.ticker?.isNotEmpty == true ? holding.ticker! - : holding.securityName ?? 'Holding', + : holding.securityName?.trim().isNotEmpty == true + ? holding.securityName! + : l.accountDetailHoldingFallback, overflow: TextOverflow.ellipsis, ), ), @@ -295,10 +302,10 @@ class _AccountDetailHeaderState extends State { ), ), ], - if (_error != null) ...[ + if (_detailsUnavailable) ...[ const SizedBox(height: 8), Text( - _error!, + l.accountDetailUnavailable, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.error, ), diff --git a/mobile/lib/widgets/category_filter.dart b/mobile/lib/widgets/category_filter.dart index 448c1fc2b..604d4d31a 100644 --- a/mobile/lib/widgets/category_filter.dart +++ b/mobile/lib/widgets/category_filter.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../models/category.dart' as models; class CategoryFilter extends StatelessWidget { @@ -15,6 +16,7 @@ class CategoryFilter extends StatelessWidget { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); if (availableCategories.isEmpty) { return const SizedBox.shrink(); } @@ -32,7 +34,7 @@ class CategoryFilter extends StatelessWidget { Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( - label: const Text('All'), + label: Text(l.commonAll), selected: isAllSelected, onSelected: (_) { onSelectionChanged({}); diff --git a/mobile/lib/widgets/connectivity_banner.dart b/mobile/lib/widgets/connectivity_banner.dart index eb8fb01f1..d87de5fd0 100644 --- a/mobile/lib/widgets/connectivity_banner.dart +++ b/mobile/lib/widgets/connectivity_banner.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../services/connectivity_service.dart'; import '../providers/transactions_provider.dart'; import '../providers/auth_provider.dart'; +import '../l10n/app_localizations.dart'; class ConnectivityBanner extends StatefulWidget { const ConnectivityBanner({super.key}); @@ -15,11 +16,14 @@ class _ConnectivityBannerState extends State { bool _isSyncing = false; Future _handleSync(BuildContext context, String? accessToken, TransactionsProvider transactionsProvider) async { + // Capture context-derived objects before the async gap so we never touch + // `context` after an await. + final l = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); if (accessToken == null) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please sign in to sync transactions'), + messenger.showSnackBar( + SnackBar( + content: Text(l.connectivitySignInToSync), backgroundColor: Colors.orange, ), ); @@ -35,17 +39,17 @@ class _ConnectivityBannerState extends State { await transactionsProvider.syncTransactions(accessToken: accessToken); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Transactions synced successfully'), + messenger.showSnackBar( + SnackBar( + content: Text(l.connectivitySyncSuccess), backgroundColor: Colors.green, ), ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to sync transactions. Please try again.'), + messenger.showSnackBar( + SnackBar( + content: Text(l.connectivitySyncFailed), backgroundColor: Colors.red, ), ); @@ -62,6 +66,7 @@ class _ConnectivityBannerState extends State { Widget build(BuildContext context) { return Consumer2( builder: (context, connectivityService, transactionsProvider, _) { + final l = AppLocalizations.of(context); final isOffline = connectivityService.isOffline; final hasPending = transactionsProvider.hasPendingTransactions; final pendingCount = transactionsProvider.pendingCount; @@ -87,8 +92,8 @@ class _ConnectivityBannerState extends State { Expanded( child: Text( isOffline - ? 'You are offline. Changes will sync when online.' - : '$pendingCount transaction${pendingCount == 1 ? '' : 's'} pending sync', + ? l.connectivityOffline + : l.connectivityPendingSync(pendingCount), style: TextStyle( color: isOffline ? Colors.orange.shade900 : Colors.blue.shade900, fontSize: 14, @@ -113,8 +118,8 @@ class _ConnectivityBannerState extends State { } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Unable to authenticate. Please try again.'), + SnackBar( + content: Text(l.connectivityAuthFailed), backgroundColor: Colors.red, ), ); @@ -145,7 +150,7 @@ class _ConnectivityBannerState extends State { valueColor: AlwaysStoppedAnimation(Colors.blue.shade900), ), ) - : const Text('Sync Now'), + : Text(l.connectivitySyncNow), ); }, ), diff --git a/mobile/lib/widgets/currency_filter.dart b/mobile/lib/widgets/currency_filter.dart index 0b82f379a..42eb77330 100644 --- a/mobile/lib/widgets/currency_filter.dart +++ b/mobile/lib/widgets/currency_filter.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import 'sure_chip.dart'; class CurrencyFilter extends StatelessWidget { @@ -39,6 +40,7 @@ class CurrencyFilter extends StatelessWidget { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); if (availableCurrencies.length <= 1) { return const SizedBox.shrink(); } @@ -61,7 +63,7 @@ class CurrencyFilter extends StatelessWidget { Padding( padding: const EdgeInsets.only(right: 8), child: SureChip( - label: 'All', + label: l.commonAll, selected: isAllSelected, onSelected: (_) => onSelectionChanged({}), ), diff --git a/mobile/lib/widgets/custom_proxy_headers_editor.dart b/mobile/lib/widgets/custom_proxy_headers_editor.dart index ce6c4aa03..8077b24f3 100644 --- a/mobile/lib/widgets/custom_proxy_headers_editor.dart +++ b/mobile/lib/widgets/custom_proxy_headers_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import '../models/custom_proxy_header.dart'; import 'sure_text_field.dart'; @@ -65,6 +66,7 @@ class _CustomProxyHeadersEditorState extends State { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -79,7 +81,7 @@ class _CustomProxyHeadersEditorState extends State { OutlinedButton.icon( onPressed: _addHeader, icon: const Icon(Icons.add), - label: const Text('Add header'), + label: Text(l.proxyHeadersAddHeader), ), ], ); @@ -99,6 +101,7 @@ class _HeaderRow extends StatelessWidget { @override Widget build(BuildContext context) { + final l = AppLocalizations.of(context); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -108,8 +111,8 @@ class _HeaderRow extends StatelessWidget { children: [ SureTextField( controller: draft.name, - label: 'Header name', - hint: 'X-Auth-Token', + label: l.proxyHeadersNameLabel, + hint: l.proxyHeadersNameHint, validator: (value) => CustomProxyHeader.validateName(value ?? ''), onChanged: (_) => onChanged(), @@ -117,7 +120,7 @@ class _HeaderRow extends StatelessWidget { const SizedBox(height: 12), SureTextField( controller: draft.value, - label: 'Header value', + label: l.proxyHeadersValueLabel, obscureText: true, validator: (value) => CustomProxyHeader.validateValue(value ?? ''), @@ -127,7 +130,7 @@ class _HeaderRow extends StatelessWidget { ), ), IconButton( - tooltip: 'Remove header', + tooltip: l.proxyHeadersRemove, icon: const Icon(Icons.delete_outline), onPressed: onRemove, ), diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index 988795c43..f435514b8 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; import 'money_text.dart'; import 'sure_icon.dart'; @@ -69,7 +70,7 @@ class NetWorthCard extends StatelessWidget { 'Outdated', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: colorScheme.secondary, - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), ), ), @@ -81,7 +82,7 @@ class NetWorthCard extends StatelessWidget { netWorthFormatted ?? '--', style: SureMoney.tabular( Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: isStale ? colorScheme.secondary : colorScheme.onSurface, @@ -212,7 +213,7 @@ class NetWorthCard extends StatelessWidget { Text( title, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: color, ), ), @@ -237,14 +238,14 @@ class NetWorthCard extends StatelessWidget { Text( entry.key, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: colorScheme.onSurfaceVariant, ), ), Text( formatAmount(entry.key, entry.value), style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, ), ), ], @@ -306,7 +307,7 @@ class _FilterButton extends StatelessWidget { child: Text( '--', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: colorScheme.onSurface, ), ), @@ -317,7 +318,7 @@ class _FilterButton extends StatelessWidget { formatAmount(sortedEntries.first.key, sortedEntries.first.value), style: SureMoney.tabular( Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: colorScheme.onSurface, ), ), @@ -339,7 +340,7 @@ class _FilterButton extends StatelessWidget { formatAmount(entry.key, entry.value), style: SureMoney.tabular( Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: SureTokens.weightMedium, color: colorScheme.onSurface, ), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 2cd91f8b8..2a93f2df6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -206,6 +206,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_markdown: dependency: "direct main" description: @@ -332,10 +337,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.20.2" json_annotation: dependency: transitive description: @@ -348,26 +353,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" lints: dependency: transitive description: @@ -428,26 +433,26 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" nested: dependency: transitive description: @@ -761,10 +766,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.4" typed_data: dependency: transitive description: @@ -881,10 +886,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" version: dependency: transitive description: @@ -942,5 +947,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.32.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 850d4ffac..6776b9556 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -10,12 +10,17 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.6 http: ^1.1.0 provider: ^6.1.1 shared_preferences: ^2.2.2 flutter_secure_storage: ^10.0.0 - intl: ^0.18.1 + # flutter_localizations pins intl per Flutter SDK (0.19.x on <3.32, 0.20.x on + # 3.32+). Per the Flutter i18n guide, use `any` so the SDK-constrained version + # is selected instead of pinning a version that conflicts with our Flutter floor. + intl: any sqflite: ^2.4.2 path: ^1.9.1 connectivity_plus: ^7.0.0 @@ -37,6 +42,7 @@ dev_dependencies: flutter: uses-material-design: true + generate: true assets: - assets/icon/ - assets/images/ diff --git a/mobile/test/theme/sure_tokens_test.dart b/mobile/test/theme/sure_tokens_test.dart index b030127cb..655052179 100644 --- a/mobile/test/theme/sure_tokens_test.dart +++ b/mobile/test/theme/sure_tokens_test.dart @@ -53,6 +53,17 @@ void main() { expect(SureTokens.radiusLg, _resolveDimension(tokens, 'border.radius.lg')); }); + test('generated font weights match canonical tiers', () { + expect( + SureTokens.weightMedium.value, + _resolveWeight(tokens, 'font.weight.medium'), + ); + expect( + SureTokens.weightSemibold.value, + _resolveWeight(tokens, 'font.weight.semibold'), + ); + }); + test('generated focus-ring and bg-inverse match canonical tokens', () { expect( SureTokens.light.focusRing.value, @@ -127,6 +138,10 @@ int _resolveColorValue( throw StateError('Unsupported color value: $value'); } +int _resolveWeight(Map tokens, String path) { + return _nodeAt(tokens, path)[r'$value'] as int; +} + double _resolveDimension(Map tokens, String path) { final value = _nodeAt(tokens, path)[r'$value'] as String; final match = RegExp(r'^([0-9]+(?:\.[0-9]+)?)px$').firstMatch(value); diff --git a/mobile/test/widgets/currency_filter_test.dart b/mobile/test/widgets/currency_filter_test.dart index cc62cf4df..12a77b758 100644 --- a/mobile/test/widgets/currency_filter_test.dart +++ b/mobile/test/widgets/currency_filter_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/l10n/app_localizations.dart'; import 'package:sure_mobile/theme/sure_theme.dart'; import 'package:sure_mobile/widgets/currency_filter.dart'; import 'package:sure_mobile/widgets/sure_chip.dart'; @@ -15,6 +16,8 @@ void main() { return tester.pumpWidget( MaterialApp( theme: SureTheme.light, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: CurrencyFilter( availableCurrencies: const {'USD', 'EUR', 'GBP'}, diff --git a/mobile/test/widgets/custom_proxy_headers_editor_test.dart b/mobile/test/widgets/custom_proxy_headers_editor_test.dart index 119457b7f..e5ecc58b3 100644 --- a/mobile/test/widgets/custom_proxy_headers_editor_test.dart +++ b/mobile/test/widgets/custom_proxy_headers_editor_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/l10n/app_localizations.dart'; import 'package:sure_mobile/models/custom_proxy_header.dart'; import 'package:sure_mobile/theme/sure_theme.dart'; import 'package:sure_mobile/widgets/custom_proxy_headers_editor.dart'; @@ -10,7 +11,12 @@ import 'package:sure_mobile/widgets/sure_text_field.dart'; void main() { Future pump(WidgetTester tester, Widget child) { return tester.pumpWidget( - MaterialApp(theme: SureTheme.light, home: Scaffold(body: child)), + MaterialApp( + theme: SureTheme.light, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), ); } diff --git a/mobile/tool/generate_sure_tokens.mjs b/mobile/tool/generate_sure_tokens.mjs index b92a1f9f1..fffd4247c 100644 --- a/mobile/tool/generate_sure_tokens.mjs +++ b/mobile/tool/generate_sure_tokens.mjs @@ -43,6 +43,14 @@ const RADIUS_TOKENS = [ ["radiusLg", "border.radius.lg"], ]; +// Named font-weight tiers, mirroring the web DS's Tailwind weight utilities +// (font-medium / font-semibold) so widgets reference a named token instead of a +// raw FontWeight. +const WEIGHT_TOKENS = [ + ["weightMedium", "font.weight.medium"], + ["weightSemibold", "font.weight.semibold"], +]; + // Single-layer elevation scale (shadow/*). Mode-aware: each token carries a // `sure.dark` extension, so light/dark emit different shadow colors. const SHADOW_TOKENS = [ @@ -117,6 +125,15 @@ function resolveDimension(tokens, path) { return Number(match[1]).toFixed(1); } +function resolveWeight(tokens, path) { + const node = nodeAt(tokens, path); + const value = Number(valueForMode(node, "light")); + if (!Number.isInteger(value) || value < 100 || value > 900 || value % 100 !== 0) { + throw new Error(`[mobile-tokens] ${path} must be a font weight (100-900 in steps of 100)`); + } + return `FontWeight.w${value}`; +} + // Parse a single-layer CSS box-shadow ("px px px px {color}") // into a Dart `BoxShadow(...)` literal. Offsets and spread may be negative // (e.g. -4px); blur must be >= 0. A strict number pattern rejects malformed @@ -170,6 +187,9 @@ function buildDart(tokens) { const radiusLines = RADIUS_TOKENS.map( ([name, path]) => ` static const double ${name} = ${resolveDimension(tokens, path)};`, ); + const weightLines = WEIGHT_TOKENS.map( + ([name, path]) => ` static const FontWeight ${name} = ${resolveWeight(tokens, path)};`, + ); return `// GENERATED CODE - DO NOT EDIT BY HAND. // Source: design/tokens/sure.tokens.json @@ -194,6 +214,8 @@ class SureTokens { ${radiusLines.join("\n")} +${weightLines.join("\n")} + ${emitPalette(tokens, "light")} ${emitPalette(tokens, "dark")} diff --git a/test/models/assistant/function/create_category_test.rb b/test/models/assistant/function/create_category_test.rb new file mode 100644 index 000000000..059ca05be --- /dev/null +++ b/test/models/assistant/function/create_category_test.rb @@ -0,0 +1,124 @@ +require "test_helper" + +class Assistant::Function::CreateCategoryTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @fn = Assistant::Function::CreateCategory.new(@user) + end + + test "to_definition returns correct schema" do + definition = @fn.to_definition + assert_equal "create_category", definition[:name] + assert_not_empty definition[:description] + assert_includes definition[:params_schema][:required], "name" + end + + test "creates a top-level category with all fields" do + assert_difference "@family.categories.count" do + result = @fn.call("name" => "Transport", "color" => "#4da568", "icon" => "bus") + + assert result[:success] + assert_equal "Transport", result[:category][:name] + assert_equal "#4da568", result[:category][:color] + assert_equal "bus", result[:category][:icon] + assert_nil result[:category][:parent_id] + end + end + + test "auto-suggests icon from name when omitted" do + result = @fn.call("name" => "Groceries", "color" => "#4da568") + + assert result[:success] + assert_equal Category.suggested_icon("Groceries"), result[:category][:icon] + end + + test "auto-assigns color from palette when omitted" do + result = @fn.call("name" => "Mystery") + + assert result[:success] + assert_includes Category::COLORS, result[:category][:color] + end + + test "creates a subcategory under an existing parent" do + parent = categories(:food_and_drink) + assert_difference "@family.categories.count" do + result = @fn.call("name" => "Fast Food", "parent_id" => parent.id) + + assert result[:success] + assert_equal parent.id, result[:category][:parent_id] + assert_match "Food & Drink > Fast Food", result[:category][:name_with_parent] + end + end + + test "subcategory inherits parent color" do + parent = categories(:food_and_drink) + result = @fn.call("name" => "Sushi", "parent_id" => parent.id) + + assert result[:success] + assert_equal parent.reload.color, result[:category][:color] + end + + test "soft error when name is whitespace only" do + result = @fn.call("name" => " ") + + assert_equal false, result[:success] + assert_equal "name_required", result[:error] + end + + test "empty string parent_id is treated as absent, creates top-level category" do + assert_difference "@family.categories.count" do + result = @fn.call("name" => "No Parent", "parent_id" => "") + + assert result[:success] + assert_nil result[:category][:parent_id] + end + end + + test "soft error when color format is invalid" do + result = @fn.call("name" => "Bad Color Cat", "color" => "not-a-color") + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "soft error when nesting a subcategory under another subcategory" do + sub = categories(:subcategory) + result = @fn.call("name" => "Too Deep", "parent_id" => sub.id) + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "soft error when name is blank" do + result = @fn.call("name" => "") + + assert_equal false, result[:success] + assert_equal "name_required", result[:error] + end + + test "soft error when parent_id does not exist" do + result = @fn.call("name" => "Orphan", "parent_id" => "00000000-0000-0000-0000-000000000000") + + assert_equal false, result[:success] + assert_equal "parent_not_found", result[:error] + end + + test "soft error on duplicate name within family" do + existing = categories(:food_and_drink) + result = @fn.call("name" => existing.name) + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "cannot use a parent from another family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_parent = other_family.categories.create!(name: "Foreign Parent", color: "#e99537", lucide_icon: "shapes") + + result = @fn.call("name" => "Child", "parent_id" => other_parent.id) + + assert_equal false, result[:success] + assert_equal "parent_not_found", result[:error] + end +end diff --git a/test/models/assistant/function/create_tag_test.rb b/test/models/assistant/function/create_tag_test.rb new file mode 100644 index 000000000..81b4badc5 --- /dev/null +++ b/test/models/assistant/function/create_tag_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +class Assistant::Function::CreateTagTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @fn = Assistant::Function::CreateTag.new(@user) + end + + test "to_definition returns correct schema" do + definition = @fn.to_definition + assert_equal "create_tag", definition[:name] + assert_not_empty definition[:description] + assert_includes definition[:params_schema][:required], "name" + end + + test "creates a tag with name and color" do + assert_difference "@family.tags.count" do + result = @fn.call("name" => "Vacation", "color" => "#4da568") + + assert result[:success] + assert_equal "Vacation", result[:tag][:name] + assert_equal "#4da568", result[:tag][:color] + assert result[:tag][:id].present? + end + end + + test "auto-assigns a color from palette when omitted" do + result = @fn.call("name" => "Auto Color") + + assert result[:success] + assert_includes Tag::COLORS, result[:tag][:color] + end + + test "soft error when name is blank" do + result = @fn.call("name" => "") + + assert_equal false, result[:success] + assert_equal "name_required", result[:error] + end + + test "soft error on duplicate name within family" do + existing = tags(:one) + result = @fn.call("name" => existing.name) + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "soft error when name is whitespace only" do + result = @fn.call("name" => " ") + + assert_equal false, result[:success] + assert_equal "name_required", result[:error] + end + + test "soft error when color format is invalid" do + result = @fn.call("name" => "Bad Color", "color" => "not-a-color") + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "scopes created tag to user's family" do + @fn.call("name" => "Scoped Tag") + tag = @family.tags.find_by(name: "Scoped Tag") + assert tag.present? + assert_equal @family.id, tag.family_id + end +end diff --git a/test/models/assistant/function/get_categories_test.rb b/test/models/assistant/function/get_categories_test.rb new file mode 100644 index 000000000..7d48462a9 --- /dev/null +++ b/test/models/assistant/function/get_categories_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Assistant::Function::GetCategoriesTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @fn = Assistant::Function::GetCategories.new(@user) + end + + test "to_definition returns correct name and description" do + definition = @fn.to_definition + assert_equal "get_categories", definition[:name] + assert_not_empty definition[:description] + end + + test "returns all family categories" do + result = @fn.call + + assert_kind_of Array, result[:categories] + assert_equal @family.categories.count, result[:total] + end + + test "each category includes required fields" do + result = @fn.call + result[:categories].each do |c| + assert c[:id].present? + assert c[:name].present? + assert c[:name_with_parent].present? + assert c[:color].present? + assert c[:icon].present? + assert c.key?(:parent_id) + assert c.key?(:is_subcategory) + end + end + + test "subcategory is_subcategory is true and has parent_id" do + result = @fn.call + sub = result[:categories].find { |c| c[:name] == categories(:subcategory).name } + + assert sub.present? + assert sub[:is_subcategory] + assert_equal categories(:food_and_drink).id, sub[:parent_id] + end + + test "top-level category has nil parent_id and is_subcategory false" do + result = @fn.call + top = result[:categories].find { |c| c[:name] == categories(:food_and_drink).name } + + assert top.present? + assert_not top[:is_subcategory] + assert_nil top[:parent_id] + end + + test "scopes to the user's family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_family.categories.create!(name: "Foreign Category", color: "#e99537", lucide_icon: "shapes") + + result = @fn.call + category_names = result[:categories].map { |c| c[:name] } + assert_not_includes category_names, "Foreign Category" + end +end diff --git a/test/models/assistant/function/get_tags_test.rb b/test/models/assistant/function/get_tags_test.rb new file mode 100644 index 000000000..b5c9ba9c7 --- /dev/null +++ b/test/models/assistant/function/get_tags_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Assistant::Function::GetTagsTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @fn = Assistant::Function::GetTags.new(@user) + end + + test "to_definition returns correct name and description" do + definition = @fn.to_definition + assert_equal "get_tags", definition[:name] + assert_not_empty definition[:description] + assert_equal "object", definition[:params_schema][:type] + end + + test "returns all family tags sorted alphabetically" do + result = @fn.call + + assert_kind_of Array, result[:tags] + assert_equal @family.tags.count, result[:total] + + names = result[:tags].map { |t| t[:name] } + assert_equal names.sort, names + end + + test "each tag includes id, name, and color" do + result = @fn.call + result[:tags].each do |t| + assert t[:id].present? + assert t[:name].present? + assert t[:color].present? + end + end + + test "scopes to the user's family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_family.tags.create!(name: "Foreign Tag") + + result = @fn.call + tag_names = result[:tags].map { |t| t[:name] } + assert_not_includes tag_names, "Foreign Tag" + end +end diff --git a/test/models/assistant/function/update_category_test.rb b/test/models/assistant/function/update_category_test.rb new file mode 100644 index 000000000..70e83a485 --- /dev/null +++ b/test/models/assistant/function/update_category_test.rb @@ -0,0 +1,104 @@ +require "test_helper" + +class Assistant::Function::UpdateCategoryTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @category = categories(:food_and_drink) + @fn = Assistant::Function::UpdateCategory.new(@user) + end + + test "to_definition returns correct schema" do + definition = @fn.to_definition + assert_equal "update_category", definition[:name] + assert_not_empty definition[:description] + assert_includes definition[:params_schema][:required], "id" + end + + test "updates category name" do + result = @fn.call("id" => @category.id, "name" => "Food & Beverages") + + assert result[:success] + assert_equal "Food & Beverages", result[:category][:name] + assert_equal "Food & Beverages", @category.reload.name + end + + test "updates category color" do + result = @fn.call("id" => @category.id, "color" => "#6471eb") + + assert result[:success] + assert_equal "#6471eb", result[:category][:color] + assert_equal "#6471eb", @category.reload.color + end + + test "updates category icon" do + result = @fn.call("id" => @category.id, "icon" => "pizza") + + assert result[:success] + assert_equal "pizza", result[:category][:icon] + assert_equal "pizza", @category.reload.lucide_icon + end + + test "updates multiple fields at once" do + result = @fn.call("id" => @category.id, "name" => "Dining", "color" => "#db5a54", "icon" => "utensils") + + assert result[:success] + @category.reload + assert_equal "Dining", @category.name + assert_equal "#db5a54", @category.color + assert_equal "utensils", @category.lucide_icon + end + + test "result includes name_with_parent for subcategory" do + sub = categories(:subcategory) + result = @fn.call("id" => sub.id, "icon" => "coffee") + + assert result[:success] + assert_match(/#{sub.parent.name}/, result[:category][:name_with_parent]) + end + + test "soft error when id is nil" do + result = @fn.call("name" => "X") + + assert_equal false, result[:success] + assert_equal "not_found", result[:error] + end + + test "whitespace-only name is treated as absent, returns no_changes" do + result = @fn.call("id" => @category.id, "name" => " ") + + assert_equal false, result[:success] + assert_equal "no_changes", result[:error] + end + + test "soft error when color format is invalid" do + result = @fn.call("id" => @category.id, "color" => "not-a-color") + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "soft error when category not found" do + result = @fn.call("id" => "00000000-0000-0000-0000-000000000000", "name" => "X") + + assert_equal false, result[:success] + assert_equal "not_found", result[:error] + end + + test "soft error when no changes provided" do + result = @fn.call("id" => @category.id) + + assert_equal false, result[:success] + assert_equal "no_changes", result[:error] + end + + test "cannot update a category from another family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_cat = other_family.categories.create!(name: "Foreign", color: "#e99537", lucide_icon: "shapes") + + result = @fn.call("id" => other_cat.id, "name" => "Hijacked") + + assert_equal false, result[:success] + assert_equal "not_found", result[:error] + end +end diff --git a/test/models/assistant/function/update_tag_test.rb b/test/models/assistant/function/update_tag_test.rb new file mode 100644 index 000000000..e86cd6445 --- /dev/null +++ b/test/models/assistant/function/update_tag_test.rb @@ -0,0 +1,91 @@ +require "test_helper" + +class Assistant::Function::UpdateTagTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @tag = tags(:one) + @fn = Assistant::Function::UpdateTag.new(@user) + end + + test "to_definition returns correct schema" do + definition = @fn.to_definition + assert_equal "update_tag", definition[:name] + assert_not_empty definition[:description] + assert_includes definition[:params_schema][:required], "name" + end + + test "params_schema enumerates family tag names" do + schema = @fn.params_schema + assert_includes schema[:properties][:name][:enum], @tag.name + end + + test "updates tag name" do + result = @fn.call("name" => @tag.name, "new_name" => "Updated Name") + + assert result[:success] + assert_equal "Updated Name", result[:tag][:name] + assert_equal "Updated Name", @tag.reload.name + end + + test "updates tag color" do + result = @fn.call("name" => @tag.name, "color" => "#6471eb") + + assert result[:success] + assert_equal "#6471eb", result[:tag][:color] + assert_equal "#6471eb", @tag.reload.color + end + + test "updates both name and color at once" do + result = @fn.call("name" => @tag.name, "new_name" => "Both Updated", "color" => "#db5a54") + + assert result[:success] + assert_equal "Both Updated", @tag.reload.name + assert_equal "#db5a54", @tag.reload.color + end + + test "soft error when tag not found" do + result = @fn.call("name" => "Nonexistent Tag", "new_name" => "X") + + assert_equal false, result[:success] + assert_equal "not_found", result[:error] + end + + test "soft error when no changes provided" do + result = @fn.call("name" => @tag.name) + + assert_equal false, result[:success] + assert_equal "no_changes", result[:error] + end + + test "soft error when lookup name is empty string" do + result = @fn.call("name" => "", "new_name" => "X") + + assert_equal false, result[:success] + assert_equal "not_found", result[:error] + end + + test "empty string new_name is treated as absent, returns no_changes" do + result = @fn.call("name" => @tag.name, "new_name" => "") + + assert_equal false, result[:success] + assert_equal "no_changes", result[:error] + end + + test "soft error when color format is invalid" do + result = @fn.call("name" => @tag.name, "color" => "not-a-color") + + assert_equal false, result[:success] + assert_equal "validation_failed", result[:error] + end + + test "cannot update a tag from another family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_tag = other_family.tags.create!(name: "Other Tag") + + result = @fn.call("name" => other_tag.name, "new_name" => "Hijacked") + + assert_equal false, result[:success] + assert_equal "not_found", result[:error] + end +end diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index b7595f238..87c544de4 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -104,6 +104,40 @@ class AccountsTest < ApplicationSystemTestCase end end + # The account page issues a Turbo morph refresh shortly after it loads + # (`turbo_refreshes_with method: :morph` reacting to a family-stream + # broadcast). Opening the menu while that refresh is in flight can detach the + # node mid-click ("Node with given id does not belong to the document") or + # wipe the just-opened edit form — neither of which Capybara auto-retries. + # Retry until the edit form is present so the test is deterministic instead + # of racing the broadcast (mirrors PropertyTest#open_account_edit_dialog). + def open_account_edit_dialog + 3.times do + # A prior (slow) attempt may have already opened the edit form. + return if has_field?("Account name", wait: 0) + + begin + within_testid("account-menu") do + # Open the menu only when it's closed. DS::Menu's trigger toggles + # (menu_controller#toggle), so blindly re-clicking an already-open + # menu would close it and hide "Edit", turning a slow-but-successful + # modal load into a fresh flake. + unless has_selector?("[role='menu']", visible: true, wait: 0) + find("button").click + end + click_on "Edit" + end + rescue Selenium::WebDriver::Error::WebDriverError => e + raise unless e.message.match?( + /does not belong to the document|stale element reference/i, + ) + next + end + return if has_field?("Account name", wait: 2) + end + assert_field "Account name" + end + def assert_account_created(accountable_type, &block) click_link Accountable.from_type(accountable_type).singular_display_name click_link "Enter account balance" if accountable_type.in?(%w[Depository Investment Crypto Loan CreditCard]) @@ -140,10 +174,7 @@ class AccountsTest < ApplicationSystemTestCase visit account_url(created_account) - within_testid("account-menu") do - find("button").click - click_on "Edit" - end + open_account_edit_dialog updated_institution_name = "[system test] Updated Institution" updated_institution_domain = "updated.example.com" diff --git a/workers/preview/package-lock.json b/workers/preview/package-lock.json index 2cfb46528..ffa57e247 100644 --- a/workers/preview/package-lock.json +++ b/workers/preview/package-lock.json @@ -11,7 +11,7 @@ "@cloudflare/containers": "^0.3.3", "@cloudflare/workers-types": "^4.20250124.0", "typescript": "^5.0.0", - "wrangler": "^4.0.0" + "wrangler": "^4.103.0" } }, "node_modules/@cloudflare/containers": { @@ -48,9 +48,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260507.1.tgz", - "integrity": "sha512-S85aMwcaPJUjKWDiG6iMMnioKWtPLACa6m0j/EhHR1GYfVpnxb974cBc6d25L+sf7jHWHJI2u5hGp0UTJ7MtXQ==", + "version": "1.20260617.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260617.1.tgz", + "integrity": "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==", "cpu": [ "x64" ], @@ -65,9 +65,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260507.1.tgz", - "integrity": "sha512-GMEBu8Zp9Q97HLnf7bWJN4KjWpN5MxpeqdvHjBGWNl8UYprJI0k+Jkp89+Wh5S8vIon+HoVbDfOzPa7VwgL6Eg==", + "version": "1.20260617.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260617.1.tgz", + "integrity": "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==", "cpu": [ "arm64" ], @@ -82,9 +82,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260507.1.tgz", - "integrity": "sha512-QlrKEBdgA3uVc0Ok0Q3+0/CW0CTjgj5ySir1i1YY5FXVv0X6GpwtnB5umjunjF2MFprss+L+iFGZzxcSvMC1nA==", + "version": "1.20260617.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260617.1.tgz", + "integrity": "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==", "cpu": [ "x64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260507.1.tgz", - "integrity": "sha512-eGbbupEtK2nh9V9Dhcx3vv3GTKeXqSVNgAEYVCCN0NGS9tl9HbMoHRX/4JL181FKXROMigWBCQVL//qPhsAzBQ==", + "version": "1.20260617.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260617.1.tgz", + "integrity": "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==", "cpu": [ "arm64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260507.1.tgz", - "integrity": "sha512-dmClJ/E0BAcuDetQIZFqbeAXejWrG5pysGRMQ6T83Y0IW/7IAamY2zFEkAJ10I5xwZsdHuYsZtzlOxpEXpJs7A==", + "version": "1.20260617.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260617.1.tgz", + "integrity": "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==", "cpu": [ "x64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260511.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260511.1.tgz", - "integrity": "sha512-FA+si7cOq9i/gtCHhIc0XJL0l1F/ApF+m00752Aj7WZFJrj3ZulT2T8/+rT3BabMT0QEnqFEGIqCgrmqhgEfMg==", + "version": "4.20260619.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260619.1.tgz", + "integrity": "sha512-bprsNzG0DapQPFwU2AvQlQ6FUO7Y4bKWaPBzLNI7nBSqlHsK0P62xx2NaNT6i/htzAgQspKZm1D32y0RxofrRQ==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -153,9 +153,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -164,9 +164,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -181,9 +181,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -198,9 +198,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -215,9 +215,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -232,9 +232,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -249,9 +249,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -266,9 +266,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -283,9 +283,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -300,9 +300,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -317,9 +317,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -334,9 +334,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -351,9 +351,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -368,9 +368,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -385,9 +385,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -402,9 +402,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -419,9 +419,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -436,9 +436,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -453,9 +453,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -470,9 +470,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -487,9 +487,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -504,9 +504,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -521,9 +521,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -538,9 +538,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -555,9 +555,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -572,9 +572,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -589,9 +589,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1214,9 +1214,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", "dev": true, "license": "CC0-1.0" }, @@ -1262,9 +1262,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1275,32 +1275,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/fsevents": { @@ -1329,17 +1329,17 @@ } }, "node_modules/miniflare": { - "version": "4.20260507.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260507.1.tgz", - "integrity": "sha512-PSXBiLExTdZ4UGO/raKCHQauUpYL7F880ZRB7j0+78Rv8h7TsdN2E/iEDK9sK2Y+SPQ5wJSeAa+rDeVKoZZoEw==", + "version": "4.20260617.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260617.1.tgz", + "integrity": "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260507.1", - "ws": "8.18.0", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260617.1", + "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { @@ -1364,9 +1364,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -1457,9 +1457,9 @@ } }, "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -1477,9 +1477,9 @@ } }, "node_modules/workerd": { - "version": "1.20260507.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260507.1.tgz", - "integrity": "sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==", + "version": "1.20260617.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260617.1.tgz", + "integrity": "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1490,30 +1490,31 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260507.1", - "@cloudflare/workerd-darwin-arm64": "1.20260507.1", - "@cloudflare/workerd-linux-64": "1.20260507.1", - "@cloudflare/workerd-linux-arm64": "1.20260507.1", - "@cloudflare/workerd-windows-64": "1.20260507.1" + "@cloudflare/workerd-darwin-64": "1.20260617.1", + "@cloudflare/workerd-darwin-arm64": "1.20260617.1", + "@cloudflare/workerd-linux-64": "1.20260617.1", + "@cloudflare/workerd-linux-arm64": "1.20260617.1", + "@cloudflare/workerd-windows-64": "1.20260617.1" } }, "node_modules/wrangler": { - "version": "4.90.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.90.0.tgz", - "integrity": "sha512-bmNIykl59TfCUn5xQgU7IWylSsPx3LQaPLMSAq2VQHt89CBrcj9qXQ0eYfjBCWA5XTBVgten391evt7xxtXwcA==", + "version": "4.103.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.103.0.tgz", + "integrity": "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260507.1", + "esbuild": "0.28.1", + "miniflare": "4.20260617.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260507.1" + "workerd": "1.20260617.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, @@ -1521,10 +1522,10 @@ "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260507.1" + "@cloudflare/workers-types": "^4.20260617.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -1533,9 +1534,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { diff --git a/workers/preview/package.json b/workers/preview/package.json index 5823dd4ba..8ade8d8c9 100644 --- a/workers/preview/package.json +++ b/workers/preview/package.json @@ -10,6 +10,6 @@ "@cloudflare/containers": "^0.3.3", "@cloudflare/workers-types": "^4.20250124.0", "typescript": "^5.0.0", - "wrangler": "^4.0.0" + "wrangler": "^4.103.0" } }