First
This commit is contained in:
commit
a78a87e46b
154 files changed
+9387
No files matched your search
@@ -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 = '许可证';
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user