This commit is contained in:
Cookiues committed 2026-05-28 22:11:05 +08:00
commit a78a87e46b
154 files changed
+9387

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
class AppStrings {
AppStrings._();
// Tab
static const tabHome = '主页';
static const tabTickets = '票据';
static const tabSettings = '设置';
static const tabAbout = '关于';
// Login
static const appTitle = 'Project Ichino';
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 lastRegion = '最后地区';
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 plateId = '姓名框 ID';
static const frameId = '背景框 ID';
static const titleId = '称号 ID';
static const trophyId = '奖杯 ID';
static const partnerId = '伙伴 ID';
static const gradeRank = '段位';
static const courseRank = '阶级';
static const classRank = '段级';
static const point = '当前 P';
static const totalPoint = '累计 P';
static const headphoneVol = '耳机音量';
static const errorId = '错误 ID';
static const rawApiResponse = '原始 API 响应 (调试)';
// Ratings / breakdown
static const ratingBreakdown = 'Rating 详情';
static const musicRating = '乐曲 Rating';
static const newChartRating = '新谱面 Rating';
static const oldChartRating = '旧谱面 Rating';
static const highestRating = '历史最高';
// First play
static const firstPlay = '入坑信息';
static const joinDate = '入坑日期';
static const firstGame = '首次游戏';
static const firstRomVersion = '初始 Rom';
static const firstDataVersion = '初始数据';
// Play stats
static const playStats = '游玩统计';
static const playCount = '总游玩次数';
static const currentPlayCount = '当期游玩';
static const totalAchievement = '总达成率';
static const totalDxScore = '总 DX 分';
static const totalSync = '总同步数';
// Notices
static const inheritedAccountNotice = '此账号当前正在游玩, 仅显示 Preview 概要, 不执行登录。';
static String rating(int r) => 'Rating: $r';
// Tickets
static const runTicket = '使用功能票';
static const runningTicket = '执行中...';
static const ticketNotConfigured = '请先配置 Title Server 设置。';
static const ticketNotLoggedIn = '尚未登录游戏服务器,无法使用功能票。';
static const ticketAutoLogout = '完成后自动退出登录';
static const stepChargeTicket = '使用功能票';
static const stepLogout = '退出游戏';
static const myTickets = '功能票';
static const refreshTickets = '刷新数据';
static const loading = '加载中...';
static const ticketNotSelected = '请先在功能票列表中选中一张票。';
static const selectedTicket = '已选票';
static const ticketUsed = '功能票使用完成';
static const ticketCooldownSeconds = 60;
static String ticketCooldownCountdown(int remaining) =>
'冷却中,剩余 $remaining 秒';
static String ticketCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。';
// 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 Ichino';
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
static const credits = '致谢';
static const creditBuiltWith = '构建框架';
static const creditQRDecode = 'QR 解码';
static const creditIconAssets = '图标资源';
static const creditLicense = '许可证';
}
+98
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);
}
}
+327
View File
@@ -0,0 +1,327 @@
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,
cookies: result.cookies,
),
),
);
} 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),
),
),
),
),
],
),
],
),
),
),
),
);
}
}
+150
View File
@@ -0,0 +1,150 @@
class UserDataBean {
final int userId;
final String userName;
final String? friendCode;
final bool isNetMember;
final int point;
final int totalPoint;
final int playerRating;
final int playerOldRating;
final int playerNewRating;
final int highestRating;
final int musicRating;
final int gradeRank;
final int courseRank;
final int classRank;
final int nameplateId;
final int frameId;
final int iconId;
final int trophyId;
final int plateId;
final int titleId;
final int partnerId;
final int playCount;
final int currentPlayCount;
final int totalSync;
final int totalAchievement;
final int totalDeluxscore;
final int totalAwake;
final String lastGameId;
final String lastRomVersion;
final String lastDataVersion;
final String lastLoginDate;
final String lastPlayDate;
final String firstGameId;
final String firstRomVersion;
final String firstDataVersion;
final String firstPlayDate;
final String lastRegionName;
final int lastRegionId;
final String dailyBonusDate;
final int banState;
const UserDataBean({
required this.userId,
required this.userName,
required this.friendCode,
required this.isNetMember,
required this.point,
required this.totalPoint,
required this.playerRating,
required this.playerOldRating,
required this.playerNewRating,
required this.highestRating,
required this.musicRating,
required this.gradeRank,
required this.courseRank,
required this.classRank,
required this.nameplateId,
required this.frameId,
required this.iconId,
required this.trophyId,
required this.plateId,
required this.titleId,
required this.partnerId,
required this.playCount,
required this.currentPlayCount,
required this.totalSync,
required this.totalAchievement,
required this.totalDeluxscore,
required this.totalAwake,
required this.lastGameId,
required this.lastRomVersion,
required this.lastDataVersion,
required this.lastLoginDate,
required this.lastPlayDate,
required this.firstGameId,
required this.firstRomVersion,
required this.firstDataVersion,
required this.firstPlayDate,
required this.lastRegionName,
required this.lastRegionId,
required this.dailyBonusDate,
required this.banState,
});
factory UserDataBean.fromJson(Map<String, dynamic> json) {
final ud = (json['userData'] as Map<String, dynamic>?) ?? const {};
int toInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
bool toBool(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v == '1' || v.toLowerCase() == 'true';
return false;
}
String toStr(dynamic v) => v?.toString() ?? '';
return UserDataBean(
userId: toInt(json['userId']),
userName: toStr(ud['userName']),
friendCode: ud['friendCode']?.toString(),
isNetMember: toBool(ud['isNetMember']),
point: toInt(ud['point']),
totalPoint: toInt(ud['totalPoint']),
playerRating: toInt(ud['playerRating']),
playerOldRating: toInt(ud['playerOldRating']),
playerNewRating: toInt(ud['playerNewRating']),
highestRating: toInt(ud['highestRating']),
musicRating: toInt(ud['musicRating']),
gradeRank: toInt(ud['gradeRank']),
courseRank: toInt(ud['courseRank']),
classRank: toInt(ud['classRank']),
nameplateId: toInt(ud['nameplateId']),
frameId: toInt(ud['frameId']),
iconId: toInt(ud['iconId']),
trophyId: toInt(ud['trophyId']),
plateId: toInt(ud['plateId']),
titleId: toInt(ud['titleId']),
partnerId: toInt(ud['partnerId']),
playCount: toInt(ud['playCount']),
currentPlayCount: toInt(ud['currentPlayCount']),
totalSync: toInt(ud['totalSync']),
totalAchievement: toInt(ud['totalAchievement']),
totalDeluxscore: toInt(ud['totalDeluxscore']),
totalAwake: toInt(ud['totalAwake']),
lastGameId: toStr(ud['lastGameId']),
lastRomVersion: toStr(ud['lastRomVersion']),
lastDataVersion: toStr(ud['lastDataVersion']),
lastLoginDate: toStr(ud['lastLoginDate']),
lastPlayDate: toStr(ud['lastPlayDate']),
firstGameId: toStr(ud['firstGameId']),
firstRomVersion: toStr(ud['firstRomVersion']),
firstDataVersion: toStr(ud['firstDataVersion']),
firstPlayDate: toStr(ud['firstPlayDate']),
lastRegionName: toStr(ud['lastRegionName']),
lastRegionId: toInt(ud['lastRegionId']),
dailyBonusDate: toStr(ud['dailyBonusDate']),
banState: toInt(json['banState']),
);
}
}
+90
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
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),
),
],
),
);
}
}
File diff suppressed because it is too large. Load diff
+220
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,
);
}
}
+703
View File
@@ -0,0 +1,703 @@
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,
chargeTicket,
logout,
complete,
failed,
}
class TicketPage extends StatefulWidget {
final int userId;
final String? cookies;
/// `loginDateTime` 来自 HomePage 启动时执行的 UserLoginApi。
/// 为 `null` 时表示当前未持有有效登录态(例如 isInherit 账号或登录失败)。
final int? loginDateTime;
final int playerRating;
/// 当用户勾选"完成后自动退出登录"时,由 HomePage 负责发送 logout 包。
final Future<void> Function()? onLogoutRequested;
const TicketPage({
super.key,
required this.userId,
required this.playerRating,
this.cookies,
this.loginDateTime,
this.onLogoutRequested,
});
@override
State<TicketPage> createState() => _TicketPageState();
}
class _TicketPageState extends State<TicketPage> {
static const _ticketNameMap = {
1: '功能票',
2: '2倍功能票',
3: '3倍功能票',
4: '4倍功能票',
5: '5倍功能票',
6: '6倍功能票',
};
TicketStep _step = TicketStep.idle;
String _stepMessage = '';
String? _error;
bool _running = false;
bool _autoLogout = false;
List<Map<String, dynamic>>? _tickets;
bool _ticketsLoading = false;
String? _ticketsError;
int? _selectedTicketId;
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
String _ticketName(int chargeId) =>
_ticketNameMap[chargeId] ?? '票$chargeId';
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant TicketPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
_cooldownRemaining = 0;
return;
}
_cooldownRemaining = _computeRemaining(loginDateTime);
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final elapsed = nowSec - loginDateTime;
final remaining = AppStrings.ticketCooldownSeconds - elapsed;
return remaining < 0 ? 0 : remaining;
}
Map<String, dynamic>? _findTicket(int chargeId) {
final tickets = _tickets;
if (tickets == null) return null;
for (final t in tickets) {
if ((t['chargeId'] as num?)?.toInt() == chargeId) return t;
}
return null;
}
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;
_selectedTicketId = null;
});
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
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 (_selectedTicketId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.ticketNotSelected)),
);
return;
}
if (!TitleServerConfigHolder().isConfigured) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
);
return;
}
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text(AppStrings.ticketNotLoggedIn)),
);
return;
}
if (_cooldownRemaining > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppStrings.ticketCooldownNotice(_cooldownRemaining)),
),
);
return;
}
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final ticketId = _selectedTicketId!;
setState(() {
_running = true;
_error = null;
});
try {
_updateStep(TicketStep.chargeTicket);
await service.upsertUserChargeLog(
userId: widget.userId,
ticketId: ticketId,
loginDateTime: loginDateTime,
playerRating: widget.playerRating,
);
if (_autoLogout && widget.onLogoutRequested != null) {
_updateStep(TicketStep.logout);
await Future.delayed(const Duration(seconds: 5));
await widget.onLogoutRequested!();
}
_updateStep(
TicketStep.complete,
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
);
} 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 {
if (mounted) 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: [
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
if (widget.loginDateTime != null && _cooldownRemaining > 0)
_buildCooldownBanner(theme),
_buildTicketListCard(theme),
const SizedBox(height: 16),
_buildAutoLogoutToggle(theme),
const SizedBox(height: 12),
_buildRunButton(theme),
if (_step != TicketStep.idle) ...[
const SizedBox(height: 16),
_buildProgressCard(theme),
],
],
),
),
);
}
Widget _buildCooldownBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.timer_outlined,
size: 18, color: theme.colorScheme.onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.ticketCooldownNotice(_cooldownRemaining),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
],
),
);
}
Widget _buildNotLoggedInBanner(ThemeData theme) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded,
size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.ticketNotLoggedIn,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
Widget _buildAutoLogoutToggle(ThemeData theme) {
final enabled = widget.onLogoutRequested != null && !_running;
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled
? () => setState(() => _autoLogout = !_autoLogout)
: null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.ticketAutoLogout,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
Widget _buildTicketListCard(ThemeData theme) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: _selectedTicketId != null
? theme.colorScheme.primary.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.confirmation_number, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
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',
),
),
),
const SizedBox(height: 12),
],
..._ticketNameMap.keys.map(
(id) => _ticketRow(theme, id, _findTicket(id)),
),
],
),
),
);
}
Widget _ticketRow(ThemeData theme, int chargeId, Map<String, dynamic>? ticket) {
final hasData = ticket != null;
final stock = (ticket?['stock'] as num?)?.toInt() ?? 0;
final validDate = ticket?['validDate'] as String? ?? '-';
final name = _ticketName(chargeId);
final isSelected = _selectedTicketId == chargeId;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _running
? null
: () {
setState(() {
_selectedTicketId = isSelected ? null : chargeId;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.5)
: null,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Text(
'$chargeId',
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
: 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: isSelected ? FontWeight.w700 : FontWeight.w600,
color: isSelected ? theme.colorScheme.primary : null,
),
),
if (hasData)
Text(
'有效期: $validDate',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isSelected)
Icon(Icons.check_circle, size: 18, color: theme.colorScheme.primary)
else if (hasData)
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 _buildRunButton(ThemeData theme) {
final selectedName = _selectedTicketId != null
? _ticketName(_selectedTicketId!)
: null;
final onCooldown = _cooldownRemaining > 0;
final canRun = widget.loginDateTime != null && !_running && !onCooldown;
return Column(
children: [
if (selectedName != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${AppStrings.selectedTicket}: $selectedName (ID: $_selectedTicketId)',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 12),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: canRun ? _runTicket : null,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(onCooldown ? Icons.timer_outlined : Icons.play_arrow, size: 20),
label: Text(
_running
? AppStrings.runningTicket
: onCooldown
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
: AppStrings.runTicket,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
);
}
Widget _buildProgressCard(ThemeData theme) {
final isFailed = _step == TicketStep.failed;
final isDone = _step == TicketStep.complete;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: isFailed
? theme.colorScheme.error.withValues(alpha: 0.5)
: isDone
? Colors.green.withValues(alpha: 0.5)
: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(TicketStep.chargeTicket, AppStrings.stepChargeTicket, theme),
if (_autoLogout)
_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: isFailed
? theme.colorScheme.errorContainer
: isDone
? Colors.green.withValues(alpha: 0.1)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? 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 == step ? Icons.error : Icons.circle_outlined;
color = _step == step ? theme.colorScheme.error : 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,
),
),
],
),
);
}
}
+164
View File
@@ -0,0 +1,164 @@
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;
final String? cookies;
const LoginResult({
required this.success,
required this.errorId,
required this.userId,
required this.token,
this.cookies,
});
}
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? ?? '';
// ── Extract ALL cookies from response headers ──
// On web, Set-Cookie is a forbidden response header (browser hides it).
// On native (dart:io), it appears as lowercase 'set-cookie'.
// The http package may join multiple Set-Cookie lines with ', '.
String? cookies;
// ignore: avoid_print
print('[login] ── Response headers (${response.headers.length} entries) ──');
final headerKeys = response.headers.keys.toList();
for (final key in headerKeys) {
final value = response.headers[key]!;
// ignore: avoid_print
print('[login] "$key": "$value"');
// Match any header name that smells like a cookie
final lower = key.toLowerCase();
if (lower.contains('cookie') || lower.contains('set-cookie')) {
cookies = value;
// ignore: avoid_print
print('[login] >>> Found cookie header: $key = $cookies');
}
}
// Also try direct lookup with various casings (just in case iteration misses)
if (cookies == null) {
for (final name in ['set-cookie', 'Set-Cookie', 'SET-COOKIE', 'cookie', 'Cookie', 'COOKIE']) {
final v = response.headers[name];
if (v != null && v.isNotEmpty) {
cookies = v;
// ignore: avoid_print
print('[login] >>> Direct lookup "$name" = $cookies');
break;
}
}
}
if (cookies == null) {
// ignore: avoid_print
print('[login] WARNING: No cookie/set-cookie header found in response.');
// Also check if response body has session info
if (obj.containsKey('jsessionId')) {
cookies = 'JSESSIONID=${obj['jsessionId']}';
// ignore: avoid_print
print('[login] >>> Found jsessionId in response body: $cookies');
} else if (obj.containsKey('sessionId')) {
cookies = 'JSESSIONID=${obj['sessionId']}';
// ignore: avoid_print
print('[login] >>> Found sessionId in response body: $cookies');
}
}
// Parse out individual cookie name=value pairs for clean forwarding
// (strip Path, HttpOnly, etc. attributes)
String? cleanCookies;
if (cookies != null) {
final parts = <String>[];
// Split on ', ' first (http package combines multiple Set-Cookie headers)
// Then split on '\n' (some server responses use newlines)
for (final chunk in cookies.split(RegExp(r', |\n'))) {
final trimmed = chunk.trim();
if (trimmed.isEmpty) continue;
// Extract "NAME=VALUE" before the first ';'
final semi = trimmed.indexOf(';');
final nv = semi > 0 ? trimmed.substring(0, semi) : trimmed;
if (nv.contains('=')) {
parts.add(nv);
}
}
cleanCookies = parts.join('; ');
// ignore: avoid_print
print('[login] Clean cookies to forward: $cleanCookies');
}
return LoginResult(
success: errorId == 0,
errorId: errorId,
userId: userId,
token: token,
cookies: cleanCookies,
);
}
}
+7
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();
@@ -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
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;
}
+8
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);
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/services.dart';
const _channel = MethodChannel('com.ichino/qr_scanner');
Future<String?> decodeQRFromBytes(Uint8List bytes) async {
try {
final result = await _channel.invokeMethod<String>('decodeQRFromBytes', bytes);
return result;
} on PlatformException {
return null;
}
}
+60
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;
}
+517
View File
@@ -0,0 +1,517 @@
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_data.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;
final int loginDateTime;
const UserLoginResult({
required this.token,
required this.loginId,
required this.lastLoginDate,
required this.loginDateTime,
});
}
class TitleApiService {
static const String _obfuscateConstant = 'MaimaiChn';
static String? lastRawResponse;
final TitleServerConfig _config;
String? _cookies;
TitleApiService(this._config, {this._cookies});
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);
}
void _captureCookiesFromResponse(http.Response response, String apiName) {
// ignore: avoid_print
print('[$apiName] ── Response headers (${response.headers.length} entries) ──');
final headerKeys = response.headers.keys.toList();
String? rawCookies;
for (final key in headerKeys) {
final value = response.headers[key]!;
// ignore: avoid_print
print('[$apiName] "$key": "$value"');
final lower = key.toLowerCase();
if (lower.contains('set-cookie') || lower.contains('cookie')) {
rawCookies = value;
// ignore: avoid_print
print('[$apiName] >>> Found cookie header: $key = $rawCookies');
}
}
if (rawCookies != null) {
// Parse JSESSIONID from the cookie value
// The jsessionid cookie is the session key from WaCa
final parts = <String>[];
for (final chunk in rawCookies.split(RegExp(r', |\n'))) {
final trimmed = chunk.trim();
if (trimmed.isEmpty) continue;
final semi = trimmed.indexOf(';');
final nv = semi > 0 ? trimmed.substring(0, semi) : trimmed;
if (nv.contains('=')) {
parts.add(nv);
final eq = nv.indexOf('=');
final name = nv.substring(0, eq);
final value = nv.substring(eq + 1);
// ignore: avoid_print
print('[$apiName] >>> Cookie pair: $name = $value');
}
}
final cleanCookies = parts.join('; ');
if (cleanCookies.isNotEmpty) {
_cookies = cleanCookies;
// ignore: avoid_print
print('[$apiName] >>> Stored cookies for subsequent requests: $_cookies');
}
}
}
Map<String, dynamic> _processResponseBody(Uint8List bodyBytes) {
try {
final decrypted = _aesDecrypt(bodyBytes);
final decompressed = _decompress(decrypted);
final jsonStr = utf8.decode(decompressed);
return jsonDecode(jsonStr) as Map<String, dynamic>;
} on FormatException catch (e) {
throw TitleApiException('Response body is not valid JSON: $e');
} on Exception catch (e) {
throw TitleApiException('Failed to process response body: $e');
}
}
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');
// ignore: avoid_print
print('══════════════════════════════════════');
// ignore: avoid_print
print('[$apiName] >>> REQUEST >>>');
// ignore: avoid_print
print('[$apiName] URL: $url');
// ignore: avoid_print
print('[$apiName] userId: $userId');
// ignore: avoid_print
print('[$apiName] hash: $hash');
// ignore: avoid_print
print('[$apiName] body (encrypted): ${body.length} bytes');
// ignore: avoid_print
final packetStr = const JsonEncoder.withIndent(' ').convert(packet);
// ignore: avoid_print
print('[$apiName] packet (plain):');
// ignore: avoid_print
print(packetStr);
final headers = <String, String>{
'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',
};
final cookies = _cookies;
if (cookies != null) {
headers['Cookie'] = cookies;
// ignore: avoid_print
print('[$apiName] Cookie: $cookies');
}
final response = await http
.post(
url,
headers: headers,
body: body,
)
.timeout(const Duration(seconds: 15));
_captureCookiesFromResponse(response, apiName);
// ignore: avoid_print
print('[$apiName] HTTP status: ${response.statusCode}');
// ignore: avoid_print
print('[$apiName] response bytes: ${response.bodyBytes.length}');
if (response.statusCode != 200) {
throw TitleApiException('$apiName returned ${response.statusCode}');
}
if (response.bodyBytes.isEmpty) {
throw TitleApiException('$apiName returned empty body');
}
final json = _processResponseBody(response.bodyBytes);
final raw = const JsonEncoder.withIndent(' ').convert(json);
// ignore: avoid_print
print('[$apiName] <<< RESPONSE <<<');
// ignore: avoid_print
print(raw);
// ignore: avoid_print
print('══════════════════════════════════════');
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<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 previewHeaders = <String, String>{
'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',
};
final previewCookies = _cookies;
if (previewCookies != null) {
previewHeaders['Cookie'] = previewCookies;
// ignore: avoid_print
print('[getUserPreview] Cookie: $previewCookies');
}
final response = await http
.post(
url,
headers: previewHeaders,
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<UserDataBean> getUserDataTyped(int userId) async {
final json = await getUserData(userId);
return UserDataBean.fromJson(json);
}
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 returnCode = json['returnCode'] as int? ?? -1;
if (returnCode != 1) {
throw TitleApiException('UserLoginApi returnCode=$returnCode');
}
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,
loginDateTime: now,
);
}
// ---- 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, // LogoutType.Logout
};
final json = await _callApi(apiName, packet, userId);
final returnCode = json['returnCode'] as int? ?? -1;
if (returnCode != 1) {
throw TitleApiException('UserLogoutApi returnCode=$returnCode');
}
}
// ---- UpsertUserChargeLog (使用功能票) ----
// Reference: KanadeBot SendTicketCommand / PayloadBuilder.generateTicketRequest
static const _ticketPriceMap = {1: 1, 2: 6, 3: 2, 4: 2, 5: 2, 6: 0};
Future<void> upsertUserChargeLog({
required int userId,
required int ticketId,
required int loginDateTime,
required int playerRating,
int playCount = 0,
}) async {
const apiName = 'UpsertUserChargelogApi';
final now = DateTime.now();
final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0)
.add(const Duration(days: 90));
final purchaseDateStr = _formatDateTime(now);
final validDateStr =
'${validDate.year}-${validDate.month.toString().padLeft(2, '0')}-${validDate.day.toString().padLeft(2, '0')} '
'${validDate.hour.toString().padLeft(2, '0')}:${validDate.minute.toString().padLeft(2, '0')}:${validDate.second.toString().padLeft(2, '0')}';
final packet = {
'userId': userId,
'userChargelog': {
'chargeId': ticketId,
'price': _ticketPriceMap[ticketId] ?? 0,
'purchaseDate': purchaseDateStr,
'playCount': playCount,
'playerRating': playerRating,
'placeId': _config.placeId,
'regionId': _config.regionId,
'clientId': _config.clientId,
},
'userCharge': {
'chargeId': ticketId,
'stock': 1,
'purchaseDate': purchaseDateStr,
'validDate': validDateStr,
},
'loginDateTime': loginDateTime,
};
final json = await _callApi(apiName, packet, userId);
final returnCode = json['returnCode'] as int? ?? -1;
if (returnCode != 1) {
throw TitleApiException('UpsertUserChargelogApi returnCode=$returnCode');
}
}
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);
}