first commit
This commit is contained in:
127
lib/config/strings.dart
Normal file
127
lib/config/strings.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
class AppStrings {
|
||||
AppStrings._();
|
||||
|
||||
// Tab
|
||||
static const tabHome = '主页';
|
||||
static const tabTickets = '票据';
|
||||
static const tabSettings = '设置';
|
||||
static const tabAbout = '关于';
|
||||
|
||||
// Login
|
||||
static const appTitle = 'Project Rapollo';
|
||||
static const loginSubtitle = '扫描或输入二维码进行登录';
|
||||
static const qrCodeToken = 'QR Code 令牌';
|
||||
static const qrHint = '粘贴二维码解析内容...';
|
||||
static const qrParsedResult = '解析结果(截取后 64 位)';
|
||||
static const uploadQR = '上传二维码';
|
||||
static const login = '登录';
|
||||
static const loggingIn = '登录中...';
|
||||
static const qrScanSuccess = '二维码解析成功';
|
||||
static const qrScanFailed = '未能识别二维码,请手动粘贴内容';
|
||||
static const qrScanError = '解析失败';
|
||||
static const loginFailed = '登录失败';
|
||||
static const requestFailed = '请求失败';
|
||||
static const settingsTooltip = '设置';
|
||||
|
||||
// Home
|
||||
static const logoutTooltip = '退出登录';
|
||||
static const titleServerNotConfigured = '未配置 Title Server';
|
||||
static const titleServerNotConfiguredDesc = '请配置 Title Server 以获取用户数据。';
|
||||
static const openSettings = '打开设置';
|
||||
static const loadFailed = '数据加载失败';
|
||||
static const retry = '重试';
|
||||
static const unknownUser = '未知用户';
|
||||
static const online = '在线';
|
||||
static const offline = '离线';
|
||||
static const gameInfo = '游戏信息';
|
||||
static const lastGame = '最后游戏';
|
||||
static const lastPlay = '最后游玩';
|
||||
static const lastLogin = '最后登录';
|
||||
static const romVersion = 'Rom 版本';
|
||||
static const dataVersion = '数据版本';
|
||||
static const status = '状态';
|
||||
static const netMember = '联网会员';
|
||||
static const inherit = '继承';
|
||||
static const banState = '封禁状态';
|
||||
static const clean = '正常';
|
||||
static const dailyBonus = '每日奖励';
|
||||
static const notClaimed = '未领取';
|
||||
static const moreDetails = '更多详情';
|
||||
static const userId = '用户 ID';
|
||||
static const totalAwake = '总计 Awake';
|
||||
static const displayRate = '展示 Rate';
|
||||
static const iconId = '图标 ID';
|
||||
static const nameplateId = '名牌 ID';
|
||||
static const trophyId = '奖杯 ID';
|
||||
static const headphoneVol = '耳机音量';
|
||||
static const errorId = '错误 ID';
|
||||
static const rawApiResponse = '原始 API 响应 (调试)';
|
||||
static String rating(int r) => 'Rating: $r';
|
||||
|
||||
// Tickets
|
||||
static const ticketsTitle = '票据';
|
||||
static const musicConfig = '乐曲配置';
|
||||
static const musicId = 'Music ID';
|
||||
static const level = 'Level';
|
||||
static const achievement = 'Achievement';
|
||||
static const comboStatus = 'Combo Status';
|
||||
static const syncStatus = 'Sync Status';
|
||||
static const deluxscoreMax = 'Deluxscore Max';
|
||||
static const scoreRank = 'Score Rank';
|
||||
static const runTicket = '执行打歌';
|
||||
static const runningTicket = '执行中...';
|
||||
static const ticketNotConfigured = '请先配置 Title Server 设置。';
|
||||
static const ticketNeedLogin = '用户未登录,请先返回主页登录。';
|
||||
|
||||
static const stepCheckPreview = '检查登录状态';
|
||||
static const stepUserLogin = '登录游戏';
|
||||
static const stepFetchData = '获取用户数据';
|
||||
static const stepSimulatePlay = '模拟游戏时间';
|
||||
static const stepUploadPlaylog = '上传游玩记录';
|
||||
static const stepUpsertAll = '上传用户数据';
|
||||
static const stepLogout = '退出游戏';
|
||||
static const stepComplete = '打歌完成';
|
||||
static const stepFailed = '执行失败';
|
||||
|
||||
static const ticketErrorAlreadyLogin = '用户已在他处登录,请先退出。';
|
||||
static const ticketErrorChime = 'Chime 验证失败。';
|
||||
static String ticketLoginInfo(int loginId) => 'Login ID: $loginId';
|
||||
|
||||
static const myTickets = '功能票';
|
||||
static const refreshTickets = '刷新';
|
||||
static const loading = '加载中...';
|
||||
static const ticketsNotLoaded = '点击刷新按钮加载功能票。';
|
||||
static const noTickets = '暂无功能票。';
|
||||
|
||||
// Settings
|
||||
static const titleServerSettings = 'Title Server 设置';
|
||||
static const required = '必填';
|
||||
static const optional = '可选 (有默认值)';
|
||||
static const save = '保存';
|
||||
static const labelTitleServerUrl = 'Title Server URL';
|
||||
static const hintTitleServerUrl = 'http://maimai-gm.wahlap.com:42081';
|
||||
static const labelAesKey = 'AES Key';
|
||||
static const hintAesKey = 'your aes key string';
|
||||
static const labelAesIv = 'AES IV';
|
||||
static const hintAesIv = 'your aes iv string';
|
||||
static const labelClientId = 'Client ID';
|
||||
static const hintClientId = 'your client id';
|
||||
static const labelRegionId = 'Region ID';
|
||||
static const hintRegionId = '1';
|
||||
static const labelPlaceId = 'Place ID';
|
||||
static const hintPlaceId = '1403';
|
||||
static const labelObfuscateParam = 'Obfuscate Param';
|
||||
static const hintObfuscateParam = 'LatuAa81';
|
||||
static const labelApiVersion = 'API Version (Mai-Encoding)';
|
||||
static const hintApiVersion = '1.53';
|
||||
static String fieldRequired(String label) => '$label 不能为空';
|
||||
|
||||
// About
|
||||
static const aboutTitle = 'Project Rapollo';
|
||||
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
|
||||
static const credits = '致谢';
|
||||
static const creditBuiltWith = '构建框架';
|
||||
static const creditQRDecode = 'QR 解码';
|
||||
static const creditIconAssets = '图标资源';
|
||||
static const creditLicense = '许可证';
|
||||
}
|
||||
98
lib/config/title_server_config.dart
Normal file
98
lib/config/title_server_config.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class TitleServerConfig {
|
||||
final String titleServerUrl;
|
||||
final String aesKey;
|
||||
final String aesIv;
|
||||
final String obfuscateParam;
|
||||
final String apiVersion;
|
||||
final String clientId;
|
||||
final int regionId;
|
||||
final int placeId;
|
||||
|
||||
const TitleServerConfig({
|
||||
required this.titleServerUrl,
|
||||
required this.aesKey,
|
||||
required this.aesIv,
|
||||
this.obfuscateParam = 'LatuAa81',
|
||||
this.apiVersion = '1.53',
|
||||
required this.clientId,
|
||||
this.regionId = 1,
|
||||
this.placeId = 1403,
|
||||
});
|
||||
|
||||
List<int> get aesKeyBytes => utf8.encode(aesKey);
|
||||
List<int> get aesIvBytes => utf8.encode(aesIv);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'titleServerUrl': titleServerUrl,
|
||||
'aesKey': aesKey,
|
||||
'aesIv': aesIv,
|
||||
'obfuscateParam': obfuscateParam,
|
||||
'apiVersion': apiVersion,
|
||||
'clientId': clientId,
|
||||
'regionId': regionId,
|
||||
'placeId': placeId,
|
||||
};
|
||||
|
||||
factory TitleServerConfig.fromJson(Map<String, dynamic> json) {
|
||||
return TitleServerConfig(
|
||||
titleServerUrl: json['titleServerUrl'] as String? ?? '',
|
||||
aesKey: json['aesKey'] as String? ?? '',
|
||||
aesIv: json['aesIv'] as String? ?? '',
|
||||
obfuscateParam: json['obfuscateParam'] as String? ?? 'LatuAa81',
|
||||
apiVersion: json['apiVersion'] as String? ?? '1.53',
|
||||
clientId: json['clientId'] as String? ?? '',
|
||||
regionId: json['regionId'] as int? ?? 1,
|
||||
placeId: json['placeId'] as int? ?? 1403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TitleServerConfigHolder {
|
||||
static const _storageKey = 'title_server_config';
|
||||
|
||||
static final TitleServerConfigHolder _instance = TitleServerConfigHolder._();
|
||||
factory TitleServerConfigHolder() => _instance;
|
||||
TitleServerConfigHolder._();
|
||||
|
||||
TitleServerConfig? _config;
|
||||
TitleServerConfig? get config => _config;
|
||||
|
||||
bool get isConfigured {
|
||||
final c = _config;
|
||||
return c != null &&
|
||||
c.titleServerUrl.isNotEmpty &&
|
||||
c.aesKey.isNotEmpty &&
|
||||
c.aesIv.isNotEmpty &&
|
||||
c.clientId.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw != null) {
|
||||
try {
|
||||
_config = TitleServerConfig.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
} catch (_) {
|
||||
_config = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> update(TitleServerConfig config) async {
|
||||
_config = config;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_storageKey, jsonEncode(config.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_config = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey);
|
||||
}
|
||||
}
|
||||
326
lib/main.dart
Normal file
326
lib/main.dart
Normal file
@@ -0,0 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'config/strings.dart';
|
||||
import 'config/title_server_config.dart';
|
||||
import 'services/api_service.dart';
|
||||
import 'services/file_picker_service.dart';
|
||||
import 'services/qr_service.dart';
|
||||
import 'pages/home_page.dart';
|
||||
import 'pages/settings_page.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await TitleServerConfigHolder().init();
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: AppStrings.appTitle,
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: ThemeMode.system,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
home: const LoginPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _qrController = TextEditingController();
|
||||
final _apiService = ApiService();
|
||||
String _qrCode = '';
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_qrController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onQRContentChanged(String value) {
|
||||
setState(() {
|
||||
_qrCode = _apiService.extractQRCode(value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onUploadQR() async {
|
||||
try {
|
||||
final bytes = await pickImageBytes();
|
||||
if (bytes == null) return;
|
||||
|
||||
final result = await decodeQRFromBytes(bytes);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
_qrController.text = result;
|
||||
setState(() {
|
||||
_qrCode = _apiService.extractQRCode(result);
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.qrScanFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.qrScanError}: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
if (_qrCode.isEmpty) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final result = await _apiService.login(_qrCode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.success) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(
|
||||
userId: result.userId,
|
||||
token: result.token,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(AppStrings.appTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: AppStrings.settingsTooltip,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.qr_code_scanner,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppStrings.appTitle,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.loginSubtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.key,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppStrings.qrCodeToken,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _qrController,
|
||||
maxLines: 3,
|
||||
onChanged: _onQRContentChanged,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: AppStrings.qrHint,
|
||||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.5),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_qrCode.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppStrings.qrParsedResult,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_qrCode,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _onUploadQR,
|
||||
icon: const Icon(Icons.image, size: 20),
|
||||
label: const Text(AppStrings.uploadQR),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed:
|
||||
(_qrCode.isNotEmpty && !_loading) ? _onLogin : null,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward, size: 20),
|
||||
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/models/user_preview.dart
Normal file
90
lib/models/user_preview.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
class UserPreviewDataBean {
|
||||
final int userId;
|
||||
final String userName;
|
||||
final bool isLogin;
|
||||
final String lastGameId;
|
||||
final String lastRomVersion;
|
||||
final String lastDataVersion;
|
||||
final String lastLoginDate;
|
||||
final String lastPlayDate;
|
||||
final int playerRating;
|
||||
final int nameplateId;
|
||||
final int iconId;
|
||||
final int trophyId;
|
||||
final bool isNetMember;
|
||||
final bool isInherit;
|
||||
final int totalAwake;
|
||||
final int dispRate;
|
||||
final String dailyBonusDate;
|
||||
final int headPhoneVolume;
|
||||
final int banState;
|
||||
final int errorId;
|
||||
|
||||
const UserPreviewDataBean({
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.isLogin,
|
||||
required this.lastGameId,
|
||||
required this.lastRomVersion,
|
||||
required this.lastDataVersion,
|
||||
required this.lastLoginDate,
|
||||
required this.lastPlayDate,
|
||||
required this.playerRating,
|
||||
required this.nameplateId,
|
||||
required this.iconId,
|
||||
required this.trophyId,
|
||||
required this.isNetMember,
|
||||
required this.isInherit,
|
||||
required this.totalAwake,
|
||||
required this.dispRate,
|
||||
required this.dailyBonusDate,
|
||||
required this.headPhoneVolume,
|
||||
required this.banState,
|
||||
required this.errorId,
|
||||
});
|
||||
|
||||
factory UserPreviewDataBean.fromJson(Map<String, dynamic> json) {
|
||||
int toInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is int) return v;
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
if (v is double) return v.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool toBool(dynamic v) {
|
||||
if (v == null) return false;
|
||||
if (v is bool) return v;
|
||||
if (v is int) return v != 0;
|
||||
if (v is String) return v == '1' || v.toLowerCase() == 'true';
|
||||
return false;
|
||||
}
|
||||
|
||||
String toStr(dynamic v) => v?.toString() ?? '';
|
||||
|
||||
return UserPreviewDataBean(
|
||||
userId: toInt(json['userId']),
|
||||
userName: toStr(json['userName']),
|
||||
isLogin: toBool(json['isLogin']),
|
||||
lastGameId: toStr(json['lastGameId']),
|
||||
lastRomVersion: toStr(json['lastRomVersion']),
|
||||
lastDataVersion: toStr(json['lastDataVersion']),
|
||||
lastLoginDate: toStr(json['lastLoginDate']),
|
||||
lastPlayDate: toStr(json['lastPlayDate']),
|
||||
playerRating: toInt(json['playerRating']),
|
||||
nameplateId: toInt(json['nameplateId']),
|
||||
iconId: toInt(json['iconId']),
|
||||
trophyId: toInt(json['trophyId']),
|
||||
isNetMember: toBool(json['isNetMember']),
|
||||
isInherit: toBool(json['isInherit']),
|
||||
totalAwake: toInt(json['totalAwake']),
|
||||
dispRate: toInt(json['dispRate']),
|
||||
dailyBonusDate: toStr(json['dailyBonusDate']),
|
||||
headPhoneVolume: (json['headPhoneVolume'] is String)
|
||||
? (int.tryParse(json['headPhoneVolume']) ?? 0)
|
||||
: toInt(json['headPhoneVolume']),
|
||||
banState: toInt(json['banState']),
|
||||
errorId: toInt(json['errorId']),
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/pages/about_page.dart
Normal file
92
lib/pages/about_page.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/strings.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
const AboutPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
Icon(Icons.qr_code_scanner, size: 64, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppStrings.aboutTitle,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.aboutDesc,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
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.credits,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_creditRow(theme, AppStrings.creditBuiltWith, 'Flutter'),
|
||||
_creditRow(theme, AppStrings.creditQRDecode, 'jsQR (web)'),
|
||||
_creditRow(theme, AppStrings.creditIconAssets, 'assets2.lxns.net'),
|
||||
_creditRow(theme, AppStrings.creditLicense, 'MIT'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _creditRow(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
651
lib/pages/home_page.dart
Normal file
651
lib/pages/home_page.dart
Normal file
@@ -0,0 +1,651 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/user_preview.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
import 'about_page.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ticket_page.dart';
|
||||
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String token;
|
||||
|
||||
const HomePage({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.token,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _currentTab = 0;
|
||||
UserPreviewDataBean? _data;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
static const _tabTitles = [AppStrings.tabHome, AppStrings.tabTickets, AppStrings.tabSettings, AppStrings.tabAbout];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPreview();
|
||||
}
|
||||
|
||||
Future<void> _loadPreview() async {
|
||||
// ignore: avoid_print
|
||||
print('[_loadPreview] called, isConfigured=${TitleServerConfigHolder().isConfigured}');
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
// ignore: avoid_print
|
||||
print('[_loadPreview] config not set, bailing');
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[_loadPreview] calling getUserPreview userId=${widget.userId} token=${widget.token}');
|
||||
final service = TitleApiService(TitleServerConfigHolder().config!);
|
||||
final data = await service.getUserPreview(
|
||||
userId: widget.userId,
|
||||
token: widget.token,
|
||||
);
|
||||
if (!mounted) return;
|
||||
// ignore: avoid_print
|
||||
print('[_loadPreview] success, errorId=${data.errorId}');
|
||||
setState(() {
|
||||
_data = data;
|
||||
_loading = false;
|
||||
});
|
||||
} on TitleApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
// ignore: avoid_print
|
||||
print('[_loadPreview] TitleApiException: $e');
|
||||
setState(() {
|
||||
_error = e.message;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// ignore: avoid_print
|
||||
print('[_loadPreview] exception: $e');
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_tabTitles[_currentTab]),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: AppStrings.logoutTooltip,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: _currentTab,
|
||||
children: [
|
||||
_buildBody(theme),
|
||||
TicketPage(userId: widget.userId, token: widget.token),
|
||||
SettingsPage(showAppBar: false),
|
||||
const AboutPage(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentTab,
|
||||
onDestinationSelected: (i) => setState(() => _currentTab = i),
|
||||
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.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings),
|
||||
NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ThemeData theme) {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.settings_ethernet, size: 64, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
AppStrings.titleServerNotConfigured,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppStrings.titleServerNotConfiguredDesc,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => setState(() => _currentTab = 2),
|
||||
icon: const Icon(Icons.settings, size: 20),
|
||||
label: const Text(AppStrings.openSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 12),
|
||||
Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _loadPreview,
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
label: const Text(AppStrings.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final data = _data!;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
children: [
|
||||
if (data.errorId != 0) _ErrorIdBanner(theme: theme, errorId: data.errorId),
|
||||
_ProfileCard(theme: theme, data: data),
|
||||
const SizedBox(height: 12),
|
||||
_GameInfoCard(theme: theme, data: data),
|
||||
const SizedBox(height: 12),
|
||||
_StatusCard(theme: theme, data: data),
|
||||
const SizedBox(height: 12),
|
||||
_DetailCard(theme: theme, data: data),
|
||||
if (TitleApiService.lastRawResponse != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_DebugRawJsonCard(
|
||||
theme: theme,
|
||||
rawJson: TitleApiService.lastRawResponse!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileCard extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final UserPreviewDataBean data;
|
||||
|
||||
const _ProfileCard({required this.theme, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
backgroundImage: data.iconId > 0
|
||||
? NetworkImage('https://assets2.lxns.net/maimai/icon/${data.iconId}.png')
|
||||
: null,
|
||||
child: data.iconId == 0
|
||||
? Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
data.userName.isEmpty ? AppStrings.unknownUser : data.userName,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppStrings.rating(data.playerRating),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
data.isLogin ? Icons.circle : Icons.circle_outlined,
|
||||
color: data.isLogin ? Colors.green : Colors.grey,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
data.isLogin ? AppStrings.online : AppStrings.offline,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: data.isLogin ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GameInfoCard extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final UserPreviewDataBean data;
|
||||
|
||||
const _GameInfoCard({required this.theme, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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.gameInfo,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_infoRow(theme, AppStrings.lastGame, data.lastGameId),
|
||||
_infoRow(theme, AppStrings.lastPlay, data.lastPlayDate),
|
||||
_infoRow(theme, AppStrings.lastLogin, data.lastLoginDate),
|
||||
_infoRow(theme, AppStrings.romVersion, data.lastRomVersion),
|
||||
_infoRow(theme, AppStrings.dataVersion, data.lastDataVersion),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final UserPreviewDataBean data;
|
||||
|
||||
const _StatusCard({required this.theme, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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.status,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_statusChip(theme, AppStrings.netMember, data.isNetMember),
|
||||
_statusChip(theme, AppStrings.inherit, data.isInherit),
|
||||
_statusChip(theme, AppStrings.banState, data.banState == 0, trueText: AppStrings.clean, falseText: '已封禁 (${data.banState})'),
|
||||
_statusChip(theme, AppStrings.dailyBonus, data.dailyBonusDate.isNotEmpty, trueText: data.dailyBonusDate, falseText: AppStrings.notClaimed),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusChip(ThemeData theme, String label, dynamic value, {String trueText = 'Yes', String falseText = 'No'}) {
|
||||
String text;
|
||||
Color color;
|
||||
if (value is bool) {
|
||||
text = value ? trueText : falseText;
|
||||
color = value ? Colors.green : Colors.grey;
|
||||
} else {
|
||||
text = '$value';
|
||||
color = theme.colorScheme.onSurface;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailCard extends StatefulWidget {
|
||||
final ThemeData theme;
|
||||
final UserPreviewDataBean data;
|
||||
|
||||
const _DetailCard({required this.theme, required this.data});
|
||||
|
||||
@override
|
||||
State<_DetailCard> createState() => _DetailCardState();
|
||||
}
|
||||
|
||||
class _DetailCardState extends State<_DetailCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = widget.theme;
|
||||
final data = widget.data;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.moreDetails,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 14),
|
||||
_detailRow(theme, AppStrings.userId, '${data.userId}'),
|
||||
_detailRow(theme, AppStrings.totalAwake, '${data.totalAwake}'),
|
||||
_detailRow(theme, AppStrings.displayRate, '${data.dispRate}'),
|
||||
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
|
||||
_detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'),
|
||||
_detailRow(theme, AppStrings.trophyId, '${data.trophyId}'),
|
||||
_detailRow(theme, AppStrings.headphoneVol, '${data.headPhoneVolume}'),
|
||||
_detailRow(theme, AppStrings.errorId, '${data.errorId}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(ThemeData theme, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DebugRawJsonCard extends StatefulWidget {
|
||||
final ThemeData theme;
|
||||
final String rawJson;
|
||||
|
||||
const _DebugRawJsonCard({
|
||||
required this.theme,
|
||||
required this.rawJson,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DebugRawJsonCard> createState() => _DebugRawJsonCardState();
|
||||
}
|
||||
|
||||
class _DebugRawJsonCardState extends State<_DebugRawJsonCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = widget.theme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: Colors.orange.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bug_report, size: 16, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.rawApiResponse,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SelectableText(
|
||||
widget.rawJson,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorIdBanner extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
final int errorId;
|
||||
|
||||
const _ErrorIdBanner({required this.theme, required this.errorId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
size: 20, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'API returned errorId: $errorId',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
lib/pages/settings_page.dart
Normal file
220
lib/pages/settings_page.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
final bool showAppBar;
|
||||
|
||||
const SettingsPage({super.key, this.showAppBar = true});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _urlController = TextEditingController();
|
||||
final _aesKeyController = TextEditingController();
|
||||
final _aesIvController = TextEditingController();
|
||||
final _clientIdController = TextEditingController();
|
||||
final _regionIdController = TextEditingController(text: '1');
|
||||
final _placeIdController = TextEditingController(text: '1403');
|
||||
final _obfuscateController = TextEditingController(text: 'LatuAa81');
|
||||
final _apiVersionController = TextEditingController(text: '1.53');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final config = TitleServerConfigHolder().config;
|
||||
if (config != null) {
|
||||
_urlController.text = config.titleServerUrl;
|
||||
_aesKeyController.text = config.aesKey;
|
||||
_aesIvController.text = config.aesIv;
|
||||
_clientIdController.text = config.clientId;
|
||||
_regionIdController.text = '${config.regionId}';
|
||||
_placeIdController.text = '${config.placeId}';
|
||||
_obfuscateController.text = config.obfuscateParam;
|
||||
_apiVersionController.text = config.apiVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_aesKeyController.dispose();
|
||||
_aesIvController.dispose();
|
||||
_clientIdController.dispose();
|
||||
_regionIdController.dispose();
|
||||
_placeIdController.dispose();
|
||||
_obfuscateController.dispose();
|
||||
_apiVersionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
await TitleServerConfigHolder().update(TitleServerConfig(
|
||||
titleServerUrl: _urlController.text.trim(),
|
||||
aesKey: _aesKeyController.text.trim(),
|
||||
aesIv: _aesIvController.text.trim(),
|
||||
clientId: _clientIdController.text.trim(),
|
||||
regionId: int.tryParse(_regionIdController.text.trim()) ?? 1,
|
||||
placeId: int.tryParse(_placeIdController.text.trim()) ?? 1403,
|
||||
obfuscateParam: _obfuscateController.text.trim(),
|
||||
apiVersion: _apiVersionController.text.trim(),
|
||||
));
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: widget.showAppBar
|
||||
? AppBar(
|
||||
title: const Text(AppStrings.titleServerSettings),
|
||||
)
|
||||
: null,
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildSection(theme, AppStrings.required, [
|
||||
_buildField(
|
||||
controller: _urlController,
|
||||
label: AppStrings.labelTitleServerUrl,
|
||||
hint: AppStrings.hintTitleServerUrl,
|
||||
required: true,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(
|
||||
controller: _aesKeyController,
|
||||
label: AppStrings.labelAesKey,
|
||||
hint: AppStrings.hintAesKey,
|
||||
required: true,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(
|
||||
controller: _aesIvController,
|
||||
label: AppStrings.labelAesIv,
|
||||
hint: AppStrings.hintAesIv,
|
||||
required: true,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(
|
||||
controller: _clientIdController,
|
||||
label: AppStrings.labelClientId,
|
||||
hint: AppStrings.hintClientId,
|
||||
required: true,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection(theme, AppStrings.optional, [
|
||||
_buildField(
|
||||
controller: _regionIdController,
|
||||
label: AppStrings.labelRegionId,
|
||||
hint: AppStrings.hintRegionId,
|
||||
required: false,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(
|
||||
controller: _placeIdController,
|
||||
label: AppStrings.labelPlaceId,
|
||||
hint: AppStrings.hintPlaceId,
|
||||
required: false,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(
|
||||
controller: _obfuscateController,
|
||||
label: AppStrings.labelObfuscateParam,
|
||||
hint: AppStrings.hintObfuscateParam,
|
||||
required: false,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(
|
||||
controller: _apiVersionController,
|
||||
label: AppStrings.labelApiVersion,
|
||||
hint: AppStrings.hintApiVersion,
|
||||
required: false,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save, size: 20),
|
||||
label: const Text(AppStrings.save),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(ThemeData theme, String title, List<Widget> children) {
|
||||
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(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required bool required,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
validator: required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? AppStrings.fieldRequired(label) : null
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
631
lib/pages/ticket_page.dart
Normal file
631
lib/pages/ticket_page.dart
Normal file
@@ -0,0 +1,631 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/strings.dart';
|
||||
import '../config/title_server_config.dart';
|
||||
import '../services/title_api_service.dart';
|
||||
|
||||
enum TicketStep {
|
||||
idle,
|
||||
checkPreview,
|
||||
userLogin,
|
||||
fetchData,
|
||||
simulatePlay,
|
||||
uploadPlaylog,
|
||||
upsertAll,
|
||||
logout,
|
||||
complete,
|
||||
failed,
|
||||
}
|
||||
|
||||
class TicketPage extends StatefulWidget {
|
||||
final int userId;
|
||||
final String token;
|
||||
|
||||
const TicketPage({super.key, required this.userId, required this.token});
|
||||
|
||||
@override
|
||||
State<TicketPage> createState() => _TicketPageState();
|
||||
}
|
||||
|
||||
class _TicketPageState extends State<TicketPage> {
|
||||
static const _ticketNameMap = {
|
||||
1: '?',
|
||||
2: '2倍票',
|
||||
3: '3倍票',
|
||||
4: '4倍票',
|
||||
5: '5倍票',
|
||||
6: '6倍票',
|
||||
7: '?',
|
||||
8: '?',
|
||||
9: '?',
|
||||
10: '?',
|
||||
11: '?',
|
||||
12: '?',
|
||||
13: '?',
|
||||
14: '?',
|
||||
15: '?',
|
||||
16: '?',
|
||||
};
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _musicIdController = TextEditingController(text: '417');
|
||||
final _levelController = TextEditingController(text: '3');
|
||||
final _achievementController = TextEditingController(text: '1010000');
|
||||
final _comboStatusController = TextEditingController(text: '4');
|
||||
final _syncStatusController = TextEditingController(text: '4');
|
||||
final _deluxscoreMaxController = TextEditingController(text: '2277');
|
||||
final _scoreRankController = TextEditingController(text: '13');
|
||||
|
||||
TicketStep _step = TicketStep.idle;
|
||||
String _stepMessage = '';
|
||||
String? _error;
|
||||
bool _running = false;
|
||||
|
||||
List<Map<String, dynamic>>? _tickets;
|
||||
bool _ticketsLoading = false;
|
||||
String? _ticketsError;
|
||||
|
||||
String _ticketName(int chargeId) =>
|
||||
_ticketNameMap[chargeId] ?? '票$chargeId';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_musicIdController.dispose();
|
||||
_levelController.dispose();
|
||||
_achievementController.dispose();
|
||||
_comboStatusController.dispose();
|
||||
_syncStatusController.dispose();
|
||||
_deluxscoreMaxController.dispose();
|
||||
_scoreRankController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildMusicData() {
|
||||
return {
|
||||
'musicId': int.tryParse(_musicIdController.text) ?? 417,
|
||||
'level': int.tryParse(_levelController.text) ?? 3,
|
||||
'achievement': int.tryParse(_achievementController.text) ?? 1010000,
|
||||
'comboStatus': int.tryParse(_comboStatusController.text) ?? 4,
|
||||
'syncStatus': int.tryParse(_syncStatusController.text) ?? 4,
|
||||
'deluxscoreMax': int.tryParse(_deluxscoreMaxController.text) ?? 2277,
|
||||
'scoreRank': int.tryParse(_scoreRankController.text) ?? 13,
|
||||
'extNum1': 0,
|
||||
'playCount': 1,
|
||||
};
|
||||
}
|
||||
|
||||
void _updateStep(TicketStep step, [String? message]) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_step = step;
|
||||
_stepMessage = message ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadTickets() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) return;
|
||||
|
||||
setState(() {
|
||||
_ticketsLoading = true;
|
||||
_ticketsError = null;
|
||||
_tickets = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config);
|
||||
final data = await service.getUserCharge(widget.userId);
|
||||
if (!mounted) return;
|
||||
|
||||
final rawList = data['userChargeList'] as List<dynamic>? ?? [];
|
||||
|
||||
setState(() {
|
||||
_tickets = rawList.map((e) => e as Map<String, dynamic>).toList();
|
||||
_ticketsLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_ticketsError = e.toString();
|
||||
_ticketsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runTicket() async {
|
||||
if (!TitleServerConfigHolder().isConfigured) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final config = TitleServerConfigHolder().config!;
|
||||
final service = TitleApiService(config);
|
||||
final musicData = _buildMusicData();
|
||||
final userId = widget.userId;
|
||||
final token = widget.token;
|
||||
|
||||
setState(() {
|
||||
_running = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: Check preview
|
||||
_updateStep(TicketStep.checkPreview);
|
||||
final preview = await service.getUserPreview(userId: userId, token: token);
|
||||
if (preview.isLogin) {
|
||||
throw TitleApiException(AppStrings.ticketErrorAlreadyLogin);
|
||||
}
|
||||
|
||||
// Step 2: Login
|
||||
_updateStep(TicketStep.userLogin);
|
||||
final loginResult = await service.userLoginFull(userId: userId, token: token);
|
||||
|
||||
// Step 3: Fetch user data
|
||||
_updateStep(TicketStep.fetchData);
|
||||
final results = await Future.wait([
|
||||
service.getUserData(userId),
|
||||
service.getUserExtend(userId),
|
||||
service.getUserOption(userId),
|
||||
service.getUserRating(userId),
|
||||
service.getUserCharge(userId),
|
||||
service.getUserActivity(userId),
|
||||
service.getUserMissionData(userId),
|
||||
]);
|
||||
|
||||
// Step 4: Simulate play time
|
||||
_updateStep(TicketStep.simulatePlay);
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Step 5: Upload playlog
|
||||
_updateStep(TicketStep.uploadPlaylog);
|
||||
await service.uploadUserPlaylog(
|
||||
userId: userId,
|
||||
loginId: loginResult.loginId,
|
||||
musicData: musicData,
|
||||
userData: results[0],
|
||||
);
|
||||
|
||||
// Step 6: Upsert all
|
||||
_updateStep(TicketStep.upsertAll);
|
||||
await service.upsertUserAll(
|
||||
userId: userId,
|
||||
loginId: loginResult.loginId,
|
||||
loginDate: loginResult.lastLoginDate,
|
||||
musicData: musicData,
|
||||
generalUserInfo: results,
|
||||
);
|
||||
|
||||
// Step 7: Logout
|
||||
_updateStep(TicketStep.logout);
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await service.userLogout(userId: userId, loginDateTime: now);
|
||||
|
||||
_updateStep(TicketStep.complete, AppStrings.ticketLoginInfo(loginResult.loginId));
|
||||
} on TitleApiException catch (e) {
|
||||
_updateStep(TicketStep.failed, e.message);
|
||||
setState(() => _error = e.message);
|
||||
} catch (e) {
|
||||
_updateStep(TicketStep.failed, e.toString());
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
@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.ticketNotConfigured, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTicketListCard(theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildMusicForm(theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildRunButton(theme),
|
||||
if (_step != TicketStep.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildProgressCard(theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTicketListCard(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: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppStrings.myTickets,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _ticketsLoading ? null : _loadTickets,
|
||||
icon: _ticketsLoading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(
|
||||
_ticketsLoading ? AppStrings.loading : AppStrings.refreshTickets,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_ticketsError != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_ticketsError!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_tickets == null)
|
||||
Text(
|
||||
AppStrings.ticketsNotLoaded,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else if (_tickets!.isEmpty)
|
||||
Text(
|
||||
AppStrings.noTickets,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
..._tickets!.map((t) => _ticketRow(theme, t)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ticketRow(ThemeData theme, Map<String, dynamic> ticket) {
|
||||
final chargeId = (ticket['chargeId'] as num?)?.toInt() ?? 0;
|
||||
final stock = (ticket['stock'] as num?)?.toInt() ?? 0;
|
||||
final validDate = ticket['validDate'] as String? ?? '-';
|
||||
final name = _ticketName(chargeId);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$chargeId',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'有效期: $validDate',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: stock > 0 ? Colors.green.withValues(alpha: 0.15) : Colors.grey.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'剩余 $stock',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: stock > 0 ? Colors.green : Colors.grey,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMusicForm(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: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppStrings.musicConfig,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildField(_musicIdController, AppStrings.musicId, theme),
|
||||
_buildField(_levelController, AppStrings.level, theme),
|
||||
_buildField(_achievementController, AppStrings.achievement, theme),
|
||||
_buildField(_comboStatusController, AppStrings.comboStatus, theme),
|
||||
_buildField(_syncStatusController, AppStrings.syncStatus, theme),
|
||||
_buildField(_deluxscoreMaxController, AppStrings.deluxscoreMax, theme),
|
||||
_buildField(_scoreRankController, AppStrings.scoreRank, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField(
|
||||
TextEditingController controller,
|
||||
String label,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
enabled: !_running,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunButton(ThemeData theme) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running ? null : _runTicket,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.play_arrow, size: 20),
|
||||
label: Text(_running ? AppStrings.runningTicket : AppStrings.runTicket),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard(ThemeData theme) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: _step == TicketStep.failed
|
||||
? theme.colorScheme.error.withValues(alpha: 0.5)
|
||||
: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_step == TicketStep.failed ? AppStrings.stepFailed : AppStrings.ticketsTitle,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: _step == TicketStep.failed
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_stepRow(TicketStep.checkPreview, AppStrings.stepCheckPreview, theme),
|
||||
_stepRow(TicketStep.userLogin, AppStrings.stepUserLogin, theme),
|
||||
_stepRow(TicketStep.fetchData, AppStrings.stepFetchData, theme),
|
||||
_stepRow(TicketStep.simulatePlay, AppStrings.stepSimulatePlay, theme),
|
||||
_stepRow(TicketStep.uploadPlaylog, AppStrings.stepUploadPlaylog, theme),
|
||||
_stepRow(TicketStep.upsertAll, AppStrings.stepUpsertAll, theme),
|
||||
_stepRow(TicketStep.logout, AppStrings.stepLogout, theme),
|
||||
if (_stepMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: _step == TicketStep.failed
|
||||
? theme.colorScheme.errorContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_stepMessage,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: _step == TicketStep.failed
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(TicketStep step, String label, ThemeData theme) {
|
||||
IconData icon;
|
||||
Color? color;
|
||||
|
||||
if (_step == TicketStep.failed && _step.index > step.index) {
|
||||
icon = step.index < _step.index ? Icons.check_circle_outline : Icons.circle_outlined;
|
||||
color = step.index < _step.index ? Colors.green : theme.colorScheme.onSurfaceVariant;
|
||||
} else if (_step.index > step.index) {
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
} else if (_step == step) {
|
||||
icon = Icons.sync;
|
||||
color = theme.colorScheme.primary;
|
||||
} else {
|
||||
icon = Icons.circle_outlined;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _step.index >= step.index
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: _step == step ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/services/api_service.dart
Normal file
88
lib/services/api_service.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class LoginResult {
|
||||
final bool success;
|
||||
final int errorId;
|
||||
final int userId;
|
||||
final String token;
|
||||
|
||||
const LoginResult({
|
||||
required this.success,
|
||||
required this.errorId,
|
||||
required this.userId,
|
||||
required this.token,
|
||||
});
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
static const String chimeSalt = 'XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW';
|
||||
static const String baseUrl = 'http://ai.sys-allnet.cn';
|
||||
|
||||
String chipId;
|
||||
|
||||
ApiService({this.chipId = 'A63E-01C28055905'});
|
||||
|
||||
String _formatTimestamp() {
|
||||
final tokyo = DateTime.now().toUtc().add(const Duration(hours: 9));
|
||||
final y = (tokyo.year % 100).toString().padLeft(2, '0');
|
||||
final M = tokyo.month.toString().padLeft(2, '0');
|
||||
final d = tokyo.day.toString().padLeft(2, '0');
|
||||
final h = tokyo.hour.toString().padLeft(2, '0');
|
||||
final m = tokyo.minute.toString().padLeft(2, '0');
|
||||
final s = tokyo.second.toString().padLeft(2, '0');
|
||||
return '$y$M$d$h$m$s';
|
||||
}
|
||||
|
||||
String _sha256(String input) {
|
||||
final bytes = utf8.encode(input);
|
||||
return sha256.convert(bytes).toString();
|
||||
}
|
||||
|
||||
String extractQRCode(String qrCodeToken) {
|
||||
if (qrCodeToken.length > 64) {
|
||||
return qrCodeToken.substring(qrCodeToken.length - 64);
|
||||
}
|
||||
return qrCodeToken;
|
||||
}
|
||||
|
||||
Future<LoginResult> login(String qrCodeToken) async {
|
||||
final timestamp = _formatTimestamp();
|
||||
final qrCode = extractQRCode(qrCodeToken);
|
||||
final rawKey = chipId + timestamp + chimeSalt;
|
||||
final key = _sha256(rawKey).toUpperCase();
|
||||
|
||||
final body = jsonEncode({
|
||||
'chipID': chipId,
|
||||
'openGameID': 'MAID',
|
||||
'key': key,
|
||||
'qrCode': qrCode,
|
||||
'timestamp': timestamp,
|
||||
});
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/wc_aime/api/get_data'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'WC_AIME_LIB',
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
final obj = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final errorId = obj['errorID'] as int? ?? -1;
|
||||
final userId = obj['userID'] as int? ?? -1;
|
||||
final token = obj['token'] as String? ?? '';
|
||||
|
||||
return LoginResult(
|
||||
success: errorId == 0,
|
||||
errorId: errorId,
|
||||
userId: userId,
|
||||
token: token,
|
||||
);
|
||||
}
|
||||
}
|
||||
7
lib/services/file_picker_service.dart
Normal file
7
lib/services/file_picker_service.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'file_picker_service_native.dart'
|
||||
if (dart.library.html) 'file_picker_service_web.dart'
|
||||
as impl;
|
||||
|
||||
Future<Uint8List?> pickImageBytes() => impl.pickImageBytes();
|
||||
10
lib/services/file_picker_service_native.dart
Normal file
10
lib/services/file_picker_service_native.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
Future<Uint8List?> pickImageBytes() async {
|
||||
final picker = ImagePicker();
|
||||
final file = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (file == null) return null;
|
||||
return await file.readAsBytes();
|
||||
}
|
||||
44
lib/services/file_picker_service_web.dart
Normal file
44
lib/services/file_picker_service_web.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'dart:async';
|
||||
import 'dart:html';
|
||||
import 'dart:typed_data';
|
||||
|
||||
Future<Uint8List?> pickImageBytes() async {
|
||||
final completer = Completer<Uint8List?>();
|
||||
|
||||
final input = FileUploadInputElement()
|
||||
..accept = 'image/*'
|
||||
..click();
|
||||
|
||||
// Detect cancellation: when user closes the file dialog without picking,
|
||||
// onChange won't fire, but window.onFocus will when focus returns.
|
||||
StreamSubscription<Event>? focusSub;
|
||||
focusSub = window.onFocus.listen((_) {
|
||||
focusSub?.cancel();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
input.onChange.listen((_) {
|
||||
focusSub?.cancel();
|
||||
|
||||
final file = input.files?.first;
|
||||
if (file == null) {
|
||||
completer.complete(null);
|
||||
return;
|
||||
}
|
||||
|
||||
final reader = FileReader();
|
||||
reader.onLoad.listen((_) {
|
||||
completer.complete(reader.result as Uint8List);
|
||||
});
|
||||
|
||||
reader.onError.listen((_) {
|
||||
completer.complete(null);
|
||||
});
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
8
lib/services/qr_service.dart
Normal file
8
lib/services/qr_service.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'qr_service_native.dart'
|
||||
if (dart.library.html) 'qr_service_web.dart'
|
||||
as impl;
|
||||
|
||||
Future<String?> decodeQRFromBytes(Uint8List bytes) =>
|
||||
impl.decodeQRFromBytes(bytes);
|
||||
12
lib/services/qr_service_native.dart
Normal file
12
lib/services/qr_service_native.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const _channel = MethodChannel('com.rapollo/qr_scanner');
|
||||
|
||||
Future<String?> decodeQRFromBytes(Uint8List bytes) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('decodeQRFromBytes', bytes);
|
||||
return result;
|
||||
} on PlatformException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
60
lib/services/qr_service_web.dart
Normal file
60
lib/services/qr_service_web.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:async';
|
||||
import 'dart:html';
|
||||
import 'dart:js' as js;
|
||||
import 'dart:typed_data';
|
||||
|
||||
Future<String?> decodeQRFromBytes(Uint8List bytes) async {
|
||||
final completer = Completer<String?>();
|
||||
|
||||
final blob = Blob([bytes]);
|
||||
final url = Url.createObjectUrlFromBlob(blob);
|
||||
|
||||
final img = ImageElement()..src = url;
|
||||
|
||||
img.onLoad.listen((_) {
|
||||
try {
|
||||
final canvas = CanvasElement(
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
);
|
||||
final ctx = canvas.context2D;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
final imageData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
);
|
||||
|
||||
if (!js.context.hasProperty('jsQR')) {
|
||||
Url.revokeObjectUrl(url);
|
||||
completer.completeError('jsQR library not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
final result = js.context.callMethod('jsQR', [
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
]);
|
||||
|
||||
Url.revokeObjectUrl(url);
|
||||
|
||||
if (result != null) {
|
||||
completer.complete(result['data'] as String?);
|
||||
} else {
|
||||
completer.complete(null);
|
||||
}
|
||||
} catch (e) {
|
||||
Url.revokeObjectUrl(url);
|
||||
completer.completeError(e);
|
||||
}
|
||||
});
|
||||
|
||||
img.onError.listen((_) {
|
||||
Url.revokeObjectUrl(url);
|
||||
completer.complete(null);
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
805
lib/services/title_api_service.dart
Normal file
805
lib/services/title_api_service.dart
Normal file
@@ -0,0 +1,805 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
import '../config/title_server_config.dart';
|
||||
import '../models/user_preview.dart';
|
||||
|
||||
class TitleApiException implements Exception {
|
||||
final String message;
|
||||
const TitleApiException(this.message);
|
||||
@override
|
||||
String toString() => 'TitleApiException: $message';
|
||||
}
|
||||
|
||||
class UserLoginResult {
|
||||
final String token;
|
||||
final int loginId;
|
||||
final String lastLoginDate;
|
||||
|
||||
const UserLoginResult({
|
||||
required this.token,
|
||||
required this.loginId,
|
||||
required this.lastLoginDate,
|
||||
});
|
||||
}
|
||||
|
||||
class TitleApiService {
|
||||
static const String _obfuscateConstant = 'MaimaiChn';
|
||||
|
||||
static String? lastRawResponse;
|
||||
|
||||
final TitleServerConfig _config;
|
||||
|
||||
TitleApiService(this._config);
|
||||
|
||||
Uint8List _aesEncrypt(Uint8List plaintext) {
|
||||
final cipher = PaddedBlockCipherImpl(
|
||||
PKCS7Padding(),
|
||||
CBCBlockCipher(AESEngine()),
|
||||
)..init(
|
||||
true,
|
||||
PaddedBlockCipherParameters(
|
||||
ParametersWithIV(
|
||||
KeyParameter(Uint8List.fromList(_config.aesKeyBytes)),
|
||||
Uint8List.fromList(_config.aesIvBytes),
|
||||
),
|
||||
null,
|
||||
),
|
||||
);
|
||||
return cipher.process(plaintext);
|
||||
}
|
||||
|
||||
Uint8List _aesDecrypt(Uint8List ciphertext) {
|
||||
final cipher = PaddedBlockCipherImpl(
|
||||
PKCS7Padding(),
|
||||
CBCBlockCipher(AESEngine()),
|
||||
)..init(
|
||||
false,
|
||||
PaddedBlockCipherParameters(
|
||||
ParametersWithIV(
|
||||
KeyParameter(Uint8List.fromList(_config.aesKeyBytes)),
|
||||
Uint8List.fromList(_config.aesIvBytes),
|
||||
),
|
||||
null,
|
||||
),
|
||||
);
|
||||
return cipher.process(ciphertext);
|
||||
}
|
||||
|
||||
Uint8List _compress(List<int> data) {
|
||||
return Uint8List.fromList(ZLibEncoder().encode(data));
|
||||
}
|
||||
|
||||
Uint8List _decompress(List<int> data) {
|
||||
return Uint8List.fromList(ZLibDecoder().decodeBytes(data));
|
||||
}
|
||||
|
||||
Uint8List _buildRequestBody(Map<String, dynamic> packet) {
|
||||
final jsonStr = jsonEncode(packet);
|
||||
final jsonBytes = utf8.encode(jsonStr);
|
||||
final compressed = _compress(jsonBytes);
|
||||
return _aesEncrypt(compressed);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _processResponseBody(Uint8List bodyBytes) {
|
||||
final decrypted = _aesDecrypt(bodyBytes);
|
||||
final decompressed = _decompress(decrypted);
|
||||
final jsonStr = utf8.decode(decompressed);
|
||||
return jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
String _buildHash(String apiName) {
|
||||
final raw = apiName + _obfuscateConstant + _config.obfuscateParam;
|
||||
return md5.convert(utf8.encode(raw)).toString();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _callApi(
|
||||
String apiName,
|
||||
Map<String, dynamic> packet,
|
||||
int userId,
|
||||
) async {
|
||||
final hash = _buildHash(apiName);
|
||||
final body = _buildRequestBody(packet);
|
||||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||||
final url = Uri.parse('$baseUrl/$hash');
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: {
|
||||
'User-Agent': '$hash#$userId',
|
||||
'Content-Type': 'application/json',
|
||||
'Mai-Encoding': _config.apiVersion,
|
||||
'Accept-Encoding': '',
|
||||
'Charset': 'UTF-8',
|
||||
'Content-Encoding': 'deflate',
|
||||
'Host': 'maimai-gm.wahlap.com:42081',
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw TitleApiException('$apiName returned ${response.statusCode}');
|
||||
}
|
||||
|
||||
final json = _processResponseBody(response.bodyBytes);
|
||||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||||
// ignore: avoid_print
|
||||
print('[$apiName] === RESPONSE ===');
|
||||
// ignore: avoid_print
|
||||
print(raw);
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
static int calcRandom() {
|
||||
final rand = _RandomHelper();
|
||||
final max = 1037933;
|
||||
final num2 = (rand.nextInt(max - 1) + 1) * 2069 + 1024;
|
||||
var num3 = 0;
|
||||
var n = num2;
|
||||
for (var i = 0; i < 32; i++) {
|
||||
num3 <<= 1;
|
||||
num3 += n % 2;
|
||||
n >>= 1;
|
||||
}
|
||||
return num3;
|
||||
}
|
||||
|
||||
String _normalizeUrl(String url) {
|
||||
var normalized = url.trim();
|
||||
if (normalized.endsWith('/')) {
|
||||
normalized = normalized.substring(0, normalized.length - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Future<String> userLogin({
|
||||
required int userId,
|
||||
required String token,
|
||||
}) async {
|
||||
const apiName = 'UserLoginApi';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final hash = _buildHash(apiName);
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'accessCode': '',
|
||||
'regionId': _config.regionId,
|
||||
'placeId': _config.placeId,
|
||||
'clientId': _config.clientId,
|
||||
'dateTime': now - 600,
|
||||
'loginDateTime': now,
|
||||
'isContinue': false,
|
||||
'genericFlag': 0,
|
||||
'token': token,
|
||||
};
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[userLogin] packet=${jsonEncode(packet)}');
|
||||
final body = _buildRequestBody(packet);
|
||||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||||
final url = Uri.parse('$baseUrl/$hash');
|
||||
// ignore: avoid_print
|
||||
print('[userLogin] POST $url');
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: {
|
||||
'User-Agent': '$hash#$userId',
|
||||
'Content-Type': 'application/json',
|
||||
'Mai-Encoding': _config.apiVersion,
|
||||
'Accept-Encoding': '',
|
||||
'Charset': 'UTF-8',
|
||||
'Content-Encoding': 'deflate',
|
||||
'Host': 'maimai-gm.wahlap.com:42081',
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[userLogin] HTTP status=${response.statusCode}');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw TitleApiException(
|
||||
'UserLoginApi returned ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
final json = _processResponseBody(response.bodyBytes);
|
||||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||||
// ignore: avoid_print
|
||||
print('[userLogin] === RESPONSE ===');
|
||||
// ignore: avoid_print
|
||||
print(raw);
|
||||
|
||||
final errorId = json['errorId'] as int? ?? -1;
|
||||
if (errorId != 0) {
|
||||
throw TitleApiException('UserLoginApi errorId=$errorId');
|
||||
}
|
||||
|
||||
final newToken = json['token'] as String?;
|
||||
if (newToken == null || newToken.isEmpty) {
|
||||
throw TitleApiException('UserLoginApi returned no token');
|
||||
}
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
Future<UserPreviewDataBean> getUserPreview({
|
||||
required int userId,
|
||||
required String token,
|
||||
}) async {
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] ===== START =====');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] userId=$userId');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] token=$token');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] clientId=${_config.clientId}');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] titleServerUrl=${_config.titleServerUrl}');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] aesKey.len=${_config.aesKeyBytes.length} aesIv.len=${_config.aesIvBytes.length}');
|
||||
|
||||
const apiName = 'GetUserPreviewApi';
|
||||
final hash = _buildHash(apiName);
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] hash=$hash');
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'segaIdAuthKey': '',
|
||||
'token': token,
|
||||
'clientId': _config.clientId,
|
||||
};
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] packet=${jsonEncode(packet)}');
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] building request body (compress + encrypt)...');
|
||||
final body = _buildRequestBody(packet);
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] body size=${body.length} bytes');
|
||||
|
||||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||||
final url = Uri.parse('$baseUrl/$hash');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] POST $url');
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
url,
|
||||
headers: {
|
||||
'User-Agent': '$hash#$userId',
|
||||
'Content-Type': 'application/json',
|
||||
'Mai-Encoding': _config.apiVersion,
|
||||
'Accept-Encoding': '',
|
||||
'Charset': 'UTF-8',
|
||||
'Content-Encoding': 'deflate',
|
||||
"Host": "maimai-gm.wahlap.com:42081"
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] HTTP status=${response.statusCode}');
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] response body size=${response.bodyBytes.length} bytes');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] non-200 response body: ${utf8.decode(response.bodyBytes.take(500).toList())}');
|
||||
throw TitleApiException(
|
||||
'Server returned ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] decrypting + decompressing...');
|
||||
final json = _processResponseBody(response.bodyBytes);
|
||||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||||
lastRawResponse = raw;
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] === RESPONSE ===');
|
||||
// ignore: avoid_print
|
||||
print(raw);
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] ===== END =====');
|
||||
return UserPreviewDataBean.fromJson(json);
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[getUserPreview] ERROR: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Generic data APIs ----
|
||||
|
||||
Future<Map<String, dynamic>> getUserData(int userId) async {
|
||||
return _callApi('GetUserDataApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getUserExtend(int userId) async {
|
||||
return _callApi('GetUserExtendApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getUserOption(int userId) async {
|
||||
return _callApi('GetUserOptionApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getUserRating(int userId) async {
|
||||
return _callApi('GetUserRatingApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getUserCharge(int userId) async {
|
||||
return _callApi('GetUserChargeApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getUserActivity(int userId) async {
|
||||
return _callApi('GetUserActivityApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getUserMissionData(int userId) async {
|
||||
return _callApi('GetUserMissionDataApi', {'userId': userId}, userId);
|
||||
}
|
||||
|
||||
// ---- UserLogin (returns full result) ----
|
||||
|
||||
Future<UserLoginResult> userLoginFull({
|
||||
required int userId,
|
||||
required String token,
|
||||
}) async {
|
||||
const apiName = 'UserLoginApi';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'accessCode': '',
|
||||
'regionId': _config.regionId,
|
||||
'placeId': _config.placeId,
|
||||
'clientId': _config.clientId,
|
||||
'dateTime': now - 600,
|
||||
'loginDateTime': now,
|
||||
'isContinue': false,
|
||||
'genericFlag': 0,
|
||||
'token': token,
|
||||
};
|
||||
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
|
||||
final errorId = json['errorId'] as int? ?? -1;
|
||||
if (errorId != 0) {
|
||||
throw TitleApiException('UserLoginApi errorId=$errorId');
|
||||
}
|
||||
|
||||
final loginId = (json['loginId'] as num?)?.toInt() ?? 0;
|
||||
final lastLoginDate = json['lastLoginDate'] as String? ?? '';
|
||||
final newToken = json['token'] as String? ?? '';
|
||||
|
||||
return UserLoginResult(
|
||||
token: newToken,
|
||||
loginId: loginId,
|
||||
lastLoginDate: lastLoginDate,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- UserLogout ----
|
||||
|
||||
Future<void> userLogout({
|
||||
required int userId,
|
||||
required int loginDateTime,
|
||||
}) async {
|
||||
const apiName = 'UserLogoutApi';
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'accessCode': '',
|
||||
'regionId': _config.regionId,
|
||||
'placeId': _config.placeId,
|
||||
'clientId': _config.clientId,
|
||||
'loginDateTime': loginDateTime,
|
||||
'type': 1,
|
||||
};
|
||||
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
final errorId = json['errorId'] as int? ?? -1;
|
||||
if (errorId != 0) {
|
||||
throw TitleApiException('UserLogoutApi errorId=$errorId');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UploadUserPlaylog ----
|
||||
|
||||
Future<void> uploadUserPlaylog({
|
||||
required int userId,
|
||||
required int loginId,
|
||||
required Map<String, dynamic> musicData,
|
||||
required Map<String, dynamic> userData,
|
||||
}) async {
|
||||
const apiName = 'UploadUserPlaylogListApi';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'userPlaylogList': [
|
||||
_buildPlaylogEntry(userId, loginId, now, musicData, userData),
|
||||
],
|
||||
};
|
||||
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
final errorId = json['errorId'] as int? ?? -1;
|
||||
if (errorId != 0) {
|
||||
throw TitleApiException('UploadUserPlaylogListApi errorId=$errorId');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPlaylogEntry(
|
||||
int userId,
|
||||
int loginId,
|
||||
int timestamp,
|
||||
Map<String, dynamic> musicData,
|
||||
Map<String, dynamic> userData,
|
||||
) {
|
||||
final user = userData['userData'] as Map<String, dynamic>? ?? {};
|
||||
final charaSlot = (user['charaSlot'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList() ??
|
||||
[0, 0, 0, 0, 0];
|
||||
|
||||
return {
|
||||
'userId': userId,
|
||||
'orderId': 0,
|
||||
'playlogId': loginId,
|
||||
'version': 1053000,
|
||||
'placeId': _config.placeId,
|
||||
'placeName': '',
|
||||
'loginDate': timestamp,
|
||||
'playDate': _formatDate(DateTime.now()),
|
||||
'userPlayDate': _formatDateTime(DateTime.now()),
|
||||
'type': 0,
|
||||
'musicId': musicData['musicId'],
|
||||
'level': musicData['level'],
|
||||
'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': musicData['achievement'],
|
||||
'deluxscore': musicData['deluxscoreMax'],
|
||||
'scoreRank': musicData['scoreRank'],
|
||||
'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': musicData['comboStatus'],
|
||||
'syncStatus': musicData['syncStatus'],
|
||||
'isClear': true,
|
||||
'beforeRating': user['playerRating'] ?? 0,
|
||||
'afterRating': user['playerRating'] ?? 0,
|
||||
'beforeGrade': 0,
|
||||
'afterGrade': 0,
|
||||
'afterGradeRank': 0,
|
||||
'beforeDeluxRating': user['playerRating'] ?? 0,
|
||||
'afterDeluxRating': user['playerRating'] ?? 0,
|
||||
'isPlayTutorial': false,
|
||||
'isEventMode': false,
|
||||
'isFreedomMode': false,
|
||||
'playMode': 0,
|
||||
'isNewFree': false,
|
||||
'trialPlayAchievement': -1,
|
||||
'extNum1': musicData['extNum1'] ?? 0,
|
||||
'extNum2': 0,
|
||||
'extNum4': 101,
|
||||
'extBool1': false,
|
||||
'extBool2': false,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- UpsertUserAll ----
|
||||
|
||||
Future<void> upsertUserAll({
|
||||
required int userId,
|
||||
required int loginId,
|
||||
required String loginDate,
|
||||
required Map<String, dynamic> musicData,
|
||||
required List<Map<String, dynamic>> generalUserInfo,
|
||||
}) async {
|
||||
const apiName = 'UpsertUserAllApi';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final userData = generalUserInfo[0];
|
||||
final userExtend = generalUserInfo[1];
|
||||
final userOption = generalUserInfo[2];
|
||||
final userRating = generalUserInfo[3];
|
||||
final userChargeList = generalUserInfo[4];
|
||||
final userActivity = generalUserInfo[5];
|
||||
final userMissionData = generalUserInfo[6];
|
||||
|
||||
final user = userData['userData'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
final packet = {
|
||||
'userId': userId,
|
||||
'playlogId': loginId,
|
||||
'isEventMode': false,
|
||||
'isFreePlay': false,
|
||||
'loginDateTime': now,
|
||||
'userPlaylogList': [
|
||||
_buildPlaylogEntry(userId, loginId, now, musicData, userData),
|
||||
],
|
||||
'upsertUserAll': {
|
||||
'userData': [
|
||||
{
|
||||
'accessCode': '',
|
||||
'userName': user['userName'] ?? '',
|
||||
'isNetMember': 1,
|
||||
'point': user['point'] ?? 0,
|
||||
'totalPoint': user['totalPoint'] ?? 0,
|
||||
'iconId': user['iconId'] ?? 0,
|
||||
'plateId': user['plateId'] ?? 0,
|
||||
'titleId': user['titleId'] ?? 0,
|
||||
'partnerId': user['partnerId'] ?? 0,
|
||||
'frameId': user['frameId'] ?? 0,
|
||||
'selectMapId': user['selectMapId'] ?? 0,
|
||||
'totalAwake': user['totalAwake'] ?? 0,
|
||||
'gradeRating': user['gradeRating'] ?? 0,
|
||||
'musicRating': user['musicRating'] ?? 0,
|
||||
'playerRating': user['playerRating'] ?? 0,
|
||||
'highestRating': user['highestRating'] ?? 0,
|
||||
'gradeRank': user['gradeRank'] ?? 0,
|
||||
'classRank': user['classRank'] ?? 0,
|
||||
'courseRank': user['courseRank'] ?? 0,
|
||||
'charaSlot': user['charaSlot'] ?? [0, 0, 0, 0, 0],
|
||||
'charaLockSlot': user['charaLockSlot'] ?? [0, 0, 0, 0, 0],
|
||||
'contentBit': user['contentBit'] ?? 0,
|
||||
'playCount': ((user['playCount'] as num?)?.toInt() ?? 0) + 1,
|
||||
'currentPlayCount': ((user['currentPlayCount'] as num?)?.toInt() ?? 0) + 1,
|
||||
'renameCredit': user['renameCredit'] ?? 0,
|
||||
'mapStock': user['mapStock'] ?? 0,
|
||||
'eventWatchedDate': user['eventWatchedDate'] ?? '',
|
||||
'lastGameId': 'SDGB',
|
||||
'lastRomVersion': user['lastRomVersion'] ?? '',
|
||||
'lastDataVersion': user['lastDataVersion'] ?? '',
|
||||
'lastLoginDate': loginDate,
|
||||
'lastPlayDate': _formatDateTime(DateTime.now()),
|
||||
'lastPlayCredit': 1,
|
||||
'lastPlayMode': 0,
|
||||
'lastPlaceId': _config.placeId,
|
||||
'lastPlaceName': '',
|
||||
'lastAllNetId': 0,
|
||||
'lastRegionId': _config.regionId,
|
||||
'lastRegionName': '',
|
||||
'lastClientId': _config.clientId,
|
||||
'lastCountryCode': 'CHN',
|
||||
'lastSelectEMoney': user['lastSelectEMoney'] ?? 0,
|
||||
'lastSelectTicket': user['lastSelectTicket'] ?? 0,
|
||||
'lastSelectCourse': user['lastSelectCourse'] ?? 0,
|
||||
'lastCountCourse': user['lastCountCourse'] ?? 0,
|
||||
'firstGameId': user['firstGameId'] ?? '',
|
||||
'firstRomVersion': user['firstRomVersion'] ?? '',
|
||||
'firstDataVersion': user['firstDataVersion'] ?? '',
|
||||
'firstPlayDate': user['firstPlayDate'] ?? '',
|
||||
'compatibleCmVersion': user['compatibleCmVersion'] ?? '',
|
||||
'dailyBonusDate': user['dailyBonusDate'] ?? '',
|
||||
'dailyCourseBonusDate': user['dailyCourseBonusDate'] ?? '',
|
||||
'lastPairLoginDate': user['lastPairLoginDate'] ?? '',
|
||||
'lastTrialPlayDate': user['lastTrialPlayDate'] ?? '',
|
||||
'playVsCount': user['playVsCount'] ?? 0,
|
||||
'playSyncCount': user['playSyncCount'] ?? 0,
|
||||
'winCount': user['winCount'] ?? 0,
|
||||
'helpCount': user['helpCount'] ?? 0,
|
||||
'comboCount': user['comboCount'] ?? 0,
|
||||
'totalDeluxscore': user['totalDeluxscore'] ?? 0,
|
||||
'totalBasicDeluxscore': user['totalBasicDeluxscore'] ?? 0,
|
||||
'totalAdvancedDeluxscore': user['totalAdvancedDeluxscore'] ?? 0,
|
||||
'totalExpertDeluxscore': user['totalExpertDeluxscore'] ?? 0,
|
||||
'totalMasterDeluxscore': user['totalMasterDeluxscore'] ?? 0,
|
||||
'totalReMasterDeluxscore': user['totalReMasterDeluxscore'] ?? 0,
|
||||
'totalSync': user['totalSync'] ?? 0,
|
||||
'totalBasicSync': user['totalBasicSync'] ?? 0,
|
||||
'totalAdvancedSync': user['totalAdvancedSync'] ?? 0,
|
||||
'totalExpertSync': user['totalExpertSync'] ?? 0,
|
||||
'totalMasterSync': user['totalMasterSync'] ?? 0,
|
||||
'totalReMasterSync': user['totalReMasterSync'] ?? 0,
|
||||
'totalAchievement': user['totalAchievement'] ?? 0,
|
||||
'totalBasicAchievement': user['totalBasicAchievement'] ?? 0,
|
||||
'totalAdvancedAchievement': user['totalAdvancedAchievement'] ?? 0,
|
||||
'totalExpertAchievement': user['totalExpertAchievement'] ?? 0,
|
||||
'totalMasterAchievement': user['totalMasterAchievement'] ?? 0,
|
||||
'totalReMasterAchievement': user['totalReMasterAchievement'] ?? 0,
|
||||
'playerOldRating': user['playerOldRating'] ?? 0,
|
||||
'playerNewRating': user['playerNewRating'] ?? 0,
|
||||
'banState': userData['banState'] ?? 0,
|
||||
'friendRegistSkip': user['friendRegistSkip'] ?? 0,
|
||||
'dateTime': now,
|
||||
}
|
||||
],
|
||||
'userExtend': [userExtend['userExtend']],
|
||||
'userOption': [userOption['userOption']],
|
||||
'userCharacterList': [],
|
||||
'userGhost': [],
|
||||
'userMapList': [],
|
||||
'userLoginBonusList': [],
|
||||
'userRatingList': [userRating['userRating']],
|
||||
'userItemList': [],
|
||||
'userMusicDetailList': [musicData],
|
||||
'userCourseList': [],
|
||||
'userFriendSeasonRankingList': [],
|
||||
'userChargeList': userChargeList['userChargeList'] ?? [],
|
||||
'userFavoriteList': [
|
||||
{'itemKind': 3, 'itemIdList': []},
|
||||
{'itemKind': 1, 'itemIdList': []},
|
||||
{'itemKind': 2, 'itemIdList': []},
|
||||
{'itemKind': 10, 'itemIdList': []},
|
||||
{'itemKind': 11, 'itemIdList': []},
|
||||
],
|
||||
'userActivityList': [userActivity['userActivity']],
|
||||
'userMissionDataList': _buildMissionDataList(userMissionData),
|
||||
'userWeeklyData': {
|
||||
'lastLoginWeek': userMissionData['userWeeklyData']?['lastLoginWeek'] ?? 0,
|
||||
'beforeLoginWeek': userMissionData['userWeeklyData']?['beforeLoginWeek'] ?? 0,
|
||||
'friendBonusFlag': userMissionData['userWeeklyData']?['friendBonusFlag'] ?? 0,
|
||||
},
|
||||
'userGamePlaylogList': [
|
||||
{
|
||||
'playlogId': loginId,
|
||||
'version': user['lastRomVersion'] ?? 0,
|
||||
'playDate': _formatDateTime(DateTime.now()),
|
||||
'playMode': 0,
|
||||
'useTicketId': -1,
|
||||
'playCredit': 1,
|
||||
'playTrack': 1,
|
||||
'clientId': _config.clientId,
|
||||
'isPlayTutorial': false,
|
||||
'isEventMode': false,
|
||||
'isNewFree': false,
|
||||
'playCount': 0,
|
||||
'playSpecial': calcRandom(),
|
||||
'playOtherUserId': 0,
|
||||
}
|
||||
],
|
||||
'user2pPlaylog': {
|
||||
'userId1': 0,
|
||||
'userId2': 0,
|
||||
'userName1': '',
|
||||
'userName2': '',
|
||||
'regionId': 0,
|
||||
'placeId': 0,
|
||||
'user2pPlaylogDetailList': [],
|
||||
},
|
||||
'userIntimateList': [],
|
||||
'userShopItemStockList': [],
|
||||
'userGetPointList': [],
|
||||
'userTradeItemList': [],
|
||||
'userFavoritemusicList': [],
|
||||
'userKaleidxScopeList': [],
|
||||
'isNewCharacterList': '',
|
||||
'isNewMapList': '',
|
||||
'isNewLoginBonusList': '',
|
||||
'isNewItemList': '',
|
||||
'isNewMusicDetailList': '0',
|
||||
'isNewCourseList': '',
|
||||
'isNewFavoriteList': '11111',
|
||||
'isNewFriendSeasonRankingList': '',
|
||||
'isNewUserIntimateList': '',
|
||||
'isNewFavoritemusicList': '',
|
||||
'isNewKaleidxScopeList': '',
|
||||
},
|
||||
};
|
||||
|
||||
final json = await _callApi(apiName, packet, userId);
|
||||
final errorId = json['errorId'] as int? ?? -1;
|
||||
if (errorId != 0) {
|
||||
throw TitleApiException('UpsertUserAllApi errorId=$errorId');
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildMissionDataList(
|
||||
Map<String, dynamic> userMissionData) {
|
||||
final list = userMissionData['userMissionDataList'] as List<dynamic>? ?? [];
|
||||
|
||||
return list.take(6).map((item) {
|
||||
final m = item as Map<String, dynamic>;
|
||||
return {
|
||||
'type': m['type'] ?? 0,
|
||||
'difficulty': m['difficulty'] ?? 0,
|
||||
'targetGenreId': m['targetGenreId'] ?? 0,
|
||||
'targetGenreTableId': m['targetGenreTableId'] ?? 0,
|
||||
'conditionGenreId': m['conditionGenreId'] ?? 0,
|
||||
'conditionGenreTableId': m['conditionGenreTableId'] ?? 0,
|
||||
'clearFlag': m['clearFlag'] ?? 0,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dt) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
class _RandomHelper {
|
||||
final _random = Random();
|
||||
|
||||
int nextInt(int max) => _random.nextInt(max);
|
||||
}
|
||||
Reference in New Issue
Block a user