1276 lines
42 KiB
Dart
1276 lines
42 KiB
Dart
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<TransferPackagePage> 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<TransferPackagePage> {
|
|
TransferStep _step = TransferStep.idle;
|
|
String? _error;
|
|
|
|
Map<String, Map<String, dynamic>>? _apiData;
|
|
|
|
final List<TextEditingController> _slotCtrls =
|
|
List.generate(5, (_) => TextEditingController());
|
|
final List<TextEditingController> _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<void> _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<Map<String, dynamic>> charList;
|
|
try {
|
|
charList = await service.getUserCharacter(widget.userId);
|
|
} catch (_) {
|
|
charList = [];
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
final userDataJson = data['GetUserDataApi']!;
|
|
final ud =
|
|
(userDataJson['userData'] as Map<String, dynamic>?) ?? const {};
|
|
|
|
final slot = List<int>.from((ud['charaSlot'] as List<dynamic>?)
|
|
?.map((e) => (e as num).toInt()) ??
|
|
[0, 0, 0, 0, 0]);
|
|
final lock = List<int>.from((ud['charaLockSlot'] as List<dynamic>?)
|
|
?.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<void> _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<String, dynamic>?) ?? const {};
|
|
final ue =
|
|
(userExtendJson['userExtend'] as Map<String, dynamic>?) ?? const {};
|
|
final uo =
|
|
(userOptionJson['userOption'] as Map<String, dynamic>?) ?? const {};
|
|
final ur =
|
|
(userRatingJson['userRating'] as Map<String, dynamic>?) ?? const {};
|
|
final uc =
|
|
(userChargeJson['userChargeList'] as List<dynamic>?) ?? const [];
|
|
final ua =
|
|
(userActivityJson['userActivity'] as Map<String, dynamic>?) ??
|
|
const {};
|
|
final um = (userMissionJson['userMissionDataList'] as List<dynamic>?) ??
|
|
const [];
|
|
final uw =
|
|
(userMissionJson['userWeeklyData'] as Map<String, dynamic>?) ??
|
|
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 = <String, dynamic>{
|
|
'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': <dynamic>[],
|
|
'userMapList': _advanced.parsedUserMapList('[]'),
|
|
'userLoginBonusList': _advanced.parsedUserLoginBonusList('[]'),
|
|
'userRatingList': [ur],
|
|
'userItemList': _advanced.parsedUserItemList('[]'),
|
|
'userMusicDetailList': [musicData],
|
|
'userCourseList': <dynamic>[],
|
|
'userFriendSeasonRankingList': <dynamic>[],
|
|
'userChargeList': uc,
|
|
'userFavoriteList': <dynamic>[
|
|
{'itemKind': 3, 'itemIdList': <dynamic>[]},
|
|
{'itemKind': 1, 'itemIdList': <dynamic>[]},
|
|
{'itemKind': 2, 'itemIdList': <dynamic>[]},
|
|
{'itemKind': 10, 'itemIdList': <dynamic>[]},
|
|
{'itemKind': 11, 'itemIdList': <dynamic>[]},
|
|
],
|
|
'userActivityList': [ua],
|
|
'userMissionDataList': um,
|
|
'userWeeklyData': uw,
|
|
'userGamePlaylogList': <dynamic>[
|
|
{
|
|
'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': <dynamic>[],
|
|
},
|
|
'userIntimateList': <dynamic>[],
|
|
'userShopItemStockList': <dynamic>[],
|
|
'userGetPointList': _advanced.parsedUserGetPointList('[]'),
|
|
'userTradeItemList': _advanced.parsedUserTradeItemList('[]'),
|
|
'userFavoritemusicList': <dynamic>[],
|
|
'userKaleidxScopeList': <dynamic>[],
|
|
'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 = <String, dynamic>{
|
|
'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<void> _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<bool>(
|
|
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<void> _batchSetLevel() async {
|
|
final ctrl = TextEditingController();
|
|
final ok = await showDialog<bool>(
|
|
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<void> _openAdvancedPage() async {
|
|
final result = await Navigator.of(context).push<AdvancedFields>(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|