Merge branch 'we-promise:main' into Transfer-charges

This commit is contained in:
Shibu M
2026-06-21 21:43:01 +05:30
committed by GitHub
61 changed files with 5843 additions and 813 deletions

View File

@@ -206,7 +206,7 @@ jobs:
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<string>app-store-connect</string>
<key>teamID</key>
<string>${IOS_TEAM_ID}</string>
<key>signingStyle</key>
@@ -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"

View File

@@ -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

View File

@@ -1 +1 @@
0.7.2-alpha.7
0.7.2-alpha.9

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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" }
}
},

5
mobile/l10n.yaml Normal file
View File

@@ -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

View File

@@ -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),
];
}

972
mobile/lib/l10n/app_en.arb Normal file
View File

@@ -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." }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<ThemeProvider>(
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,

View File

@@ -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<BackendConfigScreen> {
Future<void> _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<BackendConfigScreen> {
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<BackendConfigScreen> {
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<BackendConfigScreen> {
Future<void> _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<BackendConfigScreen> {
} 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<BackendConfigScreen> {
}
}
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<BackendConfigScreen> {
// 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<BackendConfigScreen> {
@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<BackendConfigScreen> {
),
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<BackendConfigScreen> {
),
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<BackendConfigScreen> {
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<BackendConfigScreen> {
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<BackendConfigScreen> {
),
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<BackendConfigScreen> {
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<BackendConfigScreen> {
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,
),

View File

@@ -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<BiometricLockScreen> {
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<BiometricLockScreen> {
),
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<BiometricLockScreen> {
const SizedBox(height: 16),
TextButton(
onPressed: widget.onLogout,
child: const Text('Log out'),
child: Text(l.biometricLogOut),
),
],
],

View File

@@ -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<CalendarScreen> {
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<CalendarScreen> {
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<CalendarScreen> {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
child: Text(l.commonClose),
),
],
);
@@ -303,12 +307,13 @@ class _CalendarScreenState extends State<CalendarScreen> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final accountsProvider = context.watch<AccountsProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Account Calendar'),
title: Text(l.calendarTitle),
),
body: Column(
children: [
@@ -328,23 +333,23 @@ class _CalendarScreenState extends State<CalendarScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Account Type',
l.calendarAccountTypeSection,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
segments: [
ButtonSegment<String>(
value: 'asset',
label: Text('Assets'),
icon: Icon(Icons.account_balance_wallet),
label: Text(l.calendarSegmentAssets),
icon: const Icon(Icons.account_balance_wallet),
),
ButtonSegment<String>(
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<CalendarScreen> {
child: DropdownButtonFormField<Account>(
value: _selectedAccount,
decoration: InputDecoration(
labelText: 'Select Account',
labelText: l.calendarSelectAccount,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
@@ -434,7 +439,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
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<CalendarScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Monthly Change',
l.calendarMonthlyChange,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
@@ -496,6 +503,17 @@ class _CalendarScreenState extends State<CalendarScreen> {
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<CalendarScreen> {
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(

View File

@@ -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<ChatConversationScreen> {
if (content.isEmpty) return;
setState(() => _isSendInFlight = true);
final l = AppLocalizations.of(context);
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
@@ -160,7 +163,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
_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<ChatConversationScreen> {
final newTitle = await showDialog<String>(
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<ChatConversationScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Consumer<ChatProvider>(
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<ChatConversationScreen> {
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _loadChat(forceRefresh: true),
tooltip: 'Refresh',
tooltip: l.chatConversationRefreshTooltip,
),
],
),
@@ -311,7 +316,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
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<ChatConversationScreen> {
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<ChatConversationScreen> {
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(

View File

@@ -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<ChatListScreen> {
}
Future<void> _deleteSelectedChats() async {
final l = AppLocalizations.of(context);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
final confirmed = await showDialog<bool>(
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<ChatListScreen> {
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<ChatListScreen> {
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<ChatListScreen> {
),
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<ChatListScreen> {
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<ChatListScreen> {
),
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<ChatListScreen> {
confirmDismiss: (direction) async {
return await showDialog<bool>(
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<ChatListScreen> {
),
floatingActionButton: FloatingActionButton(
onPressed: _openNewChat,
tooltip: 'New Chat',
tooltip: l.chatListNewChat,
child: const Icon(Icons.add),
),
);

View File

@@ -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<DashboardScreen> {
Future<void> _performManualSync() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
final l = AppLocalizations.of(context);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
@@ -140,10 +143,10 @@ class DashboardScreenState extends State<DashboardScreen> {
// 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<DashboardScreen> {
valueColor: AlwaysStoppedAnimation<Color>(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<DashboardScreen> {
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<DashboardScreen> {
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<DashboardScreen> {
}
Future<void> _handleAccountTap(Account account) async {
final l = AppLocalizations.of(context);
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
@@ -292,10 +296,10 @@ class DashboardScreenState extends State<DashboardScreen> {
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<DashboardScreen> {
valueColor: AlwaysStoppedAnimation<Color>(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<DashboardScreen> {
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<DashboardScreen> {
@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<DashboardScreen> {
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<DashboardScreen> {
),
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<DashboardScreen> {
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<DashboardScreen> {
),
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<DashboardScreen> {
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<DashboardScreen> {
List<Widget> _buildFilteredAccountsSection(AccountsProvider accountsProvider) {
final filteredAccounts = _getFilteredAccounts(accountsProvider);
final l = AppLocalizations.of(context);
if (filteredAccounts.isEmpty) {
return [
@@ -542,7 +548,7 @@ class DashboardScreenState extends State<DashboardScreen> {
),
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,
),
),

View File

@@ -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<LogViewerScreen> {
@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<String>(
initialValue: _selectedLevel,
@@ -86,11 +88,11 @@ class _LogViewerScreenState extends State<LogViewerScreen> {
});
},
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<LogViewerScreen> {
_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<LogViewerScreen> {
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<LogViewerScreen> {
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<LogViewerScreen> {
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<LogViewerScreen> {
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),
),
],
),

View File

@@ -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<LoginScreen> {
}
Future<void> _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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
: Text(dl.loginApiKeySignIn),
),
],
);
@@ -212,6 +215,7 @@ class _LoginScreenState extends State<LoginScreen> {
@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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
? 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<LoginScreen> {
),
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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
Consumer<AuthProvider>(
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<LoginScreen> {
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<LoginScreen> {
Consumer<AuthProvider>(
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<LoginScreen> {
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<LoginScreen> {
Consumer<AuthProvider>(
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<LoginScreen> {
top: 8,
child: IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Backend Settings',
tooltip: l.loginBackendSettingsTooltip,
onPressed: () => widget._openSettings(context),
),
),

View File

@@ -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<MainNavigationScreen> {
await _handleDestinationSelected(settingsIndex, authProvider, introLayout);
}
List<NavigationDestination> _buildDestinations(bool introLayout) {
List<NavigationDestination> _buildDestinations(bool introLayout, AppLocalizations l) {
final destinations = <NavigationDestination>[];
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<MainNavigationScreen> {
}
Future<bool> _showEnableAiPrompt() async {
final l = AppLocalizations.of(context);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final shouldEnable = await showDialog<bool>(
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<MainNavigationScreen> {
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<MainNavigationScreen> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Consumer<AuthProvider>(
builder: (context, authProvider, _) {
final introLayout = authProvider.isIntroLayout;
@@ -218,7 +224,7 @@ class _MainNavigationScreenState extends State<MainNavigationScreen> {
introLayout,
() => _handleDestinationSelected(chatIndex, authProvider, introLayout),
);
final destinations = _buildDestinations(introLayout);
final destinations = _buildDestinations(introLayout, l);
final bottomNavIndex = _resolveBottomSelectedIndex(destinations);
if (_currentIndex >= screens.length) {

View File

@@ -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(

View File

@@ -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<RecentTransactionsScreen> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final transactionsProvider = context.watch<TransactionsProvider>();
@@ -93,12 +96,12 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Recent Transactions'),
title: Text(l.recentTransactionsTitle),
actions: [
PopupMenuButton<int>(
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<RecentTransactionsScreen> {
else
const SizedBox(width: 20),
const SizedBox(width: 8),
Text('Show $limit'),
Text(l.recentTransactionsShowN(limit)),
],
),
);
@@ -148,6 +151,7 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
}
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<RecentTransactionsScreen> {
),
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<RecentTransactionsScreen> {
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<RecentTransactionsScreen> {
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<RecentTransactionsScreen> {
),
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<RecentTransactionsScreen> {
: '$sign${transaction.currency} ${_formatAmount(amount.abs())}',
trend: moneyTrend,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontWeight: SureTokens.weightMedium,
fontSize: 16,
),
),

View File

@@ -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<SettingsScreen> {
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<SettingsScreen> {
final launch = await showDialog<bool>(
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<SettingsScreen> {
: 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<SettingsScreen> {
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<SettingsScreen> {
}
Future<void> _handleClearLocalData(BuildContext context) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
}
Future<void> _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<void> _handleResetAccount(BuildContext context) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<SettingsScreen> {
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<SettingsScreen> {
} 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<SettingsScreen> {
}
Future<void> _handleDeleteAccount(BuildContext context) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<SettingsScreen> {
} 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<SettingsScreen> {
Future<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<SettingsScreen> {
}
Future<void> _showCustomHeadersDialog() async {
final l = AppLocalizations.of(context);
final formKey = GlobalKey<FormState>();
final latestHeaders =
await CustomProxyHeadersService.instance.loadHeaders();
@@ -446,8 +453,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final saved = await showDialog<bool>(
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<SettingsScreen> {
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
);
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<SettingsScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final authProvider = Provider.of<AuthProvider>(context);
final l = AppLocalizations.of(context);
return Scaffold(
body: ListView(
@@ -549,7 +558,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
// 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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
builder: (context, themeProvider, _) {
return ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'),
title: Text(l.settingsThemeLabel),
trailing: SegmentedButton<ThemeMode>(
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
// 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<SettingsScreen> {
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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,

View File

@@ -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<SsoOnboardingScreen> {
.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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
),
),
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<SsoOnboardingScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Link Account'),
: Text(AppLocalizations.of(context).ssoOnboardingLinkButton),
),
],
),
@@ -282,8 +286,10 @@ class _SsoOnboardingScreenState extends State<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
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<SsoOnboardingScreen> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(hasPendingInvitation
? 'Accept Invitation'
: 'Create Account'),
? AppLocalizations.of(context).ssoOnboardingAcceptInvitation
: AppLocalizations.of(context).ssoOnboardingCreateButton),
),
],
),

View File

@@ -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<TransactionEditScreen> {
}
Future<void> _save() async {
final l = AppLocalizations.of(context);
if (!_formKey.currentState!.validate() || widget.transaction.id == null) {
return;
}
@@ -96,8 +98,8 @@ class _TransactionEditScreenState extends State<TransactionEditScreen> {
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<TransactionEditScreen> {
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<TransactionEditScreen> {
}
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<TransactionEditScreen> {
List<DropdownMenuItem<String?>> _categoryItems(
List<models.Category> categories,
) {
final l = AppLocalizations.of(context);
final items = <DropdownMenuItem<String?>>[];
if (_selectedCategoryId == null) {
items.add(
const DropdownMenuItem<String?>(
DropdownMenuItem<String?>(
value: null,
child: Text('No category'),
child: Text(l.transactionEditNoCategory),
),
);
}
@@ -209,7 +214,7 @@ class _TransactionEditScreenState extends State<TransactionEditScreen> {
items.add(
DropdownMenuItem<String?>(
value: _selectedCategoryId,
child: Text(widget.transaction.categoryName ?? 'Current category'),
child: Text(widget.transaction.categoryName ?? l.transactionEditCurrentCategory),
),
);
}
@@ -227,12 +232,13 @@ class _TransactionEditScreenState extends State<TransactionEditScreen> {
}
List<DropdownMenuItem<String?>> _merchantItems(List<Merchant> merchants) {
final l = AppLocalizations.of(context);
final items = <DropdownMenuItem<String?>>[];
if (_selectedMerchantId == null) {
items.add(
const DropdownMenuItem<String?>(
DropdownMenuItem<String?>(
value: null,
child: Text('No merchant'),
child: Text(l.transactionEditNoMerchant),
),
);
}
@@ -243,7 +249,7 @@ class _TransactionEditScreenState extends State<TransactionEditScreen> {
items.add(
DropdownMenuItem<String?>(
value: _selectedMerchantId,
child: Text(widget.transaction.merchantName ?? 'Current merchant'),
child: Text(widget.transaction.merchantName ?? l.transactionEditCurrentMerchant),
),
);
}
@@ -261,8 +267,9 @@ class _TransactionEditScreenState extends State<TransactionEditScreen> {
}
Widget _buildTags(List<TransactionTag> 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<TransactionEditScreen> {
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<TransactionEditScreen> {
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<TransactionEditScreen> {
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<TransactionEditScreen> {
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<TransactionEditScreen> {
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<TransactionEditScreen> {
builder: (context, categoriesProvider, _) {
return DropdownButtonFormField<String?>(
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<TransactionEditScreen> {
builder: (context, merchantsProvider, _) {
return DropdownButtonFormField<String?>(
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<TransactionEditScreen> {
},
),
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<TagsProvider>(
builder: (context, tagsProvider, _) =>
@@ -409,7 +418,7 @@ class _TransactionEditScreenState extends State<TransactionEditScreen> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(_isSaving ? 'Saving...' : 'Save Changes'),
label: Text(_isSaving ? l.transactionEditSaving : l.commonSave),
),
],
),

View File

@@ -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<TransactionFormScreen> {
}
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<TransactionFormScreen> {
_log.info('TransactionForm', 'Starting transaction creation...');
final l = AppLocalizations.of(context);
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(
@@ -128,8 +132,8 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
);
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<TransactionFormScreen> {
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<TransactionFormScreen> {
} 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<TransactionFormScreen> {
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<TransactionFormScreen> {
@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<TransactionFormScreen> {
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<TransactionFormScreen> {
// 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<TransactionFormScreen> {
_nature = value;
});
},
segments: const [
segments: [
SureSegment<String>(
value: 'expense',
label: 'Expense',
icon: Icon(Icons.arrow_downward),
label: l.transactionFormTypeExpense,
icon: const Icon(Icons.arrow_downward),
),
SureSegment<String>(
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<TransactionFormScreen> {
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<TransactionFormScreen> {
? 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<TransactionFormScreen> {
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<TransactionFormScreen> {
// 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<TransactionFormScreen> {
Consumer<CategoriesProvider>(
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<TransactionFormScreen> {
return DropdownButtonFormField<String?>(
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<String?>(
DropdownMenuItem<String?>(
value: null,
child: Text('No category'),
child: Text(l.transactionFormNoCategory),
),
...categories.map((category) {
return DropdownMenuItem<String?>(
@@ -487,7 +498,7 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
strokeWidth: 2,
),
)
: const Text('Create Transaction'),
: Text(l.transactionFormCreateButton),
),
],
),

View File

@@ -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<TransactionsListScreen> {
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<TransactionsListScreen> {
Future<void> _deleteSelectedTransactions() async {
if (_selectedTransactions.isEmpty) return;
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<AuthProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
@@ -187,7 +194,7 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
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<TransactionsListScreen> {
});
} 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<TransactionsListScreen> {
}
Future<void> _undoTransaction(OfflineTransaction transaction) async {
final l = AppLocalizations.of(context);
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final isPending = transaction.syncStatus == SyncStatus.pending;
final confirmed = await showDialog<bool>(
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<TransactionsListScreen> {
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<TransactionsListScreen> {
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<AuthProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
final confirmed = await showDialog<bool>(
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<TransactionsListScreen> {
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<TransactionsListScreen> {
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<TransactionsListScreen> {
@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<TransactionsListScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadTransactions,
child: const Text('Retry'),
child: Text(l.transactionsListRetry),
),
],
),
@@ -420,7 +441,7 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
),
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<TransactionsListScreen> {
),
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<TransactionsListScreen> {
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<TransactionsListScreen> {
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<TransactionsListScreen> {
_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<TransactionsListScreen> {
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<TransactionsListScreen> {
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<TransactionsListScreen> {
style: TextStyle(
color: Colors.blue,
fontSize: 11,
fontWeight: FontWeight.w600,
fontWeight: SureTokens.weightSemibold,
),
),
),

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<AccountDetailHeader> {
widget.accountDetailService == null;
late Account _account;
bool _isLoading = false;
String? _error;
bool _detailsUnavailable = false;
List<AccountBalance> _balances = [];
List<AccountHolding> _holdings = [];
bool _disposed = false;
@@ -46,6 +47,9 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
Future<void> _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<AuthProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (_disposed) return;
@@ -60,7 +64,7 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
if (mounted) {
setState(() {
_isLoading = true;
_error = null;
_detailsUnavailable = false;
});
}
@@ -117,7 +121,7 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
}
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<AccountDetailHeader> {
@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<AccountDetailHeader> {
else
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh account details',
tooltip: l.accountDetailRefreshTooltip,
onPressed: _loadDetails,
),
],
@@ -222,7 +227,7 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
),
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<AccountDetailHeader> {
children: [
Expanded(
child: Text(
'Recent balance history',
l.accountDetailRecentBalanceHistory,
style: Theme.of(context).textTheme.titleSmall,
),
),
@@ -264,7 +269,7 @@ class _AccountDetailHeaderState extends State<AccountDetailHeader> {
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<AccountDetailHeader> {
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<AccountDetailHeader> {
),
),
],
if (_error != null) ...[
if (_detailsUnavailable) ...[
const SizedBox(height: 8),
Text(
_error!,
l.accountDetailUnavailable,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),

View File

@@ -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({});

View File

@@ -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<ConnectivityBanner> {
bool _isSyncing = false;
Future<void> _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<ConnectivityBanner> {
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<ConnectivityBanner> {
Widget build(BuildContext context) {
return Consumer2<ConnectivityService, TransactionsProvider>(
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<ConnectivityBanner> {
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<ConnectivityBanner> {
} 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<ConnectivityBanner> {
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue.shade900),
),
)
: const Text('Sync Now'),
: Text(l.connectivitySyncNow),
);
},
),

View File

@@ -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({}),
),

View File

@@ -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<CustomProxyHeadersEditor> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -79,7 +81,7 @@ class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
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,
),

View File

@@ -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,
),
),

View File

@@ -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"

View File

@@ -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/

View File

@@ -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<String, dynamic> tokens, String path) {
return _nodeAt(tokens, path)[r'$value'] as int;
}
double _resolveDimension(Map<String, dynamic> tokens, String path) {
final value = _nodeAt(tokens, path)[r'$value'] as String;
final match = RegExp(r'^([0-9]+(?:\.[0-9]+)?)px$').firstMatch(value);

View File

@@ -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'},

View File

@@ -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<void> 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),
),
);
}

View File

@@ -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 ("<x>px <y>px <blur>px <spread>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")}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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"
}
}