Files
eryao/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart
T
qzl dab47f0cb3 chore: 优化本地开发环境配置
- 添加 .env.local 支持,app.sh 和 dev-migrate.sh 自动覆盖
- Docker Compose 使用 profiles 区分 dev/prod 环境
- 改进认证 dev session 判断逻辑,使用 test account 配置
- 修复 CoinPackageCard 重复代码问题
- 清理 opencode 配置,移除敏感信息
- 新增 infra/docker/README.md 文档
- 修复 ruff/pyright/flutter lint 错误
- 更新测试用例移除已删除的 country 字段
2026-04-28 18:49:38 +08:00

535 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
class SectionLabel extends StatelessWidget {
const SectionLabel({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(
left: AppSpacing.sm,
bottom: AppSpacing.sm,
),
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
);
}
}
class SettingsGroupCard extends StatelessWidget {
const SettingsGroupCard({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Card(
margin: EdgeInsets.zero,
elevation: 0,
color: colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Column(children: children),
);
}
}
class SettingsMenuTile extends StatelessWidget {
const SettingsMenuTile({
super.key,
required this.icon,
required this.title,
required this.tint,
required this.background,
required this.onTap,
this.showDivider = true,
this.showChevron = true,
this.trailing,
this.subtitle,
this.titleColor,
this.subtitleColor,
});
final IconData icon;
final String title;
final String? subtitle;
final Color tint;
final Color background;
final VoidCallback onTap;
final bool showDivider;
final bool showChevron;
final Widget? trailing;
final Color? titleColor;
final Color? subtitleColor;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.sm,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(icon, color: tint),
),
title: Text(
title,
style: titleColor == null
? null
: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: titleColor,
fontWeight: FontWeight.w600,
),
),
subtitle: subtitle == null
? null
: Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Text(
subtitle!,
style: subtitleColor == null
? null
: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: subtitleColor),
),
),
trailing:
trailing ??
(showChevron
? Icon(Icons.chevron_right_rounded, color: colors.outline)
: null),
),
if (showDivider)
Divider(
height: 1,
indent: 72,
endIndent: AppSpacing.lg,
color: colors.outline,
),
],
);
}
}
class SettingsSwitchTile extends StatelessWidget {
const SettingsSwitchTile({
super.key,
required this.icon,
required this.title,
required this.value,
required this.onChanged,
required this.tint,
required this.background,
this.showDivider = true,
});
final IconData icon;
final String title;
final bool value;
final ValueChanged<bool> onChanged;
final Color tint;
final Color background;
final bool showDivider;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.sm,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(icon, color: tint),
),
title: Text(title),
trailing: Switch(
value: value,
onChanged: onChanged,
activeThumbColor: colors.primary,
),
),
if (showDivider)
Divider(
height: 1,
indent: 72,
endIndent: AppSpacing.lg,
color: colors.outline,
),
],
);
}
}
class ProfileHeaderCard extends StatelessWidget {
const ProfileHeaderCard({
super.key,
required this.account,
required this.displayName,
required this.bio,
required this.avatarUrl,
required this.onEditTap,
});
final String account;
final String displayName;
final String bio;
final String? avatarUrl;
final VoidCallback onEditTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Card(
margin: EdgeInsets.zero,
elevation: 0,
color: colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.full),
),
alignment: Alignment.center,
child: _AvatarContent(avatarUrl: avatarUrl),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
account,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (bio.isNotEmpty) ...[
const SizedBox(height: AppSpacing.sm),
Text(
bio,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
const SizedBox(width: AppSpacing.sm),
Material(
color: colors.surface,
elevation: 2,
shadowColor: colors.shadow.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(AppRadius.full),
child: InkWell(
onTap: onEditTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: colors.primary.withValues(alpha: 0.24),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_rounded, color: colors.primary, size: 18),
const SizedBox(width: AppSpacing.xs),
Text(
AppLocalizations.of(context)!.settingsEditProfileAction,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
],
),
),
);
}
}
class _AvatarContent extends StatelessWidget {
const _AvatarContent({required this.avatarUrl});
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final url = avatarUrl?.trim() ?? '';
if (url.isNotEmpty) {
return ClipOval(
child: Image.network(
url,
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(Icons.person_rounded, color: colors.primary, size: 30);
},
),
);
}
return Icon(Icons.person_rounded, color: colors.primary, size: 30);
}
}
class WalletHeroCard extends StatelessWidget {
const WalletHeroCard({
super.key,
required this.balance,
required this.subtitle,
required this.onTap,
});
final int balance;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.xl),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
),
),
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colors.onPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Icon(
Icons.monetization_on_rounded,
color: colors.onPrimary,
),
),
const SizedBox(width: AppSpacing.lg),
Text(
l10n.settingsCoinBalanceValue(balance),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
const Spacer(),
Icon(
Icons.chevron_right_rounded,
color: colors.onPrimary.withValues(alpha: 0.9),
),
],
),
const SizedBox(height: AppSpacing.md),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.92),
),
),
),
],
),
),
),
);
}
}
class CoinPackageCard extends StatelessWidget {
const CoinPackageCard({
super.key,
required this.title,
required this.price,
required this.amount,
this.badge,
this.onPurchase,
this.isPurchasing = false,
this.isAvailable = true,
this.unavailableMessage,
});
final String title;
final String price;
final int amount;
final String? badge;
final VoidCallback? onPurchase;
final bool isPurchasing;
final bool isAvailable;
final String? unavailableMessage;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Card(
margin: EdgeInsets.zero,
elevation: 0,
color: colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.settingsCoinAmount(amount),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: palette.historyGoldBg,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
badge!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.historyGoldText,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
if (!isAvailable && unavailableMessage != null)
Text(
unavailableMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.error,
),
)
else
Row(
children: [
Text(
price,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.primary),
),
const Spacer(),
FilledButton(
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: isPurchasing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.onPrimary,
),
)
: Text(l10n.settingsPurchaseButton),
),
],
),
],
),
),
);
}
}