import 'dart:async'; import 'package:flutter/material.dart'; import '../config/responsive.dart'; import '../config/strings.dart'; import '../config/title_server_config.dart'; import '../models/upsert_user_all.dart'; import '../services/title_api_service.dart'; import 'transfer_advanced_page.dart'; enum TransferStep { idle, fetching, ready, uploadingPlaylog, sending, done, failed } class TransferPackagePage extends StatefulWidget { final int userId; final String? cookies; final int? loginDateTime; final int? playlogId; final String? lastLoginDate; const TransferPackagePage({ super.key, required this.userId, this.cookies, this.loginDateTime, this.playlogId, this.lastLoginDate, }); @override State createState() => _TransferPackagePageState(); } /// Lightweight data-only row — no controllers, no FocusNodes. class _CharEntry { int characterId; int level; int awakening; int useCount; _CharEntry({ required this.characterId, this.level = 1, this.awakening = 0, this.useCount = 0, }); } class _TransferPackagePageState extends State { TransferStep _step = TransferStep.idle; String? _error; Map>? _apiData; final List _slotCtrls = List.generate(5, (_) => TextEditingController()); final List _lockSlotCtrls = List.generate(5, (_) => TextEditingController()); /// Character data — plain list, no widgets attached. final List<_CharEntry> _charEntries = []; /// Search filter final _searchCtrl = TextEditingController(); String _searchQuery = ''; /// Display cap — only show first N items initially. static const int _pageSize = 50; int _visibleCount = _pageSize; /// Advanced raw-JSON fields editable via the advanced page. AdvancedFields _advanced = AdvancedFields(); // ── Cooldown (same as TicketPage: 60s after login) ── Timer? _cooldownTimer; int _cooldownRemaining = 0; @override void initState() { super.initState(); _syncCooldown(); } @override void didUpdateWidget(covariant TransferPackagePage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.loginDateTime != widget.loginDateTime) { _syncCooldown(); } } @override void dispose() { _cooldownTimer?.cancel(); for (final c in _slotCtrls) { c.dispose(); } for (final c in _lockSlotCtrls) { c.dispose(); } _searchCtrl.dispose(); super.dispose(); } void _syncCooldown() { _cooldownTimer?.cancel(); final loginDateTime = widget.loginDateTime; if (loginDateTime == null) { _cooldownRemaining = 0; return; } _cooldownRemaining = _computeRemaining(loginDateTime); if (_cooldownRemaining <= 0) return; _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; final remaining = _computeRemaining(loginDateTime); setState(() => _cooldownRemaining = remaining); if (remaining <= 0) { _cooldownTimer?.cancel(); _cooldownTimer = null; } }); } int _computeRemaining(int loginDateTime) { final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; final elapsed = nowSec - loginDateTime; final remaining = AppStrings.ticketCooldownSeconds - elapsed; return remaining < 0 ? 0 : remaining; } // ─── Data fetching ─────────────────────────────────────────────────────── Future _fetchData() async { if (!TitleServerConfigHolder().isConfigured) return; setState(() { _step = TransferStep.fetching; _error = null; _apiData = null; _charEntries.clear(); _searchQuery = ''; _searchCtrl.clear(); _visibleCount = _pageSize; }); try { final config = TitleServerConfigHolder().config!; final service = TitleApiService(config, cookies: widget.cookies); final data = await service.fetchUserAllData(widget.userId); List> charList; try { charList = await service.getUserCharacter(widget.userId); } catch (_) { charList = []; } if (!mounted) return; final userDataJson = data['GetUserDataApi']!; final ud = (userDataJson['userData'] as Map?) ?? const {}; final slot = List.from((ud['charaSlot'] as List?) ?.map((e) => (e as num).toInt()) ?? [0, 0, 0, 0, 0]); final lock = List.from((ud['charaLockSlot'] as List?) ?.map((e) => (e as num).toInt()) ?? [0, 0, 0, 0, 0]); for (var i = 0; i < 5; i++) { _slotCtrls[i].text = slot.length > i ? '${slot[i]}' : '0'; _lockSlotCtrls[i].text = lock.length > i ? '${lock[i]}' : '0'; } for (final c in charList) { _charEntries.add(_CharEntry( characterId: (c['characterId'] as num?)?.toInt() ?? 0, level: (c['level'] as num?)?.toInt() ?? 1, awakening: (c['awakening'] as num?)?.toInt() ?? 0, useCount: (c['useCount'] as num?)?.toInt() ?? 0, )); } setState(() { _apiData = data; _step = TransferStep.ready; }); } on TitleApiException catch (e) { if (!mounted) return; setState(() { _error = e.message; _step = TransferStep.failed; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _step = TransferStep.failed; }); } } // ─── Send ──────────────────────────────────────────────────────────────── Future _sendPackage() async { if (_apiData == null) return; if (!TitleServerConfigHolder().isConfigured) return; final loginDateTime = widget.loginDateTime; if (loginDateTime == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(AppStrings.transferNotLoggedIn)), ); return; } // Step 1: Upload fake playlog setState(() { _step = TransferStep.uploadingPlaylog; _error = null; }); try { final config = TitleServerConfigHolder().config!; final service = TitleApiService(config, cookies: widget.cookies); final playlog = _buildFakePlaylog(); await service.uploadUserPlaylog(playlog, widget.userId); if (!mounted) return; // Step 2: UpsertUserAll setState(() => _step = TransferStep.sending); final payload = _buildPayload(); await service.upsertUserAll(payload.toJson(), widget.userId); if (!mounted) return; setState(() => _step = TransferStep.done); } on TitleApiException catch (e) { if (!mounted) return; setState(() { _error = e.message; _step = TransferStep.failed; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _step = TransferStep.failed; }); } } // ─── Fake game log (UploadUserPlaylog) ───────────────────────────────── /// Look up level + awakening for a character ID from the editable list. (int level, int awakening) _charLevel(int charId) { for (final e in _charEntries) { if (e.characterId == charId) return (e.level, e.awakening); } return (1, 0); } /// Build a playlog entry with character levels from _charEntries. Map _makePlaylogEntry({ required List charaSlot, required int musicId, required int level, required int trackNo, required int achievement, required int deluxscore, required int scoreRank, required int comboStatus, required int maxCombo, required int totalCombo, required int maxSync, required int totalSync, required bool isClear, required bool isAchieveNewRecord, required bool isDeluxscoreNewRecord, required int extNum4, }) { final rating = _apiRating; final cfg = TitleServerConfigHolder().config; final loginDt = widget.loginDateTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; final c1 = _charLevel(charaSlot.isNotEmpty ? charaSlot[0] : 0); final c2 = _charLevel(charaSlot.length > 1 ? charaSlot[1] : 0); final c3 = _charLevel(charaSlot.length > 2 ? charaSlot[2] : 0); final c4 = _charLevel(charaSlot.length > 3 ? charaSlot[3] : 0); final c5 = _charLevel(charaSlot.length > 4 ? charaSlot[4] : 0); return { 'userId': 0, 'orderId': 0, 'playlogId': widget.playlogId ?? 0, 'version': 1053000, 'placeId': cfg?.placeId ?? 0, 'placeName': cfg?.placeName ?? '', 'loginDate': loginDt, 'playDate': _nowStr().split(' ')[0], 'userPlayDate': _nowStr(), 'type': 0, 'musicId': musicId, 'level': level, 'trackNo': trackNo, 'vsMode': 0, 'vsUserName': '', 'vsStatus': 0, 'vsUserRating': 0, 'vsUserAchievement': 0, 'vsUserGradeRank': 0, 'vsRank': 0, 'playerNum': 1, 'playedUserId1': 0, 'playedUserName1': '', 'playedMusicLevel1': 0, 'playedUserId2': 0, 'playedUserName2': '', 'playedMusicLevel2': 0, 'playedUserId3': 0, 'playedUserName3': '', 'playedMusicLevel3': 0, 'characterId1': charaSlot.isNotEmpty ? charaSlot[0] : 0, 'characterLevel1': c1.$1, 'characterAwakening1': c1.$2, 'characterId2': charaSlot.length > 1 ? charaSlot[1] : 0, 'characterLevel2': c2.$1, 'characterAwakening2': c2.$2, 'characterId3': charaSlot.length > 2 ? charaSlot[2] : 0, 'characterLevel3': c3.$1, 'characterAwakening3': c3.$2, 'characterId4': charaSlot.length > 3 ? charaSlot[3] : 0, 'characterLevel4': c4.$1, 'characterAwakening4': c4.$2, 'characterId5': charaSlot.length > 4 ? charaSlot[4] : 0, 'characterLevel5': c5.$1, 'characterAwakening5': c5.$2, 'achievement': achievement, 'deluxscore': deluxscore, 'scoreRank': scoreRank, 'maxCombo': maxCombo, 'totalCombo': totalCombo, 'maxSync': maxSync, 'totalSync': totalSync, 'tapCriticalPerfect': 101, 'tapPerfect': 0, 'tapGreat': 0, 'tapGood': 0, 'tapMiss': 0, 'holdCriticalPerfect': 9, 'holdPerfect': 0, 'holdGreat': 0, 'holdGood': 0, 'holdMiss': 0, 'slideCriticalPerfect': 4, 'slidePerfect': 0, 'slideGreat': 0, 'slideGood': 0, 'slideMiss': 0, 'touchCriticalPerfect': 0, 'touchPerfect': 0, 'touchGreat': 0, 'touchGood': 0, 'touchMiss': 0, 'breakCriticalPerfect': 1, 'breakPerfect': 0, 'breakGreat': 0, 'breakGood': 0, 'breakMiss': 0, 'isTap': true, 'isHold': true, 'isSlide': true, 'isTouch': false, 'isBreak': true, 'isCriticalDisp': true, 'isFastLateDisp': true, 'fastCount': 0, 'lateCount': 0, 'isAchieveNewRecord': isAchieveNewRecord, 'isDeluxscoreNewRecord': isDeluxscoreNewRecord, 'comboStatus': comboStatus, 'syncStatus': 0, 'isClear': isClear, 'beforeRating': rating, 'afterRating': rating, 'beforeGrade': 0, 'afterGrade': 0, 'afterGradeRank': 0, 'beforeDeluxRating': rating, 'afterDeluxRating': rating, 'isPlayTutorial': false, 'isEventMode': false, 'isFreedomMode': false, 'playMode': 0, 'isNewFree': false, 'trialPlayAchievement': -1, 'extNum1': 0, 'extNum2': 0, 'extNum4': extNum4, 'extBool1': false, 'extBool2': false, }; } /// Build musicDetail entry matching a playlog. Map _makeMusicDetail({ required int musicId, required int level, required int playCount, required int achievement, required int comboStatus, required int syncStatus, required int deluxscoreMax, required int scoreRank, }) { return { 'musicId': musicId, 'level': level, 'playCount': playCount, 'achievement': achievement, 'comboStatus': comboStatus, 'syncStatus': syncStatus, 'deluxscoreMax': deluxscoreMax, 'scoreRank': scoreRank, 'extNum1': 0, }; } int get _apiRating { final data = _apiData; if (data == null) return 0; final userDataJson = data['GetUserDataApi']!; final ud = (userDataJson['userData'] as Map?) ?? const {}; return ud['playerRating'] as int? ?? 0; } Map _buildFakePlaylog() { final charaSlot = _slotCtrls.map((c) => int.tryParse(c.text) ?? 0).toList(); return _makePlaylogEntry( charaSlot: charaSlot, musicId: 834, level: 4, trackNo: 1, achievement: 1000000, deluxscore: 0, scoreRank: 13, comboStatus: 3, maxCombo: 0, totalCombo: 128, maxSync: 0, totalSync: 0, isClear: true, isAchieveNewRecord: true, isDeluxscoreNewRecord: true, extNum4: 101, ); } // ─── Build UpsertUserAll payload ────────────────────────────────────── UpsertUserAllPayload _buildPayload() { final data = _apiData!; final loginDt = widget.loginDateTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; final userDataJson = data['GetUserDataApi']!; final userExtendJson = data['GetUserExtendApi']!; final userOptionJson = data['GetUserOptionApi']!; final userRatingJson = data['GetUserRatingApi']!; final userChargeJson = data['GetUserChargeApi']!; final userActivityJson = data['GetUserActivityApi']!; final userMissionJson = data['GetUserMissionDataApi']!; final charaSlot = _slotCtrls.map((c) => int.tryParse(c.text) ?? 0).toList(); final charaLockSlot = _lockSlotCtrls.map((c) => int.tryParse(c.text) ?? 0).toList(); final userCharacterList = _charEntries .where((e) => e.characterId > 0) .map((e) => UserCharacter( characterId: e.characterId, level: e.level, awakening: e.awakening, useCount: e.useCount, )) .toList(); final ud = (userDataJson['userData'] as Map?) ?? const {}; final ue = (userExtendJson['userExtend'] as Map?) ?? const {}; final uo = (userOptionJson['userOption'] as Map?) ?? const {}; final ur = (userRatingJson['userRating'] as Map?) ?? const {}; final uc = (userChargeJson['userChargeList'] as List?) ?? const []; final ua = (userActivityJson['userActivity'] as Map?) ?? const {}; final um = (userMissionJson['userMissionDataList'] as List?) ?? const []; final uw = (userMissionJson['userWeeklyData'] as Map?) ?? const {}; final cfg = TitleServerConfigHolder().config; final clientId = ud['lastClientId'] ?? cfg?.clientId ?? ''; const musicId = 834; const level = 4; const achievement = 1000000; const comboStatus = 3; const deluxscore = 0; const scoreRank = 13; // Build musicDetailList + gamePlaylog + outer playlog (all for 1 track). final musicDetail = _makeMusicDetail( musicId: musicId, level: level, playCount: 1, achievement: achievement, comboStatus: comboStatus, syncStatus: 0, deluxscoreMax: deluxscore, scoreRank: scoreRank, ); final upsertUserAll = { 'userData': [ { ...ud, 'lastLoginDate': widget.lastLoginDate ?? ud['lastLoginDate'] ?? '', 'banState': userDataJson['banState'] ?? ud['banState'] ?? 0, 'charaSlot': charaSlot, 'charaLockSlot': charaLockSlot, 'lastGameId': 'SDGB', 'lastRomVersion': ud['lastRomVersion'] ?? '1.53.00', 'lastDataVersion': ud['lastDataVersion'] ?? '1.50.14', 'lastPlayDate': _nowStr(), 'lastPlayMode': 0, 'lastPlaceId': cfg?.placeId ?? ud['lastPlaceId'] ?? 0, 'lastPlaceName': cfg?.placeName ?? ud['lastPlaceName'] ?? '', 'lastRegionId': cfg?.regionId ?? ud['lastRegionId'] ?? 0, 'lastRegionName': cfg?.regionName ?? ud['lastRegionName'] ?? '', 'lastAllNetId': ud['lastAllNetId'] ?? 0, 'lastCountryCode': ud['lastCountryCode'] ?? 'CHN', 'lastClientId': clientId, 'dateTime': DateTime.now().millisecondsSinceEpoch ~/ 1000, }, ], 'userExtend': [ue], 'userOption': [uo], 'userCharacterList': userCharacterList.map((c) => c.toJson()).toList(), 'userGhost': [], 'userMapList': _advanced.parsedUserMapList('[]'), 'userLoginBonusList': _advanced.parsedUserLoginBonusList('[]'), 'userRatingList': [ur], 'userItemList': _advanced.parsedUserItemList('[]'), 'userMusicDetailList': [musicDetail], 'userCourseList': [], 'userFriendSeasonRankingList': [], 'userChargeList': uc, 'userFavoriteList': [ {'itemKind': 3, 'itemIdList': []}, {'itemKind': 1, 'itemIdList': []}, {'itemKind': 2, 'itemIdList': []}, {'itemKind': 10, 'itemIdList': []}, {'itemKind': 11, 'itemIdList': []}, ], 'userActivityList': [ua], 'userMissionDataList': um, 'userWeeklyData': uw, 'userGamePlaylogList': [ { 'playlogId': widget.playlogId ?? 0, 'version': ud['lastRomVersion'] ?? '1.53.00', 'playDate': _nowStr(), 'playMode': 0, 'useTicketId': -1, 'playCredit': 1, 'playTrack': 1, 'clientId': ud['lastClientId'] ?? TitleServerConfigHolder().config?.clientId ?? '', 'isPlayTutorial': false, 'isEventMode': false, 'isNewFree': false, 'playCount': 0, 'playSpecial': TitleApiService.calcRandom(), 'playOtherUserId': 0, }, ], 'user2pPlaylog': { 'userId1': 0, 'userId2': 0, 'userName1': '', 'userName2': '', 'regionId': 0, 'placeId': 0, 'user2pPlaylogDetailList': [], }, 'userIntimateList': [], 'userShopItemStockList': [], 'userGetPointList': _advanced.parsedUserGetPointList('[]'), 'userTradeItemList': _advanced.parsedUserTradeItemList('[]'), 'userFavoritemusicList': [], 'userKaleidxScopeList': [], 'isNewCharacterList': '', 'isNewMapList': '', 'isNewLoginBonusList': '', 'isNewItemList': '', 'isNewMusicDetailList': '0', 'isNewCourseList': '', 'isNewFavoriteList': '11111', 'isNewFriendSeasonRankingList': '', 'isNewUserIntimateList': '', 'isNewFavoritemusicList': '', 'isNewKaleidxScopeList': '', }; // Outer playlog matching the fake game log. final playlog = _makePlaylogEntry( charaSlot: charaSlot, musicId: musicId, level: level, trackNo: 1, achievement: achievement, deluxscore: deluxscore, scoreRank: scoreRank, comboStatus: comboStatus, maxCombo: 0, totalCombo: 128, maxSync: 0, totalSync: 0, isClear: true, isAchieveNewRecord: true, isDeluxscoreNewRecord: true, extNum4: 101, ); return UpsertUserAllPayload( userId: widget.userId, playlogId: widget.playlogId ?? 0, loginDateTime: loginDt, upsertUserAll: upsertUserAll, charaSlot: charaSlot, charaLockSlot: charaLockSlot, userCharacterList: userCharacterList, userPlaylog: playlog, ); } String _nowStr() { final now = DateTime.now(); String pad(int n) => n.toString().padLeft(2, '0'); return '${now.year}-${pad(now.month)}-${pad(now.day)} ' '${pad(now.hour)}:${pad(now.minute)}:${pad(now.second)}.0'; } // ─── Search / filter ───────────────────────────────────────────────────── List<_CharEntry> get _filteredEntries { if (_searchQuery.isEmpty) return _charEntries; final q = _searchQuery.toLowerCase(); return _charEntries .where((e) => e.characterId.toString().contains(q)) .toList(); } void _onSearchChanged(String value) { setState(() { _searchQuery = value.trim(); _visibleCount = _pageSize; }); } // ─── Edit dialog ───────────────────────────────────────────────────────── Future _openEditDialog(_CharEntry entry) async { final idCtrl = TextEditingController(text: '${entry.characterId}'); final levelCtrl = TextEditingController(text: '${entry.level}'); final awakeningCtrl = TextEditingController(text: '${entry.awakening}'); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('编辑角色'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: idCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '角色 ID', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: levelCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '等级', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: awakeningCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '觉醒', border: OutlineInputBorder(), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('保存'), ), ], ), ); idCtrl.dispose(); levelCtrl.dispose(); awakeningCtrl.dispose(); if (ok == true) { setState(() { entry.characterId = int.tryParse(idCtrl.text) ?? entry.characterId; entry.level = int.tryParse(levelCtrl.text) ?? entry.level; entry.awakening = int.tryParse(awakeningCtrl.text) ?? entry.awakening; }); } } // ─── Batch operations ──────────────────────────────────────────────────── Future _batchSetLevel() async { final ctrl = TextEditingController(); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('批量设置等级'), content: TextField( controller: ctrl, keyboardType: TextInputType.number, autofocus: true, decoration: const InputDecoration( labelText: '目标等级', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('全部设置'), ), ], ), ); final level = int.tryParse(ctrl.text); ctrl.dispose(); if (ok == true && level != null) { setState(() { for (final e in _charEntries) { e.level = level; } }); } } void _addCharacterRow() { setState(() { _charEntries.add(_CharEntry(characterId: 0, level: 1)); }); } void _removeCharacter(int index) { // Index in the filtered list — find the actual entry final filtered = _filteredEntries; if (index >= filtered.length) return; final entry = filtered[index]; final realIdx = _charEntries.indexOf(entry); if (realIdx >= 0) { setState(() => _charEntries.removeAt(realIdx)); } } // ─── UI ────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final theme = Theme.of(context); if (!TitleServerConfigHolder().isConfigured) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.settings_ethernet, size: 48, color: theme.colorScheme.primary), const SizedBox(height: 16), Text(AppStrings.titleServerNotConfigured, style: theme.textTheme.titleMedium), ], ), ), ); } return Column( children: [ // Top section (description + fetch button) — always visible Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: ConstrainedBox( constraints: BoxConstraints(maxWidth: responsiveMaxWidth(context)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.loginDateTime == null) _buildBanner( theme, Icons.warning_amber_rounded, AppStrings.transferNotLoggedIn, theme.colorScheme.errorContainer, theme.colorScheme.onErrorContainer, ), if (widget.loginDateTime != null && _cooldownRemaining > 0) _buildCooldownBanner(theme), _buildDescCard(theme), const SizedBox(height: 12), _buildFetchButton(theme), const SizedBox(height: 4), _buildAdvancedButton(theme), ], ), ), ), // Data sections — scrollable if (_apiData != null && _step != TransferStep.fetching) Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: ConstrainedBox( constraints: BoxConstraints( maxWidth: responsiveMaxWidth(context)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSlotSection(theme), const SizedBox(height: 12), _buildCharacterSection(theme), const SizedBox(height: 20), _buildSendButton(theme), if (_error != null) ...[ const SizedBox(height: 12), _buildErrorCard(theme), ], if (_step == TransferStep.done) ...[ const SizedBox(height: 12), _buildDoneCard(theme), ], ], ), ), ), ), if (_step == TransferStep.fetching || _step == TransferStep.sending || _step == TransferStep.uploadingPlaylog) _buildProgress(theme), if (_error != null && _apiData == null) ...[ const SizedBox(height: 12), _buildErrorCard(theme), ], ], ); } Widget _buildDescCard(ThemeData theme) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(10), ), child: Text( AppStrings.transferDesc, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onTertiaryContainer, ), ), ); } Widget _buildCooldownBanner(ThemeData theme) { return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Icon(Icons.timer_outlined, size: 18, color: theme.colorScheme.onTertiaryContainer), const SizedBox(width: 8), Expanded( child: Text( AppStrings.packetCooldownNotice(_cooldownRemaining), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onTertiaryContainer, ), ), ), ], ), ); } Widget _buildAdvancedButton(ThemeData theme) { return SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _openAdvancedPage, icon: const Icon(Icons.tune, size: 18), label: const Text('高级字段'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), ); } Future _openAdvancedPage() async { final result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => TransferAdvancedPage(initial: _advanced), ), ); if (result != null) { setState(() => _advanced = result); } } Widget _buildBanner( ThemeData theme, IconData icon, String text, Color bgColor, Color fgColor, ) { return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Icon(icon, size: 18, color: fgColor), const SizedBox(width: 8), Expanded( child: Text( text, style: theme.textTheme.bodySmall?.copyWith(color: fgColor), ), ), ], ), ); } Widget _buildFetchButton(ThemeData theme) { final hasLogin = widget.loginDateTime != null; final isLoading = _step == TransferStep.fetching; final isSending = _step == TransferStep.sending || _step == TransferStep.uploadingPlaylog; final canFetch = hasLogin && !isLoading && !isSending; return SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: canFetch ? _fetchData : null, icon: isLoading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.download, size: 20), label: Text(isLoading ? AppStrings.transferFetching : AppStrings.transferFetchData), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ); } Widget _buildSlotSection(ThemeData theme) { return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.3)), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppStrings.transferCharaSlot, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 12), for (var i = 0; i < 5; i++) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ SizedBox( width: 60, child: Text( '${AppStrings.transferSlotLabel} $i', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: TextField( controller: _slotCtrls[i], keyboardType: TextInputType.number, decoration: InputDecoration( isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), ], ), ), const SizedBox(height: 16), Text( AppStrings.transferCharaLockSlot, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 12), for (var i = 0; i < 5; i++) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ SizedBox( width: 60, child: Text( '${AppStrings.transferSlotLabel} $i', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: TextField( controller: _lockSlotCtrls[i], keyboardType: TextInputType.number, decoration: InputDecoration( isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), ], ), ), ], ), ), ); } // ─── Character section (optimised) ─────────────────────────────────────── Widget _buildCharacterSection(ThemeData theme) { final filtered = _filteredEntries; final visible = filtered.length > _visibleCount ? filtered.sublist(0, _visibleCount) : filtered; return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.3)), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header row with count Row( children: [ Expanded( child: Text( '${AppStrings.transferCharacterLevels} (${_charEntries.length})', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), TextButton.icon( onPressed: _batchSetLevel, icon: const Icon(Icons.dynamic_feed, size: 16), label: Text('批量改等级', style: theme.textTheme.labelSmall), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), ), ), TextButton.icon( onPressed: _addCharacterRow, icon: const Icon(Icons.add, size: 18), label: Text(AppStrings.transferAddCharacter, style: theme.textTheme.labelSmall), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), ), ), ], ), const SizedBox(height: 8), // Search box TextField( controller: _searchCtrl, onChanged: _onSearchChanged, decoration: InputDecoration( hintText: '搜索角色ID...', prefixIcon: const Icon(Icons.search, size: 18), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear, size: 18), onPressed: () { _searchCtrl.clear(); _onSearchChanged(''); }, ) : null, isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), const SizedBox(height: 8), // List — uses a fixed-height container + ListView.builder // so only visible rows are built. if (visible.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( _searchQuery.isNotEmpty ? '没有匹配的角色。' : '暂无角色。', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) else ...[ // Table header _charTableHeader(theme), SizedBox( // Cap height at ~10 rows to keep scrolling performant height: (visible.length.clamp(0, 10) * 40.0 + 4) .toDouble(), child: ListView.builder( itemCount: visible.length, itemExtent: 40, itemBuilder: (ctx, i) => _charRow(theme, visible[i], i), ), ), // "show more" button if (_visibleCount < filtered.length) TextButton( onPressed: () => setState(() => _visibleCount += _pageSize), child: Text( '显示更多 (已显示 $_visibleCount / ${filtered.length})', style: theme.textTheme.labelSmall, ), ), ], ], ), ), ); } Widget _charTableHeader(ThemeData theme) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( children: [ const SizedBox(width: 28), Expanded( flex: 3, child: Text('ID', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant)), ), Expanded( flex: 2, child: Text('等级', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant)), ), Expanded( flex: 2, child: Text('觉醒', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant)), ), ], ), ); } Widget _charRow(ThemeData theme, _CharEntry entry, int displayIdx) { return InkWell( onTap: () => _openEditDialog(entry), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), child: Row( children: [ SizedBox( width: 28, child: Icon(Icons.edit, size: 14, color: theme.colorScheme.onSurfaceVariant .withValues(alpha: 0.5)), ), Expanded( flex: 3, child: Text('${entry.characterId}', style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace')), ), Expanded( flex: 2, child: Text('${entry.level}', style: theme.textTheme.bodySmall), ), Expanded( flex: 2, child: Text('${entry.awakening}', style: theme.textTheme.bodySmall), ), IconButton( icon: Icon(Icons.remove_circle_outline, size: 16, color: theme.colorScheme.error), padding: EdgeInsets.zero, constraints: const BoxConstraints(), tooltip: AppStrings.transferRemove, onPressed: () => _removeCharacter(displayIdx), ), ], ), ), ); } Widget _buildSendButton(ThemeData theme) { final isSending = _step == TransferStep.sending; final hasLogin = widget.loginDateTime != null; final onCooldown = _cooldownRemaining > 0; final canSend = hasLogin && !isSending && !onCooldown; return SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: canSend ? _sendPackage : null, icon: isSending ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.file_upload_outlined, size: 20), label: Text(isSending ? AppStrings.transferSending : AppStrings.transferSend), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ); } Widget _buildProgress(ThemeData theme) { String text; if (_step == TransferStep.fetching) { text = AppStrings.transferFetching; } else if (_step == TransferStep.uploadingPlaylog) { text = AppStrings.transferUploadingPlaylog; } else { text = AppStrings.transferSending; } return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Center( child: Column( children: [ const CircularProgressIndicator(), const SizedBox(height: 12), Text( text, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ); } Widget _buildErrorCard(ThemeData theme) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.error, size: 16, color: theme.colorScheme.onErrorContainer), const SizedBox(width: 8), Text( AppStrings.transferFailed, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onErrorContainer, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 6), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onErrorContainer, fontFamily: 'monospace', ), ), ], ), ); } Widget _buildDoneCard(ThemeData theme) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ const Icon(Icons.check_circle, size: 18, color: Colors.green), const SizedBox(width: 8), Text( AppStrings.transferSuccess, style: theme.textTheme.labelMedium?.copyWith( color: Colors.green, fontWeight: FontWeight.w600, ), ), ], ), ); } }