first commit

This commit is contained in:
2026-05-22 22:00:37 +08:00
commit fee7291ab9
152 changed files with 8954 additions and 0 deletions

127
lib/config/strings.dart Normal file
View 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 = '许可证';
}

View 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
View 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),
),
),
),
),
],
),
],
),
),
),
),
);
}
}

View 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
View 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
View 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,
),
),
),
],
),
);
}
}

View 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
View 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,
),
),
],
),
);
}
}

View 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,
);
}
}

View 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();

View 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();
}

View 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;
}

View 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);

View 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;
}
}

View 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;
}

View 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);
}