| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708 |
- import 'package:flutter/material.dart';
- import 'package:flutter/services.dart';
- import 'package:image_picker/image_picker.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/avatar_urls.dart';
- import '../../../core/utils/dialog_utils.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../data/repositories/copy_trading_repository.dart';
- import '../../../providers/profile_provider.dart';
- class TraderSettingsScreen extends ConsumerStatefulWidget {
- const TraderSettingsScreen({super.key});
- @override
- ConsumerState<TraderSettingsScreen> createState() =>
- _TraderSettingsScreenState();
- }
- class _TraderSettingsScreenState extends ConsumerState<TraderSettingsScreen> {
- bool _loading = true;
- bool _saving = false;
- String _traderId = '';
- String? _avatarUrl;
- late TextEditingController _nicknameCtrl;
- late TextEditingController _descCtrl;
- // 所有可选标签
- List<Map<String, dynamic>> _allTags = [];
- // 已选标签 id 集合
- Set<String> _selectedTagIds = {};
- static const int _maxTags = 4;
- // 所有可选合约(固定列表,与安卓一致)
- static const List<String> _allSymbols = ['BTC/USDT', 'ETH/USDT'];
- // 已选合约(symbol 名称集合)
- Set<String> _selectedSymbols = {};
- // 初始合约(用于判断是否变更)
- Set<String> _initialSymbols = {};
- @override
- void initState() {
- super.initState();
- _nicknameCtrl = TextEditingController();
- _descCtrl = TextEditingController();
- _loadAll();
- }
- @override
- void dispose() {
- _nicknameCtrl.dispose();
- _descCtrl.dispose();
- super.dispose();
- }
- Future<void> _loadAll() async {
- setState(() => _loading = true);
- try {
- final repo = ref.read(copyTradingRepositoryProvider);
- // 并行: 个人信息 + 所有标签 + 已选标签
- final results = await Future.wait([
- repo.getFollowerInfo(), // [0]
- repo.getAllTags(), // [1]
- repo.getMyTags(), // [2]
- ]);
- final info = results[0] as Map<String, dynamic>?;
- final allTags = results[1] as List<Map<String, dynamic>>;
- final myTags = results[2] as List<Map<String, dynamic>>;
- _traderId = info?['id']?.toString() ?? '';
- _avatarUrl = info != null ? resolvedAvatarUrlFromRecord(info) : null;
- _nicknameCtrl.text = info?['nickname']?.toString() ?? '';
- _descCtrl.text = info?['description']?.toString() ?? '';
- _allTags = allTags;
- _selectedTagIds = myTags.map((t) => t['id']?.toString() ?? '').toSet();
- // 加载已选合约(API 只返回已选的,全量列表用固定 _allSymbols)
- if (_traderId.isNotEmpty) {
- final symbols = await repo.getTraderSymbols(_traderId);
- _selectedSymbols = symbols
- .map((s) => s['symbol']?.toString() ?? '')
- .where((s) => s.isNotEmpty)
- .toSet();
- _initialSymbols = Set.from(_selectedSymbols);
- }
- if (context.mounted) setState(() => _loading = false);
- } catch (e) {
- if (context.mounted) setState(() => _loading = false);
- }
- }
- Future<void> _save() async {
- if (!context.mounted) return;
- setState(() => _saving = true);
- try {
- final repo = ref.read(copyTradingRepositoryProvider);
- final nickname = _nicknameCtrl.text.trim();
- final desc = _descCtrl.text.trim();
- // 顺序执行,避免并行时部分成功部分失败导致 UI 混乱
- await repo.updateTraderProfile(nickname: nickname, description: desc);
- await repo.updateTraderTags(_selectedTagIds.toList());
- // 仅当合约有变更时才调用(有跟随者时后台会拒绝)
- final symbolsChanged = !_selectedSymbols.containsAll(_initialSymbols) ||
- !_initialSymbols.containsAll(_selectedSymbols);
- if (symbolsChanged) {
- await repo.updateTraderSymbols(_selectedSymbols.toList());
- }
- if (context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.savedSuccess,
- backgroundColor: const Color(0xFF2ECC71));
- context.pop();
- }
- } catch (e) {
- if (context.mounted) {
- showTopToast(context, message: extractErrorMessage(e));
- }
- } finally {
- if (context.mounted) setState(() => _saving = false);
- }
- }
- Future<void> _pickAvatar() async {
- final l10nSheet = AppLocalizations.of(context)!;
- final source = await showModalBottomSheet<ImageSource>(
- context: context,
- useRootNavigator: true,
- builder: (sheetCtx) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- GestureDetector(
- onTap: () => Navigator.pop(sheetCtx, ImageSource.camera),
- child: Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(children: [
- const Icon(Icons.camera_alt_outlined),
- const SizedBox(width: 16),
- Text(l10nSheet.takePhoto)
- ]),
- ),
- ),
- GestureDetector(
- onTap: () => Navigator.pop(sheetCtx, ImageSource.gallery),
- child: Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(children: [
- const Icon(Icons.photo_library_outlined),
- const SizedBox(width: 16),
- Text(l10nSheet.chooseFromAlbum)
- ]),
- ),
- ),
- GestureDetector(
- onTap: () => Navigator.pop(sheetCtx),
- child: Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
- child: Row(children: [
- const Icon(Icons.close),
- const SizedBox(width: 16),
- Text(l10nSheet.cancelLabel)
- ]),
- ),
- ),
- ],
- ),
- ),
- );
- if (source == null || !context.mounted) return;
- final picker = ImagePicker();
- final XFile? file = await picker.pickImage(
- source: source,
- maxWidth: 800,
- maxHeight: 800,
- imageQuality: 85,
- );
- if (file == null || !context.mounted) return;
- setState(() => _saving = true);
- try {
- final url =
- await ref.read(copyTradingRepositoryProvider).updateAvatar(file.path);
- if (context.mounted && url != null) {
- setState(() {
- _avatarUrl = normalizeAvatarHttpUrl(url) ?? url;
- });
- ref.invalidate(profileProvider);
- showTopToast(context,
- message: AppLocalizations.of(context)!.avatarUpdated,
- backgroundColor: const Color(0xFF2ECC71));
- }
- } catch (e) {
- if (context.mounted)
- showTopToast(context, message: extractErrorMessage(e));
- } finally {
- if (context.mounted) setState(() => _saving = false);
- }
- }
- Future<void> _cancelTrader() async {
- final l10n = AppLocalizations.of(context)!;
- final confirm = await showDialog<bool>(
- context: context,
- builder: (ctx) {
- final cs = Theme.of(ctx).colorScheme;
- final isDark = Theme.of(ctx).brightness == Brightness.dark;
- return AlertDialog(
- backgroundColor: isDark ? const Color(0xFF1E1E1E) : Colors.white,
- shape:
- RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
- title: Text(
- l10n.confirmCancelTitle,
- style: TextStyle(
- color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600),
- ),
- content: Text(
- l10n.confirmCancelTraderMsg,
- style: TextStyle(
- color: cs.onSurface.withAlpha(180), fontSize: 14, height: 1.5),
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(ctx, false),
- child: Text(l10n.cancelLabel,
- style: TextStyle(color: cs.onSurface.withAlpha(153))),
- ),
- TextButton(
- onPressed: () => Navigator.pop(ctx, true),
- child:
- Text(l10n.confirm, style: const TextStyle(color: Colors.red)),
- ),
- ],
- );
- },
- );
- if (confirm != true) return;
- try {
- await ref.read(copyTradingRepositoryProvider).cancelTraderQualification();
- if (context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.applicationSubmitted,
- backgroundColor: const Color(0xFF2ECC71));
- context.pop();
- }
- } catch (e) {
- if (context.mounted) {
- showTopToast(context, message: extractErrorMessage(e));
- }
- }
- }
- void _toggleTag(String id) {
- setState(() {
- if (_selectedTagIds.contains(id)) {
- _selectedTagIds.remove(id);
- } else {
- if (_selectedTagIds.length >= _maxTags) return;
- _selectedTagIds.add(id);
- }
- });
- }
- void _toggleSymbol(String symbol) {
- setState(() {
- if (_selectedSymbols.contains(symbol)) {
- _selectedSymbols.remove(symbol);
- } else {
- _selectedSymbols.add(symbol);
- }
- });
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final nickname =
- _nicknameCtrl.text.isEmpty ? 'T' : _nicknameCtrl.text[0].toUpperCase();
- return Scaffold(
- appBar: AppBar(
- leading: IconButton(
- icon: const Icon(Icons.arrow_back_ios, size: 18),
- onPressed: () => context.pop(),
- ),
- title: Text(AppLocalizations.of(context)!.tradingSettings,
- style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
- ),
- body: _loading
- ? const Center(child: CircularProgressIndicator())
- : SingleChildScrollView(
- padding: const EdgeInsets.symmetric(horizontal: 20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const SizedBox(height: 24),
- // ── 头像 ──────────────────────────────────
- Center(
- child: GestureDetector(
- onTap: _pickAvatar,
- child: Stack(
- children: [
- ClipRRect(
- borderRadius: BorderRadius.circular(20),
- child:
- (_avatarUrl != null && _avatarUrl!.isNotEmpty)
- ? Image.network(
- _avatarUrl!,
- width: 80,
- height: 80,
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) =>
- _DefaultAvatar(letter: nickname),
- )
- : _DefaultAvatar(letter: nickname),
- ),
- Positioned(
- bottom: 2,
- right: 2,
- child: Container(
- width: 22,
- height: 22,
- decoration: BoxDecoration(
- color: cs.surface,
- shape: BoxShape.circle,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withAlpha(30),
- blurRadius: 4)
- ],
- ),
- child: Icon(Icons.edit_outlined,
- size: 13, color: cs.onSurface.withAlpha(180)),
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 28),
- // ── 带单昵称 ──────────────────────────────
- _SectionLabel(
- label: AppLocalizations.of(context)!.tradingNickname),
- const SizedBox(height: 8),
- _InputField(
- controller: _nicknameCtrl,
- hint: AppLocalizations.of(context)!.enterNickname,
- maxLength: 10),
- const SizedBox(height: 20),
- // ── 个人签名 ──────────────────────────────
- _SectionLabel(
- label: AppLocalizations.of(context)!.personalBio),
- const SizedBox(height: 8),
- _SignatureField(controller: _descCtrl),
- const SizedBox(height: 20),
- // ── 标签 ─────────────────────────────────
- Row(
- children: [
- _SectionLabel(
- label: AppLocalizations.of(context)!.tagsLabel),
- const Spacer(),
- Text(
- AppLocalizations.of(context)!.maxTagsHint(
- _maxTags.toString(),
- _selectedTagIds.length.toString()),
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 12),
- ),
- ],
- ),
- const SizedBox(height: 10),
- _TagsWrap(
- tags: _allTags,
- selectedIds: _selectedTagIds,
- onToggle: _toggleTag,
- ),
- const SizedBox(height: 20),
- // ── 带单合约 ──────────────────────────────
- _SectionLabel(
- label: AppLocalizations.of(context)!.tradingContracts),
- const SizedBox(height: 10),
- _SymbolsWrap(
- allSymbols: _allSymbols,
- selected: _selectedSymbols,
- onToggle: _toggleSymbol,
- ),
- const SizedBox(height: 32),
- // ── 保存按钮 ──────────────────────────────
- SizedBox(
- width: double.infinity,
- height: 52,
- child: ElevatedButton(
- onPressed: _saving ? null : _save,
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- foregroundColor: Colors.black,
- disabledBackgroundColor: AppColors.brand.withAlpha(80),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(26)),
- elevation: 0,
- ),
- child: _saving
- ? SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- color: Colors.black, strokeWidth: 2),
- )
- : Text(AppLocalizations.of(context)!.saveLabel,
- style: const TextStyle(
- fontSize: 16, fontWeight: FontWeight.w600)),
- ),
- ),
- const SizedBox(height: 16),
- // ── 取消资格 ─────────────────────────────
- Center(
- child: GestureDetector(
- onTap: _cancelTrader,
- child: Text(
- AppLocalizations.of(context)!.cancelTraderQualify,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 13,
- decoration: TextDecoration.underline,
- decorationColor: cs.onSurface.withAlpha(100),
- ),
- ),
- ),
- ),
- const SizedBox(height: 32),
- ],
- ),
- ),
- );
- }
- }
- // ── 通用子组件 ────────────────────────────────────────────
- class _SectionLabel extends StatelessWidget {
- const _SectionLabel({required this.label});
- final String label;
- @override
- Widget build(BuildContext context) {
- return Text(
- label,
- style: TextStyle(
- color: Theme.of(context).colorScheme.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- ),
- );
- }
- }
- class _InputField extends StatelessWidget {
- const _InputField(
- {required this.controller, required this.hint, this.maxLength});
- final TextEditingController controller;
- final String hint;
- final int? maxLength;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Container(
- decoration: BoxDecoration(
- border: Border.all(color: cs.outline.withAlpha(60)),
- borderRadius: BorderRadius.circular(8),
- ),
- child: TextField(
- controller: controller,
- maxLength: maxLength,
- maxLengthEnforcement: MaxLengthEnforcement.enforced,
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- decoration: InputDecoration(
- hintText: hint,
- hintStyle:
- TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 14),
- contentPadding:
- const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
- border: InputBorder.none,
- counterText: '',
- ),
- ),
- );
- }
- }
- class _SignatureField extends StatefulWidget {
- const _SignatureField({required this.controller});
- final TextEditingController controller;
- @override
- State<_SignatureField> createState() => _SignatureFieldState();
- }
- class _SignatureFieldState extends State<_SignatureField> {
- static const int _maxLen = 20;
- void _onControllerChanged() => setState(() {});
- @override
- void initState() {
- super.initState();
- widget.controller.addListener(_onControllerChanged);
- }
- @override
- void dispose() {
- widget.controller.removeListener(_onControllerChanged);
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final count = widget.controller.text.length;
- return Container(
- decoration: BoxDecoration(
- border: Border.all(color: cs.outline.withAlpha(60)),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Column(
- children: [
- TextField(
- controller: widget.controller,
- maxLines: 4,
- maxLength: _maxLen,
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- decoration: InputDecoration(
- hintText: AppLocalizations.of(context)!.bioHint,
- hintStyle:
- TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 13),
- contentPadding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
- border: InputBorder.none,
- counterText: '',
- ),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(0, 0, 12, 8),
- child: Align(
- alignment: Alignment.centerRight,
- child: Text(
- '$count/$_maxLen',
- style:
- TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 12),
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
- class _TagsWrap extends StatelessWidget {
- const _TagsWrap(
- {required this.tags, required this.selectedIds, required this.onToggle});
- final List<Map<String, dynamic>> tags;
- final Set<String> selectedIds;
- final void Function(String id) onToggle;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- // 如果 API 返回空则用默认标签
- final displayTags = tags.isNotEmpty
- ? tags
- : [
- {'id': '1', 'name': l10n.tagShortTerm},
- {'id': '2', 'name': l10n.tagMidLong},
- {'id': '3', 'name': l10n.tagConservative},
- {'id': '4', 'name': l10n.tagAggressive},
- {'id': '5', 'name': l10n.tagHighLeverage},
- {'id': '6', 'name': l10n.tagLowLeverage},
- ];
- return Wrap(
- spacing: 10,
- runSpacing: 10,
- children: displayTags.map((tag) {
- final id = tag['id']?.toString() ?? '';
- final name = tag['name']?.toString() ?? '';
- final selected = selectedIds.contains(id);
- return GestureDetector(
- onTap: () => onToggle(id),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
- decoration: BoxDecoration(
- color: selected ? AppColors.brand : Colors.transparent,
- border: Border.all(
- color: selected ? AppColors.brand : cs.outline.withAlpha(80)),
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- name,
- style: TextStyle(
- color: selected ? Colors.black : cs.onSurface,
- fontSize: 13,
- fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
- ),
- ),
- ),
- );
- }).toList(),
- );
- }
- }
- class _SymbolsWrap extends StatelessWidget {
- const _SymbolsWrap(
- {required this.allSymbols,
- required this.selected,
- required this.onToggle});
- final List<String> allSymbols;
- final Set<String> selected;
- final void Function(String symbol) onToggle;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Wrap(
- spacing: 10,
- runSpacing: 10,
- children: allSymbols.map((symbol) {
- final isSelected = selected.contains(symbol);
- return GestureDetector(
- onTap: () => onToggle(symbol),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- decoration: BoxDecoration(
- color: isSelected ? AppColors.brand : Colors.transparent,
- border: Border.all(
- color: isSelected ? AppColors.brand : cs.outline.withAlpha(80),
- ),
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- symbol,
- style: TextStyle(
- color: isSelected ? Colors.black : cs.onSurface,
- fontSize: 13,
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
- ),
- ),
- ),
- );
- }).toList(),
- );
- }
- }
- class _DefaultAvatar extends StatelessWidget {
- const _DefaultAvatar({required this.letter});
- final String letter;
- @override
- Widget build(BuildContext context) {
- return Container(
- width: 80,
- height: 80,
- decoration: BoxDecoration(
- gradient: const LinearGradient(
- colors: [Color(0xFF5B7BE8), Color(0xFF7B5EA7)],
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- ),
- borderRadius: BorderRadius.circular(20),
- ),
- child: Center(
- child: Text(letter,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 32,
- fontWeight: FontWeight.w700)),
- ),
- );
- }
- }
|