diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 0b33eeb..ea8e7a6 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -4,6 +4,7 @@ class AppStrings { // Tab static const tabHome = '主页'; static const tabTickets = '票据'; + static const tabTransfer = '传包'; static const tabSettings = '设置'; static const tabAbout = '关于'; @@ -116,6 +117,8 @@ class AppStrings { '冷却中,剩余 $remaining 秒'; static String ticketCooldownNotice(int remaining) => '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。'; + static String packetCooldownNotice(int remaining) => + '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用传包。'; // Settings static const titleServerSettings = 'Title Server 设置'; @@ -124,7 +127,7 @@ class AppStrings { static const machineSettings = '机器信息'; static const save = '保存'; static const labelTitleServerUrl = 'Title Server URL'; - static const hintTitleServerUrl = 'https://maimai-gm.wahlap.com:42081/Maimai2Servlet/'; + static const hintTitleServerUrl = ''; static const labelAesKey = 'AES Key'; static const hintAesKey = 'your aes key string'; static const labelAesIv = 'AES IV'; @@ -146,7 +149,7 @@ class AppStrings { static const labelKeychipId = 'Keychip ID'; static const hintKeychipId = 'ID'; static const labelAimeUrl = 'Aime URL'; - static const hintAimeUrl = 'http://ai.sys-allnet.cn/wc_aime/api/get_data'; + static const hintAimeUrl = ''; static const labelAimeSalt = 'Aime Salt'; static const hintAimeSalt = 'API Key 派生用 Salt'; static String fieldRequired(String label) => '$label 不能为空'; @@ -160,6 +163,28 @@ class AppStrings { static const importFailed = '导入失败:剪贴板内容无效'; static const noConfigToExport = '没有可导出的配置'; + // Transfer Package (传包) + static const transferTitle = '传包 (UpsertUserAll)'; + static const transferDesc = '拉取用户数据后,可修改角色槽位、锁定槽位和角色等级,然后传包回服务器。'; + static const transferFetchData = '拉取数据'; + static const transferFetching = '拉取中...'; + static const transferNotLoggedIn = '尚未登录游戏服务器,无法传包。'; + static const transferCharaSlot = '角色槽位 (charaSlot)'; + static const transferCharaLockSlot = '锁定槽位 (charaLockSlot)'; + static const transferCharacterLevels = '角色等级 (userCharacterList)'; + static const transferAddCharacter = '添加角色'; + static const transferCharacterId = '角色ID'; + static const transferLevel = '等级'; + static const transferAwakening = '觉醒'; + static const transferSend = '发送传包'; + static const transferSending = '发送中...'; + static const transferSuccess = '传包完成'; + static const transferFailed = '传包失败'; + static const transferSlotLabel = '槽位'; + static const transferNoData = '⚠️ 未拉取到用户数据,请先点「拉取数据」按钮获取。'; + static const transferDataLoaded = '数据已就绪,可在下方编辑后发送。'; + static const transferRemove = '移除'; + // About static const aboutTitle = 'Project Ichino'; static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。'; diff --git a/lib/models/upsert_user_all.dart b/lib/models/upsert_user_all.dart new file mode 100644 index 0000000..972eb25 --- /dev/null +++ b/lib/models/upsert_user_all.dart @@ -0,0 +1,91 @@ +class UserCharacter { + int characterId; + int level; + int awakening; + int useCount; + + UserCharacter({ + required this.characterId, + this.level = 1, + this.awakening = 0, + this.useCount = 0, + }); + + factory UserCharacter.fromJson(Map json) { + return UserCharacter( + characterId: (json['characterId'] as num).toInt(), + level: (json['level'] as num?)?.toInt() ?? 1, + awakening: (json['awakening'] as num?)?.toInt() ?? 0, + useCount: (json['useCount'] as num?)?.toInt() ?? 0, + ); + } + + Map toJson() => { + 'characterId': characterId, + 'level': level, + 'awakening': awakening, + 'useCount': useCount, + }; +} + +/// The full UpsertUserAll API payload, constructed from fetched user data +/// and editable fields (charaSlot, charaLockSlot, character levels). +class UpsertUserAllPayload { + final int userId; + final int playlogId; + final bool isEventMode; + final bool isFreePlay; + final int loginDateTime; + + /// The entire [upsertUserAll] sub-object from the JSON. + final Map upsertUserAll; + + /// Editable character slots (5 elements). + final List charaSlot; + + /// Editable locked character slots (5 elements). + final List charaLockSlot; + + /// Editable character list. + final List userCharacterList; + + /// Minimal playlog entry (required — server rejects empty playlogList). + final Map? userPlaylog; + + const UpsertUserAllPayload({ + required this.userId, + required this.playlogId, + this.isEventMode = false, + this.isFreePlay = false, + required this.loginDateTime, + required this.upsertUserAll, + required this.charaSlot, + required this.charaLockSlot, + required this.userCharacterList, + this.userPlaylog, + }); + + /// Serialize to the JSON packet expected by UpsertUserAllApi. + Map toJson() { + final copy = Map.from(upsertUserAll); + final userData = List>.from( + (copy['userData'] as List) + .map((e) => Map.from(e as Map)), + ); + userData[0]['charaSlot'] = charaSlot; + userData[0]['charaLockSlot'] = charaLockSlot; + copy['userData'] = userData; + copy['userCharacterList'] = + userCharacterList.map((c) => c.toJson()).toList(); + + return { + 'userId': userId, + 'playlogId': playlogId, + 'isEventMode': isEventMode, + 'isFreePlay': isFreePlay, + 'loginDateTime': loginDateTime, + 'userPlaylogList': userPlaylog != null ? [userPlaylog] : [], + 'upsertUserAll': copy, + }; + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index c3d754a..2cb99d8 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -9,6 +9,7 @@ import '../services/title_api_service.dart'; import 'about_page.dart'; import 'settings_page.dart'; import 'ticket_page.dart'; +import 'transfer_package_page.dart'; class HomePage extends StatefulWidget { @@ -42,6 +43,7 @@ class _HomePageState extends State { static const _tabTitles = [ AppStrings.tabHome, AppStrings.tabTickets, + AppStrings.tabTransfer, AppStrings.tabSettings, AppStrings.tabAbout, ]; @@ -184,8 +186,10 @@ class _HomePageState extends State { Future _performLogoutAndExit() async { setState(() => _loggingOut = true); - await Future.delayed(const Duration(seconds: 5)); - await _logoutSession(); + if (_login != null) { + await Future.delayed(const Duration(seconds: 5)); + await _logoutSession(); + } if (mounted) { Navigator.of(context).pop(); } @@ -227,6 +231,13 @@ class _HomePageState extends State { playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0, onLogoutRequested: _login != null ? _logoutSession : null, ), + TransferPackagePage( + userId: widget.userId, + cookies: _sessionCookies ?? widget.cookies, + loginDateTime: _login?.loginDateTime, + playlogId: _login?.loginId, + lastLoginDate: _login?.lastLoginDate, + ), SettingsPage(showAppBar: false), const AboutPage(), ], @@ -237,6 +248,7 @@ class _HomePageState extends State { destinations: const [ NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: AppStrings.tabHome), NavigationDestination(icon: Icon(Icons.confirmation_number_outlined), selectedIcon: Icon(Icons.confirmation_number), label: AppStrings.tabTickets), + NavigationDestination(icon: Icon(Icons.inventory_2_outlined), selectedIcon: Icon(Icons.inventory_2), label: AppStrings.tabTransfer), NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings), NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout), ], @@ -268,7 +280,7 @@ class _HomePageState extends State { ), const SizedBox(height: 24), FilledButton.icon( - onPressed: () => setState(() => _currentTab = 2), + onPressed: () => setState(() => _currentTab = 3), icon: const Icon(Icons.settings, size: 20), label: const Text(AppStrings.openSettings), ), diff --git a/lib/pages/transfer_advanced_page.dart b/lib/pages/transfer_advanced_page.dart new file mode 100644 index 0000000..322d033 --- /dev/null +++ b/lib/pages/transfer_advanced_page.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +/// Editable JSON fields that feed into the UpsertUserAll payload. +/// Defaults match the Python script at E:\eaquira\sdgb\payload.py. +class AdvancedFields { + String userItemList; + String userMapList; + String userLoginBonusList; + String userGetPointList; + String userTradeItemList; + + AdvancedFields({ + this.userItemList = '[]', + this.userMapList = '[]', + this.userLoginBonusList = '[]', + this.userGetPointList = '[]', + this.userTradeItemList = '[]', + }); + + List? _parse(String src) { + try { + final decoded = jsonDecode(src); + return decoded is List ? decoded : null; + } catch (_) { + return null; + } + } + + List parsedUserItemList(String fb) => _parse(userItemList) ?? const []; + List parsedUserMapList(String fb) => _parse(userMapList) ?? const []; + List parsedUserLoginBonusList(String fb) => _parse(userLoginBonusList) ?? const []; + List parsedUserGetPointList(String fb) => _parse(userGetPointList) ?? const []; + List parsedUserTradeItemList(String fb) => _parse(userTradeItemList) ?? const []; + + Map toJson() => { + 'userItemList': userItemList, + 'userMapList': userMapList, + 'userLoginBonusList': userLoginBonusList, + 'userGetPointList': userGetPointList, + 'userTradeItemList': userTradeItemList, + }; + + factory AdvancedFields.fromJson(Map json) { + return AdvancedFields( + userItemList: json['userItemList'] ?? '[]', + userMapList: json['userMapList'] ?? '[]', + userLoginBonusList: json['userLoginBonusList'] ?? '[]', + userGetPointList: json['userGetPointList'] ?? '[]', + userTradeItemList: json['userTradeItemList'] ?? '[]', + ); + } +} + +/// Page that lets users paste raw JSON for advanced UpsertUserAll fields. +class TransferAdvancedPage extends StatefulWidget { + final AdvancedFields initial; + + const TransferAdvancedPage({ + super.key, + required this.initial, + }); + + @override + State createState() => _TransferAdvancedPageState(); +} + +class _TransferAdvancedPageState extends State { + late final List<_FieldMeta> _fields; + + @override + void initState() { + super.initState(); + _fields = [ + _FieldMeta('userItemList', '物品列表', widget.initial.userItemList), + _FieldMeta('userMapList', '地图列表', widget.initial.userMapList), + _FieldMeta('userLoginBonusList', '登录奖励', widget.initial.userLoginBonusList), + _FieldMeta('userGetPointList', '获取点数', widget.initial.userGetPointList), + _FieldMeta('userTradeItemList', '交易物品', widget.initial.userTradeItemList), + ]; + } + + @override + void dispose() { + for (final f in _fields) { + f.ctrl.dispose(); + } + super.dispose(); + } + + AdvancedFields get values => AdvancedFields( + userItemList: _fields[0].ctrl.text, + userMapList: _fields[1].ctrl.text, + userLoginBonusList: _fields[2].ctrl.text, + userGetPointList: _fields[3].ctrl.text, + userTradeItemList: _fields[4].ctrl.text, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('高级字段'), + actions: [ + IconButton( + icon: const Icon(Icons.check), + tooltip: '保存并返回', + onPressed: () => Navigator.of(context).pop(values), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '以下字段默认来自 Python 脚本 (空数组 [] )。如需覆盖,请粘贴完整 JSON 数组。错误格式将回退为 []。', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.orange.shade800, + ), + ), + ), + const SizedBox(height: 16), + for (final field in _fields) _buildFieldCard(theme, field), + ], + ), + ), + ); + } + + Widget _buildFieldCard(ThemeData theme, _FieldMeta field) { + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + field.label, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + TextButton( + onPressed: () { + field.ctrl.text = '[]'; + }, + child: Text('重置', style: theme.textTheme.labelSmall), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: field.ctrl, + maxLines: 6, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.all(10), + ), + ), + ], + ), + ), + ); + } +} + +class _FieldMeta { + final String key; + final String label; + final TextEditingController ctrl; + + _FieldMeta(this.key, this.label, String initial) + : ctrl = TextEditingController(text: initial); +} diff --git a/lib/pages/transfer_package_page.dart b/lib/pages/transfer_package_page.dart new file mode 100644 index 0000000..65e6011 --- /dev/null +++ b/lib/pages/transfer_package_page.dart @@ -0,0 +1,1275 @@ +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, 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; + } + + setState(() { + _step = TransferStep.sending; + _error = null; + }); + + try { + final config = TitleServerConfigHolder().config!; + final service = TitleApiService(config, cookies: widget.cookies); + 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; + }); + } + } + + 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 ?? ''; + + // Build musicData for userMusicDetailList — must NOT be empty. + // Matches the playlog entry below (same musicId/level/achievement/…). + final musicData = { + 'musicId': 834, + 'level': 4, + 'playCount': 1, + 'achievement': 1000000, + 'comboStatus': 3, + 'syncStatus': 0, + 'deluxscoreMax': 0, + 'scoreRank': 13, + 'extNum1': 0, + }; + + final upsertUserAll = { + 'userData': [ + { + ...ud, + 'playCount': (ud['playCount'] as int? ?? 0) + 1, + 'currentPlayCount': + (ud['currentPlayCount'] as int? ?? 0) + 1, + '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(), + 'lastPlayCredit': 1, + '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': loginDt, + }, + ], + 'userExtend': [ue], + 'userOption': [uo], + 'userCharacterList': + userCharacterList.map((c) => c.toJson()).toList(), + 'userGhost': [], + 'userMapList': _advanced.parsedUserMapList('[]'), + 'userLoginBonusList': _advanced.parsedUserLoginBonusList('[]'), + 'userRatingList': [ur], + 'userItemList': _advanced.parsedUserItemList('[]'), + 'userMusicDetailList': [musicData], + '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': '', + }; + + // Minimal playlog entry — required, server rejects empty playlogList. + final rating = ud['playerRating'] as int? ?? 0; + final playlog = { + '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': 834, 'level': 4, 'trackNo': 1, + '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': 1, 'characterAwakening1': 0, + 'characterId2': charaSlot.length > 1 ? charaSlot[1] : 0, + 'characterLevel2': 1, 'characterAwakening2': 0, + 'characterId3': charaSlot.length > 2 ? charaSlot[2] : 0, + 'characterLevel3': 1, 'characterAwakening3': 0, + 'characterId4': charaSlot.length > 3 ? charaSlot[3] : 0, + 'characterLevel4': 1, 'characterAwakening4': 0, + 'characterId5': charaSlot.length > 4 ? charaSlot[4] : 0, + 'characterLevel5': 1, 'characterAwakening5': 0, + 'achievement': 1000000, 'deluxscore': 0, 'scoreRank': 13, + 'maxCombo': 0, 'totalCombo': 128, 'maxSync': 0, 'totalSync': 0, + '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': false, 'isDeluxscoreNewRecord': false, + 'comboStatus': 3, 'syncStatus': 0, 'isClear': true, + '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': 101, + 'extBool1': false, 'extBool2': false, + }; + + 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) + _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; + 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) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text( + _step == TransferStep.fetching + ? AppStrings.transferFetching + : AppStrings.transferSending, + 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index bc0564f..9071c6a 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -510,6 +510,56 @@ class TitleApiService { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.0'; } + + // ---- UpsertUserAll (传包) ---- + + /// Fetch all user data needed to build an UpsertUserAll payload. + /// Returns a map keyed by the raw API names (e.g. 'GetUserDataApi'). + Future>> fetchUserAllData(int userId) async { + final results = await Future.wait([ + getUserData(userId), + getUserExtend(userId), + getUserOption(userId), + getUserRating(userId), + getUserCharge(userId), + getUserActivity(userId), + getUserMissionData(userId), + ]); + + return { + 'GetUserDataApi': results[0], + 'GetUserExtendApi': results[1], + 'GetUserOptionApi': results[2], + 'GetUserRatingApi': results[3], + 'GetUserChargeApi': results[4], + 'GetUserActivityApi': results[5], + 'GetUserMissionDataApi': results[6], + }; + } + + /// Send an UpsertUserAll payload to the server. + Future upsertUserAll( + Map packet, + int userId, + ) async { + const apiName = 'UpsertUserAllApi'; + final json = await _callApi(apiName, packet, userId); + final returnCode = json['returnCode'] as int? ?? -1; + if (returnCode != 1) { + throw TitleApiException('UpsertUserAllApi returnCode=$returnCode'); + } + } + + /// Fetch user character list. + Future>> getUserCharacter(int userId) async { + final json = await _callApi( + 'GetUserCharacterApi', + {'userId': userId}, + userId, + ); + final list = json['userCharacterList'] as List? ?? []; + return list.map((e) => e as Map).toList(); + } } class _RandomHelper {