mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 16:55:25 +00:00
Merge branch 'we-promise:main' into Transfer-charges
This commit is contained in:
3
.github/workflows/ios-testflight.yml
vendored
3
.github/workflows/ios-testflight.yml
vendored
@@ -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"
|
||||
|
||||
26
.github/workflows/publish.yml
vendored
26
.github/workflows/publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.7.2-alpha.7
|
||||
0.7.2-alpha.9
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -10,7 +10,7 @@ gem "pg", "~> 1.5"
|
||||
gem "redis", "~> 5.4"
|
||||
|
||||
# Deployment
|
||||
gem "puma", ">= 5.0"
|
||||
gem "puma", ">= 7.2.1"
|
||||
gem "bootsnap", require: false
|
||||
|
||||
# Assets
|
||||
|
||||
28
Gemfile.lock
28
Gemfile.lock
@@ -147,7 +147,7 @@ GEM
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
concurrent-ruby (1.3.7)
|
||||
connection_pool (2.5.5)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
@@ -215,7 +215,7 @@ GEM
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -223,7 +223,7 @@ GEM
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.3.2)
|
||||
faraday (~> 2.0)
|
||||
@@ -336,7 +336,7 @@ GEM
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.2)
|
||||
json (2.19.9)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
@@ -429,21 +429,21 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3-aarch64-linux-gnu)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-aarch64-linux-musl)
|
||||
nokogiri (1.19.4-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-arm-linux-gnu)
|
||||
nokogiri (1.19.4-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-arm-linux-musl)
|
||||
nokogiri (1.19.4-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-musl)
|
||||
nokogiri (1.19.4-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
oauth2 (2.0.18)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
@@ -531,7 +531,7 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (6.6.0)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -918,7 +918,7 @@ DEPENDENCIES
|
||||
plaid
|
||||
posthog-ruby
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
puma (>= 7.2.1)
|
||||
pundit
|
||||
rack-attack (~> 6.6)
|
||||
rack-cors
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
81
app/models/assistant/function/create_category.rb
Normal file
81
app/models/assistant/function/create_category.rb
Normal 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
|
||||
56
app/models/assistant/function/create_tag.rb
Normal file
56
app/models/assistant/function/create_tag.rb
Normal 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
|
||||
36
app/models/assistant/function/get_categories.rb
Normal file
36
app/models/assistant/function/get_categories.rb
Normal 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
|
||||
25
app/models/assistant/function/get_tags.rb
Normal file
25
app/models/assistant/function/get_tags.rb
Normal 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
|
||||
73
app/models/assistant/function/update_category.rb
Normal file
73
app/models/assistant/function/update_category.rb
Normal 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
|
||||
63
app/models/assistant/function/update_tag.rb
Normal file
63
app/models/assistant/function/update_tag.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
5
mobile/l10n.yaml
Normal 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
|
||||
@@ -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
972
mobile/lib/l10n/app_en.arb
Normal 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." }
|
||||
}
|
||||
1933
mobile/lib/l10n/app_localizations.dart
Normal file
1933
mobile/lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load Diff
1026
mobile/lib/l10n/app_localizations_en.dart
Normal file
1026
mobile/lib/l10n/app_localizations_en.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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({}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
124
test/models/assistant/function/create_category_test.rb
Normal file
124
test/models/assistant/function/create_category_test.rb
Normal 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
|
||||
70
test/models/assistant/function/create_tag_test.rb
Normal file
70
test/models/assistant/function/create_tag_test.rb
Normal 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
|
||||
62
test/models/assistant/function/get_categories_test.rb
Normal file
62
test/models/assistant/function/get_categories_test.rb
Normal 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
|
||||
44
test/models/assistant/function/get_tags_test.rb
Normal file
44
test/models/assistant/function/get_tags_test.rb
Normal 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
|
||||
104
test/models/assistant/function/update_category_test.rb
Normal file
104
test/models/assistant/function/update_category_test.rb
Normal 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
|
||||
91
test/models/assistant/function/update_tag_test.rb
Normal file
91
test/models/assistant/function/update_tag_test.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
329
workers/preview/package-lock.json
generated
329
workers/preview/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user