Files
xianyan/lib/features/discover/presentation/pages/tool/exchange_rate_page.dart
Developer 9ea8d3d606 chore: 汇总批量提交的功能优化与bug修复
本次提交包含多项迭代优化和问题修复:
1. 新增缩略图图片组件、数字格式化工具类,补充多语言翻译类型与本地化支持
2. 优化底部导航栏主题色统一使用动态accent色值
3. 修复多处图表动画、路由跳转、API请求相关问题
4. 简化服务器公告文案,调整默认分屏状态为关闭
5. 新增安卓/iOS桌面快捷方式配置
6. 重构多处状态管理类使用SafeNotifierInit统一异常保护
7. 替换硬编码蓝色为主题色,更新版本号获取方式为动态读取
8. 优化缓存预加载逻辑,移除无用代码
9. 调整默认设置项,优化用户体验细节
2026-05-31 12:24:05 +08:00

824 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — 汇率换算页面
/// 创建时间: 2026-05-30
/// 更新时间: 2026-05-30
/// 作用: iOS风格汇率换算界面集成exchangerate-api.com实时数据
/// 上次更新: 初始版本
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/features/discover/services/exchange_rate_service.dart';
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
/// ── 汇率换算状态 ──
class ExchangeRateState {
const ExchangeRateState({
this.fromCurrency = 'CNY',
this.toCurrency = 'USD',
this.amount = 1.0,
this.result,
this.isLoading = false,
this.error,
this.rates,
});
final String fromCurrency;
final String toCurrency;
final double amount;
final ExchangeRateResult? result;
final bool isLoading;
final String? error;
final Map<String, double>? rates;
ExchangeRateState copyWith({
String? fromCurrency,
String? toCurrency,
double? amount,
ExchangeRateResult? result,
bool? isLoading,
String? error,
Map<String, double>? rates,
}) {
return ExchangeRateState(
fromCurrency: fromCurrency ?? this.fromCurrency,
toCurrency: toCurrency ?? this.toCurrency,
amount: amount ?? this.amount,
result: result ?? this.result,
isLoading: isLoading ?? this.isLoading,
error: error,
rates: rates ?? this.rates,
);
}
}
/// ── 汇率换算 Notifier ──
class ExchangeRateNotifier extends Notifier<ExchangeRateState> {
@override
ExchangeRateState build() {
return const ExchangeRateState();
}
/// 换算
Future<void> convert() async {
final s = state;
state = s.copyWith(isLoading: true);
final result = await ExchangeRateService.convert(
from: s.fromCurrency,
to: s.toCurrency,
amount: s.amount,
);
if (result != null) {
state = state.copyWith(
isLoading: false,
result: result,
);
} else {
state = state.copyWith(
isLoading: false,
error: '汇率查询失败,请检查网络',
);
}
}
/// 设置源货币
void setFromCurrency(String code) {
state = state.copyWith(fromCurrency: code);
_autoConvert();
}
/// 设置目标货币
void setToCurrency(String code) {
state = state.copyWith(toCurrency: code);
_autoConvert();
}
/// 设置金额
void setAmount(double value) {
state = state.copyWith(amount: value);
}
/// 交换货币
void swapCurrencies() {
final s = state;
state = s.copyWith(
fromCurrency: s.toCurrency,
toCurrency: s.fromCurrency,
);
_autoConvert();
}
/// 自动换算(切换货币时触发)
void _autoConvert() {
convert();
}
/// 加载全部汇率(用于热门货币网格)
Future<void> loadRates() async {
final rates = await ExchangeRateService.getRates(state.fromCurrency);
if (rates != null) {
state = state.copyWith(rates: rates);
}
}
}
/// ── Provider ──
final exchangeRateProvider =
NotifierProvider<ExchangeRateNotifier, ExchangeRateState>(
ExchangeRateNotifier.new,
);
/// ── 汇率换算页面 ──
class ExchangeRatePage extends ConsumerStatefulWidget {
const ExchangeRatePage({super.key});
@override
ConsumerState<ExchangeRatePage> createState() => _ExchangeRatePageState();
}
class _ExchangeRatePageState extends ConsumerState<ExchangeRatePage> {
final _amountController = TextEditingController(text: '1');
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(exchangeRateProvider.notifier).convert();
ref.read(exchangeRateProvider.notifier).loadRates();
});
}
@override
void dispose() {
_amountController.dispose();
super.dispose();
}
/// 提交换算
void _submit() {
final text = _amountController.text.trim();
final amount = double.tryParse(text);
if (amount == null || amount <= 0) return;
ref.read(exchangeRateProvider.notifier).setAmount(amount);
ref.read(exchangeRateProvider.notifier).convert();
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final state = ref.watch(exchangeRateProvider);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('💱', style: TextStyle(fontSize: 18)),
const SizedBox(width: 4),
Text(
'汇率换算',
style: AppTypography.title3.copyWith(color: ext.textPrimary),
),
],
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.9),
border: null,
leading: const AdaptiveBackButton(),
trailing: _buildNetworkBadge(ext),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCurrencySelector(ext, state),
const SizedBox(height: AppSpacing.md),
_buildAmountInput(ext),
const SizedBox(height: AppSpacing.md),
_buildConvertButton(ext, state),
if (state.isLoading) _buildLoading(ext),
if (state.error != null) _buildError(ext, state.error!),
if (state.result != null) _buildResult(ext, state.result!),
const SizedBox(height: AppSpacing.lg),
_buildPopularGrid(ext, state),
],
),
),
),
);
}
/// ── 网络标识 ──
Widget _buildNetworkBadge(AppThemeExtension ext) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.globe, size: 12, color: ext.accent),
const SizedBox(width: 3),
Text(
'联网',
style: AppTypography.caption2.copyWith(color: ext.accent),
),
],
),
);
}
/// ── 货币选择器 ──
Widget _buildCurrencySelector(AppThemeExtension ext, ExchangeRateState state) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: ext.accent.withValues(alpha: 0.15)),
),
child: Column(
children: [
_buildCurrencyRow(
ext,
label: '',
currencyCode: state.fromCurrency,
onTap: () => _showCurrencyPicker(
isFrom: true,
currentCode: state.fromCurrency,
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: Row(
children: [
Expanded(
child: Container(
height: 0.5,
color: ext.textHint.withValues(alpha: 0.2),
),
),
GestureDetector(
onTap: () =>
ref.read(exchangeRateProvider.notifier).swapCurrencies(),
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
),
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Icon(
CupertinoIcons.arrow_up_down,
size: 18,
color: ext.accent,
),
),
),
Expanded(
child: Container(
height: 0.5,
color: ext.textHint.withValues(alpha: 0.2),
),
),
],
),
),
_buildCurrencyRow(
ext,
label: '',
currencyCode: state.toCurrency,
onTap: () => _showCurrencyPicker(
isFrom: false,
currentCode: state.toCurrency,
),
),
],
),
);
}
/// ── 单行货币 ──
Widget _buildCurrencyRow(
AppThemeExtension ext, {
required String label,
required String currencyCode,
required VoidCallback onTap,
}) {
final info = ExchangeRateService.currencyMap[currencyCode];
return GestureDetector(
onTap: onTap,
child: Row(
children: [
Text(
label,
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
),
const SizedBox(width: AppSpacing.sm),
Text(
info?.flag ?? '🌍',
style: const TextStyle(fontSize: 28),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currencyCode,
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
Text(
info?.name ?? currencyCode,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
],
),
),
Icon(
CupertinoIcons.chevron_right,
size: 16,
color: ext.textHint,
),
],
),
);
}
/// ── 金额输入 ──
Widget _buildAmountInput(AppThemeExtension ext) {
final state = ref.watch(exchangeRateProvider);
final info = ExchangeRateService.currencyMap[state.fromCurrency];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'金额',
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.xs),
Container(
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.lgBorder,
),
child: CupertinoTextField(
controller: _amountController,
placeholder: '输入金额',
placeholderStyle: AppTypography.body.copyWith(
color: ext.textHint,
),
style: AppTypography.headline.copyWith(color: ext.textPrimary),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 4,
),
decoration: const BoxDecoration(),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
prefix: Padding(
padding: const EdgeInsets.only(left: AppSpacing.md),
child: Text(
info?.symbol ?? '',
style: AppTypography.headline.copyWith(
color: ext.textSecondary,
),
),
),
onSubmitted: (_) => _submit(),
onChanged: (value) {
final amount = double.tryParse(value.trim());
if (amount != null) {
ref.read(exchangeRateProvider.notifier).setAmount(amount);
}
},
),
),
],
);
}
/// ── 换算按钮 ──
Widget _buildConvertButton(AppThemeExtension ext, ExchangeRateState state) {
return SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CupertinoActivityIndicator(color: CupertinoColors.white)
: Text(
'💱 换算',
style: AppTypography.title3.copyWith(
color: CupertinoColors.white,
),
),
),
);
}
/// ── 加载指示器 ──
Widget _buildLoading(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.lg),
child: Center(
child: Column(
children: [
const CupertinoActivityIndicator(),
const SizedBox(height: AppSpacing.sm),
Text(
'正在查询汇率...',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
],
),
),
);
}
/// ── 错误提示 ──
Widget _buildError(AppThemeExtension ext, String error) {
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.lg),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
borderRadius: AppRadius.lgBorder,
),
child: Row(
children: [
const Icon(
CupertinoIcons.xmark_circle,
color: CupertinoColors.systemRed,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
error,
style: AppTypography.body.copyWith(
color: CupertinoColors.systemRed,
),
),
),
],
),
),
);
}
/// ── 换算结果 ──
Widget _buildResult(AppThemeExtension ext, ExchangeRateResult result) {
final toInfo = ExchangeRateService.currencyMap[result.to];
final fromInfo = ExchangeRateService.currencyMap[result.from];
final fmt = NumberFormat('#,##0.00');
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.lg),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: ext.accent.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.checkmark_circle,
color: ext.accent,
size: 20,
),
const SizedBox(width: AppSpacing.sm),
Text(
'换算结果',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.md),
_buildResultRow(
ext,
label: '输入金额',
value: '${fromInfo?.symbol ?? ''}${fmt.format(result.amount)} ${result.from}',
),
const SizedBox(height: AppSpacing.sm),
_buildResultRow(
ext,
label: '换算结果',
value: '${toInfo?.symbol ?? ''}${fmt.format(result.converted)} ${result.to}',
isHighlight: true,
),
const SizedBox(height: AppSpacing.sm),
_buildResultRow(
ext,
label: '参考汇率',
value: '1 ${result.from} = ${fmt.format(result.rate)} ${result.to}',
),
const SizedBox(height: AppSpacing.sm),
_buildResultRow(
ext,
label: '更新时间',
value: _formatDate(result.updateTime),
),
],
),
),
);
}
/// ── 结果行 ──
Widget _buildResultRow(
AppThemeExtension ext, {
required String label,
required String value,
bool isHighlight = false,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
),
Expanded(
child: Text(
value,
style: (isHighlight ? AppTypography.headline : AppTypography.body)
.copyWith(
color: isHighlight ? ext.accent : ext.textPrimary,
fontWeight: isHighlight ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
);
}
/// ── 热门货币网格 ──
Widget _buildPopularGrid(AppThemeExtension ext, ExchangeRateState state) {
final rates = state.rates;
if (rates == null) return const SizedBox.shrink();
final popular = ExchangeRateService.popularCurrencies
.where((c) => c.code != state.fromCurrency && rates.containsKey(c.code))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(CupertinoIcons.chart_bar, size: 16, color: ext.accent),
const SizedBox(width: AppSpacing.xs),
Text(
'热门货币汇率',
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'基于 1 ${state.fromCurrency}',
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: popular.map((info) {
final rate = rates[info.code]!;
return _buildCurrencyChip(ext, info, rate, state);
}).toList(),
),
],
);
}
/// ── 货币芯片 ──
Widget _buildCurrencyChip(
AppThemeExtension ext,
CurrencyInfo info,
double rate,
ExchangeRateState state,
) {
final isSelected = info.code == state.toCurrency;
final fmt = NumberFormat('#,##0.00##');
return GestureDetector(
onTap: () {
ref.read(exchangeRateProvider.notifier).setToCurrency(info.code);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.sm - 2,
),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.12)
: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
border: isSelected
? Border.all(color: ext.accent, width: 1.2)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(info.flag, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 4),
Text(
info.code,
style: AppTypography.caption1.copyWith(
color: isSelected ? ext.accent : ext.textPrimary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
const SizedBox(width: 4),
Text(
fmt.format(rate),
style: AppTypography.caption2.copyWith(
color: ext.textSecondary,
),
),
],
),
),
);
}
/// ── 货币选择器弹窗 ──
void _showCurrencyPicker({
required bool isFrom,
required String currentCode,
}) {
final ext = AppTheme.ext(context);
showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
return Container(
height: MediaQuery.of(ctx).size.height * 0.5,
decoration: BoxDecoration(
color: ext.bgElevated,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppRadius.xl),
),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'选择货币',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(ctx).pop(),
child: Text(
'完成',
style: AppTypography.subhead.copyWith(
color: ext.accent,
),
),
),
],
),
),
Expanded(
child: CupertinoScrollbar(
child: ListView.builder(
itemCount: ExchangeRateService.popularCurrencies.length,
itemBuilder: (_, index) {
final info =
ExchangeRateService.popularCurrencies[index];
final isSelected = info.code == currentCode;
return CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
onPressed: () {
if (isFrom) {
ref
.read(exchangeRateProvider.notifier)
.setFromCurrency(info.code);
ref
.read(exchangeRateProvider.notifier)
.loadRates();
} else {
ref
.read(exchangeRateProvider.notifier)
.setToCurrency(info.code);
}
Navigator.of(ctx).pop();
},
child: Row(
children: [
Text(
info.flag,
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
info.code,
style: AppTypography.body.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w500,
),
),
Text(
info.name,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
],
),
),
if (isSelected)
Icon(
CupertinoIcons.checkmark_circle_fill,
color: ext.accent,
size: 22,
),
],
),
);
},
),
),
),
],
),
);
},
);
}
/// ── 日期格式化 ──
String _formatDate(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
}