Merge pull request #1 from Bad0RANG3/refactor/shared-risk-scaffolding
refactor: 抽取高危功能页共享脚手架与传输层日志
This commit is contained in:
21 files changed
+1196
-2176
No files matched your search
@@ -5,12 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Build & Development Commands
|
## Build & Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run the app (launches on connected device or browser)
|
# Run the app (launches on connected device)
|
||||||
flutter run
|
flutter run
|
||||||
|
|
||||||
# Run specifically for web (primary target)
|
|
||||||
flutter run -d chrome
|
|
||||||
|
|
||||||
# Run analyzer
|
# Run analyzer
|
||||||
flutter analyze
|
flutter analyze
|
||||||
|
|
||||||
@@ -20,8 +17,12 @@ flutter test
|
|||||||
# Run a single test file
|
# Run a single test file
|
||||||
flutter test test/widget_test.dart
|
flutter test test/widget_test.dart
|
||||||
|
|
||||||
# Build for web
|
# Build release artifacts
|
||||||
flutter build web
|
flutter build apk --release # Android
|
||||||
|
flutter build windows --release # Windows
|
||||||
|
flutter build macos --release # macOS
|
||||||
|
flutter build linux --release # Linux
|
||||||
|
flutter build ios --release --no-codesign # iOS(无证书时只验证可编译)
|
||||||
|
|
||||||
# Check for outdated dependencies
|
# Check for outdated dependencies
|
||||||
flutter pub outdated
|
flutter pub outdated
|
||||||
@@ -32,31 +33,55 @@ flutter pub upgrade --major-versions
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is a Flutter app (targeting mobile native) that serves as a QR-code-based login client for the maimai DX arcade game network.
|
This is a Flutter app (Android / iOS / Windows / macOS / Linux) that serves as a QR-code-based
|
||||||
|
login client for the maimai DX arcade game network.
|
||||||
|
|
||||||
### Code Layout
|
### Code Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── main.dart # App entry point, LoginPage widget (the entire UI)
|
├── main.dart # 入口:main() + MyApp(MaterialApp)
|
||||||
|
├── pages/
|
||||||
|
│ ├── login_page.dart # 登录页(QR 粘贴 / 图片识别)
|
||||||
|
│ ├── home_page.dart # 主页骨架(底部导航 + 各 tab)
|
||||||
|
│ ├── settings_page.dart # Title Server / 机器参数配置
|
||||||
|
│ └── ... # 各功能页(ticket / best50 / 风险操作等)
|
||||||
|
├── widgets/
|
||||||
|
│ ├── cooldown_mixin.dart # CooldownMixin:登录后冷却倒计时
|
||||||
|
│ ├── step_progress.dart # RiskStep + StepProgressCard
|
||||||
|
│ ├── app_card.dart # AppCard / SectionTitle / AutoLogoutToggle
|
||||||
|
│ ├── app_notice.dart # AppNotice + context.showSnack
|
||||||
|
│ └── best50_poster.dart # B50 成绩图
|
||||||
└── services/
|
└── services/
|
||||||
├── api_service.dart # API client for game server auth
|
├── api_service.dart # Aime 登录 API 客户端
|
||||||
├── qr_service.dart # Platform-agnostic QR decode interface (conditional import)
|
├── title_api_service.dart # 游戏服务器传输层(加解密 / 会话 / 各 Api)
|
||||||
├── qr_service_native.dart # Native stub — QR decode is NOT implemented on mobile/desktop
|
├── user_all_payload_builder.dart # UpsertUserAll 组装与各补丁
|
||||||
└── qr_service_web.dart # Web QR decode via jsQR (loaded in web/index.html via CDN)
|
├── api_log.dart # 传输层调试日志(仅 debug)
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
- **Single-widget app**: The entire app is currently one `LoginPage` widget in `main.dart`. There is no routing — the login result shows an `AlertDialog`.
|
- **Risk-flow scaffolding is shared**: the feature pages (ticket / unlock music / collectibles /
|
||||||
- **Conditional imports for platform code**: `qr_service.dart` uses the `if (dart.library.html)` syntax to import `qr_service_web.dart` on web and `qr_service_native.dart` everywhere else. Native QR scanning returns `null` (not implemented).
|
travel partner / number patch / map traverse / kaleidx / risk hub) all use `CooldownMixin`,
|
||||||
- **Web is the primary target**: jsQR is loaded via CDN `<script>` tag in `web/index.html`. The web QR decoder draws the image onto a `<canvas>`, extracts `ImageData`, and calls `jsQR()` via `dart:js`.
|
`RiskStep`, `StepProgressCard`, `AppCard`, `AppNotice` and `AutoLogoutToggle` instead of
|
||||||
- **Auth flow**: `ApiService.login()` takes a QR code token (last 64 chars), builds a SHA-256 key from `chipID + JST-timestamp + chimeSalt`, and POSTs JSON to `http://ai.sys-allnet.cn/wc_aime/api/get_data`. A successful response has `errorID: 0` and returns `userID` + `token`.
|
copy-pasting the same timer / step / banner / card code. Keep new pages on these primitives.
|
||||||
- **Timestamp is JST (UTC+9)**: The API key derivation uses Japan Standard Time, formatted as `yyMMddHHmmss`.
|
- **QR decoding is native-only**: `services/qr_service.dart` calls the
|
||||||
|
`dev.naominet.ichino/qr_scanner` MethodChannel, implemented by Android's `MainActivity` and
|
||||||
|
iOS's `SceneDelegate`. Desktop has no handler, so image-based QR scanning is mobile-only.
|
||||||
|
- **Auth flow**: `ApiService.login()` takes a QR code token (last 64 chars), builds a SHA-256 key
|
||||||
|
from `chipID + JST-timestamp + chimeSalt`, and POSTs JSON to the configured Aime URL. A successful
|
||||||
|
response has `errorID: 0` and returns `userID` + `token`.
|
||||||
|
- **Title server transport**: `TitleApiService` derives the URL path from
|
||||||
|
`MD5(apiName + salt + obfuscateParam)`, JSON → ZLib → AES-CBC encodes/decodes, and keeps the
|
||||||
|
`JSESSIONID` cookie. `getUserPreview` and every other API go through the same `_callApi` helper.
|
||||||
|
- **Timestamp is JST (UTC+9)** for Aime key derivation, formatted `yyMMddHHmmss`.
|
||||||
|
|
||||||
### Notable Dependencies
|
### Notable Dependencies
|
||||||
|
|
||||||
- `http` — API calls
|
- `http` — API calls
|
||||||
- `crypto` — SHA-256 for API key derivation
|
- `crypto` — MD5 URL hash + SHA-256 Aime key
|
||||||
|
- `pointycastle` / `archive` — AES-CBC + ZLib for the title-server wire format
|
||||||
- `image_picker` — picking QR images from the device gallery
|
- `image_picker` — picking QR images from the device gallery
|
||||||
- `google_fonts` — custom fonts (currently unused in code)
|
- `shared_preferences` — persisted `TitleServerConfig`
|
||||||
|
- `share_plus` — exporting the B50 PNG
|
||||||
@@ -41,14 +41,28 @@ flutter build ios --release --no-codesign # iOS(无证书时只验证可编
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── main.dart # 入口 + 登录页
|
├── main.dart # 入口(MyApp + MaterialApp)
|
||||||
├── config/ # 字符串、响应式布局、Title Server 配置
|
├── config/ # 字符串、响应式布局、Title Server 配置
|
||||||
├── models/ # API 数据模型(UserData / UserPreview / UserRating / UserCharacter)
|
├── models/ # API 数据模型(UserData / UserPreview / UserRating / UserCharacter / UserKaleidxScope / Session)
|
||||||
├── pages/ # 各功能页面
|
├── pages/ # 登录页与各功能页面
|
||||||
├── services/ # 传输层、乐曲元数据、导出、RA 计算
|
├── services/ # 传输层、payload 组装、乐曲元数据、导出、RA 计算、调试日志
|
||||||
└── widgets/ # 可复用视觉组件(B50 海报)
|
└── widgets/ # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 共享抽象
|
||||||
|
|
||||||
|
功能票 / 解锁歌曲 / 收藏品 / 旅行伙伴 / 数值修改 / 一键跑图 / 万花筒 / 高危 hub
|
||||||
|
这些页面以前各抄一份脚手架,现已收口到:
|
||||||
|
|
||||||
|
- `widgets/cooldown_mixin.dart` —`CooldownMixin`:登录后冷却倒计时(`Timer.periodic`)。
|
||||||
|
- `widgets/step_progress.dart` —`RiskStep` + `StepProgressCard`:执行阶段枚举与进度卡。
|
||||||
|
- `widgets/app_card.dart` —`AppCard` / `SectionTitle` / `AutoLogoutToggle`。
|
||||||
|
- `widgets/app_notice.dart` —`AppNotice`(未登录/冷却/错误横幅)与 `context.showSnack`。
|
||||||
|
- `services/title_api_service.dart` —`TitleApiService.fromHolder()` 统一构建会话,`config` getter
|
||||||
|
供 payload builder 复用。
|
||||||
|
- `services/user_all_payload_builder.dart` — 占位 `musicData`(`placeholderMusicData`)。
|
||||||
|
- `services/api_log.dart` —`ApiLog`:传输层日志,仅 debug 输出,release 下不落 token/cookie。
|
||||||
|
|
||||||
平台差异只体现在原生侧:QR 解码由 Android 的 `MainActivity` 与 iOS 的
|
平台差异只体现在原生侧:QR 解码由 Android 的 `MainActivity` 与 iOS 的
|
||||||
`SceneDelegate` 通过 `dev.naominet.ichino/qr_scanner` 这个 MethodChannel 提供,
|
`SceneDelegate` 通过 `dev.naominet.ichino/qr_scanner` 这个 MethodChannel 提供,
|
||||||
桌面平台没有该 handler,因此从图片识别二维码仅在移动端可用。
|
桌面平台没有该 handler,因此从图片识别二维码仅在移动端可用。
|
||||||
|
|||||||
+2
-295
@@ -1,12 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'config/responsive.dart';
|
|
||||||
import 'config/strings.dart';
|
import 'config/strings.dart';
|
||||||
import 'config/title_server_config.dart';
|
import 'config/title_server_config.dart';
|
||||||
import 'models/session_model.dart';
|
import 'pages/login_page.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 {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -39,292 +35,3 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
|
||||||
const LoginPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LoginPage> createState() => _LoginPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginPage> {
|
|
||||||
final _qrController = TextEditingController();
|
|
||||||
String _qrCode = '';
|
|
||||||
bool _loading = false;
|
|
||||||
bool _forcePreviewApi = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_qrController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onQRContentChanged(String value) {
|
|
||||||
setState(() {
|
|
||||||
_qrCode = SessionModel.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 = SessionModel.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 SessionModel.instance.loginWithQr(_qrCode);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => HomePage(
|
|
||||||
userId: result.userId,
|
|
||||||
token: result.token,
|
|
||||||
forcePreviewApi: _forcePreviewApi,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} 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: responsiveBody(
|
|
||||||
context,
|
|
||||||
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(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
CheckboxListTile(
|
|
||||||
value: _forcePreviewApi,
|
|
||||||
onChanged: (v) => setState(() => _forcePreviewApi = v ?? false),
|
|
||||||
title: const Text(AppStrings.forcePreviewApi),
|
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
dense: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,10 +75,7 @@ class _Best50PageState extends State<Best50Page> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final service = TitleApiService(
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
TitleServerConfigHolder().config!,
|
|
||||||
cookies: widget.cookies,
|
|
||||||
);
|
|
||||||
final rating = await service.getUserRatingTyped(widget.userId);
|
final rating = await service.getUserRatingTyped(widget.userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../config/responsive.dart';
|
import '../config/responsive.dart';
|
||||||
import '../config/strings.dart';
|
import '../config/strings.dart';
|
||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/cooldown_mixin.dart';
|
||||||
import 'collectibles_page.dart';
|
import 'collectibles_page.dart';
|
||||||
import 'kaleidx_scope_page.dart';
|
import 'kaleidx_scope_page.dart';
|
||||||
import 'map_traverse_page.dart';
|
import 'map_traverse_page.dart';
|
||||||
@@ -43,56 +43,10 @@ class HighRiskFeaturePage extends StatefulWidget {
|
|||||||
State<HighRiskFeaturePage> createState() => _HighRiskFeaturePageState();
|
State<HighRiskFeaturePage> createState() => _HighRiskFeaturePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
class _HighRiskFeaturePageState extends State<HighRiskFeaturePage>
|
||||||
Timer? _cooldownTimer;
|
with CooldownMixin<HighRiskFeaturePage> {
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant HighRiskFeaturePage 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openUnlockMusic() {
|
void _openUnlockMusic() {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
@@ -209,9 +163,17 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
context,
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
if (!isLoggedIn)
|
||||||
if (widget.loginDateTime != null && _cooldownRemaining > 0)
|
AppNotice(
|
||||||
_buildCooldownBanner(theme),
|
AppStrings.musicRiskHubNotLoggedIn,
|
||||||
|
error: true,
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
),
|
||||||
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
|
AppNotice(
|
||||||
|
AppStrings.musicRiskHubCooldownNotice(cooldownRemaining),
|
||||||
|
icon: Icons.timer_outlined,
|
||||||
|
),
|
||||||
_buildDescCard(theme),
|
_buildDescCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildNavCard(
|
_buildNavCard(
|
||||||
@@ -275,60 +237,6 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.musicRiskHubNotLoggedIn,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.musicRiskHubCooldownNotice(_cooldownRemaining),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDescCard(ThemeData theme) {
|
Widget _buildDescCard(ThemeData theme) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
|||||||
@@ -75,10 +75,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
});
|
});
|
||||||
_session.setGameLogin(null);
|
_session.setGameLogin(null);
|
||||||
|
|
||||||
final service = TitleApiService(
|
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||||
TitleServerConfigHolder().config!,
|
|
||||||
cookies: _session.cookies,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final preview = await service.getUserPreview(
|
final preview = await service.getUserPreview(
|
||||||
@@ -102,10 +99,8 @@ class _HomePageState extends State<HomePage> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final userDataService = TitleApiService(
|
final userDataService =
|
||||||
TitleServerConfigHolder().config!,
|
TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||||
cookies: _session.cookies,
|
|
||||||
);
|
|
||||||
final userData = await userDataService.getUserDataTyped(widget.userId);
|
final userData = await userDataService.getUserDataTyped(widget.userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_session.updateCookies(userDataService.cookies);
|
_session.updateCookies(userDataService.cookies);
|
||||||
@@ -142,10 +137,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final service = TitleApiService(
|
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||||
TitleServerConfigHolder().config!,
|
|
||||||
cookies: _session.cookies,
|
|
||||||
);
|
|
||||||
final userData = await service.getUserDataTyped(widget.userId);
|
final userData = await service.getUserDataTyped(widget.userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_session.updateCookies(service.cookies);
|
_session.updateCookies(service.cookies);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -8,6 +7,10 @@ import '../config/title_server_config.dart';
|
|||||||
import '../models/user_kaleidx_scope.dart';
|
import '../models/user_kaleidx_scope.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
import '../services/user_all_payload_builder.dart';
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/cooldown_mixin.dart';
|
||||||
|
import '../widgets/step_progress.dart';
|
||||||
|
|
||||||
/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。
|
/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。
|
||||||
///
|
///
|
||||||
@@ -34,12 +37,8 @@ class KaleidxScopePage extends StatefulWidget {
|
|||||||
State<KaleidxScopePage> createState() => _KaleidxScopePageState();
|
State<KaleidxScopePage> createState() => _KaleidxScopePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||||
|
with CooldownMixin<KaleidxScopePage> {
|
||||||
class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|
||||||
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
|
||||||
static const int _placeholderMusicId = 11538;
|
|
||||||
|
|
||||||
final _gateIdController = TextEditingController();
|
final _gateIdController = TextEditingController();
|
||||||
final List<int> _pending = [];
|
final List<int> _pending = [];
|
||||||
|
|
||||||
@@ -52,65 +51,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _autoLogout = true;
|
bool _autoLogout = true;
|
||||||
_Step _step = _Step.idle;
|
RiskStep _step = RiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
|
|
||||||
Timer? _cooldownTimer;
|
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
bool get _loggedIn => widget.loginDateTime != null;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant KaleidxScopePage oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
_gateIdController.dispose();
|
_gateIdController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncCooldown() {
|
void _snack(String message) => context.showSnack(message);
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
final loginDateTime = widget.loginDateTime;
|
|
||||||
if (loginDateTime == null) {
|
|
||||||
setState(() => _cooldownRemaining = 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _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 remaining =
|
|
||||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
|
||||||
return remaining < 0 ? 0 : remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _snack(String message) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addPending() {
|
void _addPending() {
|
||||||
final id = int.tryParse(_gateIdController.text.trim()) ?? -1;
|
final id = int.tryParse(_gateIdController.text.trim()) ?? -1;
|
||||||
@@ -149,8 +102,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
service.fetchUserAllData(widget.userId),
|
service.fetchUserAllData(widget.userId),
|
||||||
service.getUserKaleidxScopes(widget.userId),
|
service.getUserKaleidxScopes(widget.userId),
|
||||||
@@ -179,8 +131,8 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
_snack(AppStrings.kaleidxNotLoggedIn);
|
_snack(AppStrings.kaleidxNotLoggedIn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_cooldownRemaining > 0) {
|
if (cooldownRemaining > 0) {
|
||||||
_snack(AppStrings.kaleidxCooldownNotice(_cooldownRemaining));
|
_snack(AppStrings.kaleidxCooldownNotice(cooldownRemaining));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_pending.isEmpty) {
|
if (_pending.isEmpty) {
|
||||||
@@ -195,13 +147,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
setState(() => _running = true);
|
setState(() => _running = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
final builder = UserAllPayloadBuilder(service.config);
|
||||||
final builder = UserAllPayloadBuilder(config);
|
|
||||||
|
|
||||||
var data = _userAllData;
|
var data = _userAllData;
|
||||||
if (data.isEmpty || _serverScopes.isEmpty) {
|
if (data.isEmpty || _serverScopes.isEmpty) {
|
||||||
_updateStep(_Step.fetchData);
|
_updateStep(RiskStep.fetchData);
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
service.fetchUserAllData(widget.userId),
|
service.fetchUserAllData(widget.userId),
|
||||||
service.getUserKaleidxScopes(widget.userId),
|
service.getUserKaleidxScopes(widget.userId),
|
||||||
@@ -215,12 +166,12 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStep(_Step.upload);
|
_updateStep(RiskStep.upload);
|
||||||
final packet = builder.build(
|
final packet = builder.build(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
loginDateTime: loginDateTime,
|
loginDateTime: loginDateTime,
|
||||||
musicData: _placeholderMusicData(),
|
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||||
generalUserInfo: data,
|
generalUserInfo: data,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -236,18 +187,18 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await service.upsertUserAll(packet, widget.userId);
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
_updateStep(_Step.complete, await _verify(service));
|
_updateStep(RiskStep.complete, await _verify(service));
|
||||||
|
|
||||||
if (_autoLogout && widget.onExitToTitle != null) {
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||||
await widget.onExitToTitle!();
|
await widget.onExitToTitle!();
|
||||||
}
|
}
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(_Step.failed, e.message);
|
_updateStep(RiskStep.failed, e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_updateStep(_Step.failed, '$e');
|
_updateStep(RiskStep.failed, '$e');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _running = false);
|
if (mounted) setState(() => _running = false);
|
||||||
}
|
}
|
||||||
@@ -273,19 +224,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
return AppStrings.kaleidxVerified(saved.length);
|
return AppStrings.kaleidxVerified(saved.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _placeholderMusicData() => {
|
void _updateStep(RiskStep step, [String? message]) {
|
||||||
'musicId': _placeholderMusicId,
|
|
||||||
'level': 0,
|
|
||||||
'playCount': 1,
|
|
||||||
'achievement': 0,
|
|
||||||
'comboStatus': 0,
|
|
||||||
'syncStatus': 0,
|
|
||||||
'deluxscoreMax': 0,
|
|
||||||
'scoreRank': 0,
|
|
||||||
'extNum1': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
void _updateStep(_Step step, [String? message]) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = step;
|
_step = step;
|
||||||
@@ -306,22 +245,19 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!_loggedIn)
|
if (!isLoggedIn)
|
||||||
_notice(theme, AppStrings.kaleidxNotLoggedIn, error: true),
|
const AppNotice(AppStrings.kaleidxNotLoggedIn, error: true),
|
||||||
if (_loggedIn && _cooldownRemaining > 0)
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
_notice(
|
AppNotice(
|
||||||
theme,
|
AppStrings.kaleidxCooldownNotice(cooldownRemaining),
|
||||||
AppStrings.kaleidxCooldownNotice(_cooldownRemaining),
|
|
||||||
),
|
),
|
||||||
_card(
|
AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_titleRow(
|
const SectionTitle(
|
||||||
theme,
|
icon: Icons.science_outlined,
|
||||||
Icons.science_outlined,
|
title: AppStrings.kaleidxFeatureTitle,
|
||||||
AppStrings.kaleidxFeatureTitle,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -339,12 +275,24 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_fetchCard(theme),
|
_fetchCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_autoLogoutToggle(theme),
|
AutoLogoutToggle(
|
||||||
|
value: _autoLogout,
|
||||||
|
enabled: widget.onExitToTitle != null && !_running,
|
||||||
|
onChanged: (v) => setState(() => _autoLogout = v),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_runButton(theme),
|
_runButton(theme),
|
||||||
if (_step != _Step.idle) ...[
|
if (_step != RiskStep.idle) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_progressCard(theme),
|
StepProgressCard(
|
||||||
|
step: _step,
|
||||||
|
message: _stepMessage,
|
||||||
|
steps: [
|
||||||
|
(RiskStep.fetchData, AppStrings.kaleidxStepFetch),
|
||||||
|
(RiskStep.upload, AppStrings.kaleidxStepUpload),
|
||||||
|
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -353,59 +301,6 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _card(ThemeData theme, {required Widget child}) {
|
|
||||||
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: child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _titleRow(ThemeData theme, IconData icon, String title) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _checkRow({
|
Widget _checkRow({
|
||||||
required String label,
|
required String label,
|
||||||
required bool value,
|
required bool value,
|
||||||
@@ -429,8 +324,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
|
|
||||||
Widget _inputCard(ThemeData theme) {
|
Widget _inputCard(ThemeData theme) {
|
||||||
final enabled = !_running;
|
final enabled = !_running;
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -521,8 +415,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
|
|
||||||
Widget _fetchCard(ThemeData theme) {
|
Widget _fetchCard(ThemeData theme) {
|
||||||
final hasData = _userAllData.isNotEmpty;
|
final hasData = _userAllData.isNotEmpty;
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -573,38 +466,11 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _autoLogoutToggle(ThemeData theme) {
|
|
||||||
final enabled = widget.onExitToTitle != null && !_running;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(
|
|
||||||
value: _autoLogout,
|
|
||||||
onChanged: enabled
|
|
||||||
? (v) => setState(() => _autoLogout = v ?? false)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
AppStrings.autoLogoutAndExit,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _runButton(ThemeData theme) {
|
Widget _runButton(ThemeData theme) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _running || !_loggedIn ? null : _run,
|
onPressed: _running || !isLoggedIn ? null : _run,
|
||||||
icon: _running
|
icon: _running
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -624,72 +490,4 @@ class _KaleidxScopePageState extends State<KaleidxScopePage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _progressCard(ThemeData theme) {
|
|
||||||
final isFailed = _step == _Step.failed;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_stepRow(theme, _Step.fetchData, AppStrings.kaleidxStepFetch),
|
|
||||||
_stepRow(theme, _Step.upload, AppStrings.kaleidxStepUpload),
|
|
||||||
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
|
||||||
if (_stepMessage.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_stepMessage,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
|
||||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
|
||||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
|
||||||
final done = order.indexOf(current) > order.indexOf(step);
|
|
||||||
final active = _step == step;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
done
|
|
||||||
? Icons.check_circle
|
|
||||||
: active
|
|
||||||
? Icons.radio_button_checked
|
|
||||||
: Icons.radio_button_unchecked,
|
|
||||||
size: 18,
|
|
||||||
color: done
|
|
||||||
? Colors.green
|
|
||||||
: active
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../config/responsive.dart';
|
||||||
|
import '../config/strings.dart';
|
||||||
|
import '../models/session_model.dart';
|
||||||
|
import '../services/file_picker_service.dart';
|
||||||
|
import '../services/qr_service.dart';
|
||||||
|
import 'home_page.dart';
|
||||||
|
import 'settings_page.dart';
|
||||||
|
|
||||||
|
/// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。
|
||||||
|
class LoginPage extends StatefulWidget {
|
||||||
|
const LoginPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginPage> createState() => _LoginPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPageState extends State<LoginPage> {
|
||||||
|
final _qrController = TextEditingController();
|
||||||
|
String _qrCode = '';
|
||||||
|
bool _loading = false;
|
||||||
|
bool _forcePreviewApi = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_qrController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onQRContentChanged(String value) {
|
||||||
|
setState(() {
|
||||||
|
_qrCode = SessionModel.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 = SessionModel.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 SessionModel.instance.loginWithQr(_qrCode);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => HomePage(
|
||||||
|
userId: result.userId,
|
||||||
|
token: result.token,
|
||||||
|
forcePreviewApi: _forcePreviewApi,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} 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: responsiveBody(
|
||||||
|
context,
|
||||||
|
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(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _forcePreviewApi,
|
||||||
|
onChanged: (v) => setState(() => _forcePreviewApi = v ?? false),
|
||||||
|
title: const Text(AppStrings.forcePreviewApi),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -7,6 +6,10 @@ import '../config/strings.dart';
|
|||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
import '../services/user_all_payload_builder.dart';
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/cooldown_mixin.dart';
|
||||||
|
import '../widgets/step_progress.dart';
|
||||||
|
|
||||||
/// 一键跑图:把选中的区域标记为已完成。
|
/// 一键跑图:把选中的区域标记为已完成。
|
||||||
///
|
///
|
||||||
@@ -39,12 +42,8 @@ class MapTraversePage extends StatefulWidget {
|
|||||||
State<MapTraversePage> createState() => _MapTraversePageState();
|
State<MapTraversePage> createState() => _MapTraversePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
class _MapTraversePageState extends State<MapTraversePage>
|
||||||
|
with CooldownMixin<MapTraversePage> {
|
||||||
class _MapTraversePageState extends State<MapTraversePage> {
|
|
||||||
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
|
|
||||||
static const int _placeholderMusicId = 11538;
|
|
||||||
|
|
||||||
final _mapIdController = TextEditingController();
|
final _mapIdController = TextEditingController();
|
||||||
final List<int> _pending = [];
|
final List<int> _pending = [];
|
||||||
|
|
||||||
@@ -55,65 +54,19 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _autoLogout = true;
|
bool _autoLogout = true;
|
||||||
_Step _step = _Step.idle;
|
RiskStep _step = RiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
|
|
||||||
Timer? _cooldownTimer;
|
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
bool get _loggedIn => widget.loginDateTime != null;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant MapTraversePage oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
_mapIdController.dispose();
|
_mapIdController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncCooldown() {
|
void _snack(String message) => context.showSnack(message);
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
final loginDateTime = widget.loginDateTime;
|
|
||||||
if (loginDateTime == null) {
|
|
||||||
setState(() => _cooldownRemaining = 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _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 remaining =
|
|
||||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
|
||||||
return remaining < 0 ? 0 : remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _snack(String message) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addPending() {
|
void _addPending() {
|
||||||
final id = int.tryParse(_mapIdController.text.trim()) ?? -1;
|
final id = int.tryParse(_mapIdController.text.trim()) ?? -1;
|
||||||
@@ -144,8 +97,7 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
service.fetchUserAllData(widget.userId),
|
service.fetchUserAllData(widget.userId),
|
||||||
service.getUserMaps(widget.userId),
|
service.getUserMaps(widget.userId),
|
||||||
@@ -174,8 +126,8 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
_snack(AppStrings.mapNotLoggedIn);
|
_snack(AppStrings.mapNotLoggedIn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_cooldownRemaining > 0) {
|
if (cooldownRemaining > 0) {
|
||||||
_snack(AppStrings.mapCooldownNotice(_cooldownRemaining));
|
_snack(AppStrings.mapCooldownNotice(cooldownRemaining));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_pending.isEmpty) {
|
if (_pending.isEmpty) {
|
||||||
@@ -186,14 +138,13 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
setState(() => _running = true);
|
setState(() => _running = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
final builder = UserAllPayloadBuilder(service.config);
|
||||||
final builder = UserAllPayloadBuilder(config);
|
|
||||||
|
|
||||||
var data = _userAllData;
|
var data = _userAllData;
|
||||||
var serverIds = _serverMapIds;
|
var serverIds = _serverMapIds;
|
||||||
if (data.isEmpty || serverIds == null) {
|
if (data.isEmpty || serverIds == null) {
|
||||||
_updateStep(_Step.fetchData);
|
_updateStep(RiskStep.fetchData);
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
service.fetchUserAllData(widget.userId),
|
service.fetchUserAllData(widget.userId),
|
||||||
service.getUserMaps(widget.userId),
|
service.getUserMaps(widget.userId),
|
||||||
@@ -204,12 +155,12 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
serverIds = _serverMapIds!;
|
serverIds = _serverMapIds!;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStep(_Step.upload);
|
_updateStep(RiskStep.upload);
|
||||||
final packet = builder.build(
|
final packet = builder.build(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
loginDateTime: loginDateTime,
|
loginDateTime: loginDateTime,
|
||||||
musicData: _placeholderMusicData(),
|
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||||
generalUserInfo: data,
|
generalUserInfo: data,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,18 +175,18 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
|
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
|
||||||
|
|
||||||
await service.upsertUserAll(packet, widget.userId);
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
_updateStep(_Step.complete, await _verify(service));
|
_updateStep(RiskStep.complete, await _verify(service));
|
||||||
|
|
||||||
if (_autoLogout && widget.onExitToTitle != null) {
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||||
await widget.onExitToTitle!();
|
await widget.onExitToTitle!();
|
||||||
}
|
}
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(_Step.failed, e.message);
|
_updateStep(RiskStep.failed, e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_updateStep(_Step.failed, '$e');
|
_updateStep(RiskStep.failed, '$e');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _running = false);
|
if (mounted) setState(() => _running = false);
|
||||||
}
|
}
|
||||||
@@ -260,19 +211,7 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
return AppStrings.mapVerified(requested.length);
|
return AppStrings.mapVerified(requested.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _placeholderMusicData() => {
|
void _updateStep(RiskStep step, [String? message]) {
|
||||||
'musicId': _placeholderMusicId,
|
|
||||||
'level': 0,
|
|
||||||
'playCount': 1,
|
|
||||||
'achievement': 0,
|
|
||||||
'comboStatus': 0,
|
|
||||||
'syncStatus': 0,
|
|
||||||
'deluxscoreMax': 0,
|
|
||||||
'scoreRank': 0,
|
|
||||||
'extNum1': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
void _updateStep(_Step step, [String? message]) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = step;
|
_step = step;
|
||||||
@@ -293,19 +232,17 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!_loggedIn)
|
if (!isLoggedIn)
|
||||||
_notice(theme, AppStrings.mapNotLoggedIn, error: true),
|
const AppNotice(AppStrings.mapNotLoggedIn, error: true),
|
||||||
if (_loggedIn && _cooldownRemaining > 0)
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
_notice(theme, AppStrings.mapCooldownNotice(_cooldownRemaining)),
|
AppNotice(AppStrings.mapCooldownNotice(cooldownRemaining)),
|
||||||
_card(
|
AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_titleRow(
|
const SectionTitle(
|
||||||
theme,
|
icon: Icons.map_outlined,
|
||||||
Icons.map_outlined,
|
title: AppStrings.mapFeatureTitle,
|
||||||
AppStrings.mapFeatureTitle,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -331,12 +268,24 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_fetchCard(theme),
|
_fetchCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_autoLogoutToggle(theme),
|
AutoLogoutToggle(
|
||||||
|
value: _autoLogout,
|
||||||
|
enabled: widget.onExitToTitle != null && !_running,
|
||||||
|
onChanged: (v) => setState(() => _autoLogout = v),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_runButton(theme),
|
_runButton(theme),
|
||||||
if (_step != _Step.idle) ...[
|
if (_step != RiskStep.idle) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_progressCard(theme),
|
StepProgressCard(
|
||||||
|
step: _step,
|
||||||
|
message: _stepMessage,
|
||||||
|
steps: [
|
||||||
|
(RiskStep.fetchData, AppStrings.mapStepFetch),
|
||||||
|
(RiskStep.upload, AppStrings.mapStepUpload),
|
||||||
|
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -345,63 +294,9 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _card(ThemeData theme, {required Widget child}) {
|
|
||||||
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: child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _titleRow(ThemeData theme, IconData icon, String title) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _inputCard(ThemeData theme) {
|
Widget _inputCard(ThemeData theme) {
|
||||||
final enabled = !_running;
|
final enabled = !_running;
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -449,8 +344,7 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
|
|
||||||
Widget _fetchCard(ThemeData theme) {
|
Widget _fetchCard(ThemeData theme) {
|
||||||
final hasData = _serverMapIds != null;
|
final hasData = _serverMapIds != null;
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -501,38 +395,11 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _autoLogoutToggle(ThemeData theme) {
|
|
||||||
final enabled = widget.onExitToTitle != null && !_running;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(
|
|
||||||
value: _autoLogout,
|
|
||||||
onChanged: enabled
|
|
||||||
? (v) => setState(() => _autoLogout = v ?? false)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
AppStrings.autoLogoutAndExit,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _runButton(ThemeData theme) {
|
Widget _runButton(ThemeData theme) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _running || !_loggedIn ? null : _run,
|
onPressed: _running || !isLoggedIn ? null : _run,
|
||||||
icon: _running
|
icon: _running
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -552,72 +419,4 @@ class _MapTraversePageState extends State<MapTraversePage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _progressCard(ThemeData theme) {
|
|
||||||
final isFailed = _step == _Step.failed;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_stepRow(theme, _Step.fetchData, AppStrings.mapStepFetch),
|
|
||||||
_stepRow(theme, _Step.upload, AppStrings.mapStepUpload),
|
|
||||||
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
|
||||||
if (_stepMessage.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_stepMessage,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
|
||||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
|
||||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
|
||||||
final done = order.indexOf(current) > order.indexOf(step);
|
|
||||||
final active = _step == step;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
done
|
|
||||||
? Icons.check_circle
|
|
||||||
: active
|
|
||||||
? Icons.radio_button_checked
|
|
||||||
: Icons.radio_button_unchecked,
|
|
||||||
size: 18,
|
|
||||||
color: done
|
|
||||||
? Colors.green
|
|
||||||
: active
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../config/responsive.dart';
|
import '../config/responsive.dart';
|
||||||
@@ -7,6 +5,10 @@ import '../config/strings.dart';
|
|||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
import '../services/user_all_payload_builder.dart';
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/cooldown_mixin.dart';
|
||||||
|
import '../widgets/step_progress.dart';
|
||||||
|
|
||||||
/// Dart port of `eaquira/action/UnlockMusic.py`:
|
/// Dart port of `eaquira/action/UnlockMusic.py`:
|
||||||
///
|
///
|
||||||
@@ -15,8 +17,6 @@ import '../services/user_all_payload_builder.dart';
|
|||||||
/// - 附加道具 (userItemList / isNewItemList)
|
/// - 附加道具 (userItemList / isNewItemList)
|
||||||
/// - UpsertUserAllApi 上传
|
/// - UpsertUserAllApi 上传
|
||||||
/// - 可选自动 UserLogout
|
/// - 可选自动 UserLogout
|
||||||
enum MusicRiskStep { idle, fetchData, upload, logout, complete, failed }
|
|
||||||
|
|
||||||
enum MusicRiskFeatureMode {
|
enum MusicRiskFeatureMode {
|
||||||
/// 解锁歌曲与谱面 (itemKind 5/6/7)
|
/// 解锁歌曲与谱面 (itemKind 5/6/7)
|
||||||
unlockMusic,
|
unlockMusic,
|
||||||
@@ -95,10 +95,8 @@ class MusicRiskFeatureView extends StatefulWidget {
|
|||||||
State<MusicRiskFeatureView> createState() => _MusicRiskFeatureViewState();
|
State<MusicRiskFeatureView> createState() => _MusicRiskFeatureViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||||
/// eaquira `settings.musicData` 默认歌曲 (Amber Chronicle)。
|
with CooldownMixin<MusicRiskFeatureView> {
|
||||||
static const int _defaultMusicId = 11538;
|
|
||||||
|
|
||||||
// ── 解锁选项 (UnlockMusic) ──
|
// ── 解锁选项 (UnlockMusic) ──
|
||||||
final _unlockMusicIdController = TextEditingController();
|
final _unlockMusicIdController = TextEditingController();
|
||||||
bool _unlockMusic = false;
|
bool _unlockMusic = false;
|
||||||
@@ -122,66 +120,23 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
String? _fetchError;
|
String? _fetchError;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _autoLogout = true;
|
bool _autoLogout = true;
|
||||||
MusicRiskStep _step = MusicRiskStep.idle;
|
RiskStep _step = RiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
// ── 冷却 ──
|
|
||||||
Timer? _cooldownTimer;
|
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
|
bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant MusicRiskFeatureView oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
_unlockMusicIdController.dispose();
|
_unlockMusicIdController.dispose();
|
||||||
_itemIdController.dispose();
|
_itemIdController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncCooldown() {
|
void _updateStep(RiskStep step, [String? message]) {
|
||||||
_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;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateStep(MusicRiskStep step, [String? message]) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = step;
|
_step = step;
|
||||||
@@ -194,11 +149,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
|
|
||||||
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
|
int _itemId() => int.tryParse(_itemIdController.text.trim()) ?? -1;
|
||||||
|
|
||||||
void _snack(String message) {
|
void _snack(String message) => context.showSnack(message);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把当前输入框里的收藏品加入待提交列表。
|
/// 把当前输入框里的收藏品加入待提交列表。
|
||||||
void _addItemToList() {
|
void _addItemToList() {
|
||||||
@@ -245,21 +196,10 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
/// 解锁状态本身由 userItemList 的 itemKind 5/6/7 行表达,这里只保留一条
|
/// 解锁状态本身由 userItemList 的 itemKind 5/6/7 行表达,这里只保留一条
|
||||||
/// 记录以维持 payload 形状,取列表首条歌曲 ID。
|
/// 记录以维持 payload 形状,取列表首条歌曲 ID。
|
||||||
Map<String, dynamic> _buildMusicData() {
|
Map<String, dynamic> _buildMusicData() {
|
||||||
return {
|
final musicId = _isUnlock && _pendingMusics.isNotEmpty
|
||||||
'musicId': _isUnlock
|
? _pendingMusics.first.musicId
|
||||||
? (_pendingMusics.isNotEmpty
|
: UserAllPayloadBuilder.placeholderMusicId;
|
||||||
? _pendingMusics.first.musicId
|
return UserAllPayloadBuilder.placeholderMusicData(musicId: musicId);
|
||||||
: _defaultMusicId)
|
|
||||||
: _defaultMusicId,
|
|
||||||
'level': 0,
|
|
||||||
'playCount': 1,
|
|
||||||
'achievement': 0,
|
|
||||||
'comboStatus': 0,
|
|
||||||
'syncStatus': 0,
|
|
||||||
'deluxscoreMax': 0,
|
|
||||||
'scoreRank': 0,
|
|
||||||
'extNum1': 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 汇总成 wire 上的 userItemList 行。
|
/// 汇总成 wire 上的 userItemList 行。
|
||||||
@@ -307,8 +247,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final data = await service.fetchUserAllData(widget.userId);
|
final data = await service.fetchUserAllData(widget.userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -327,34 +266,25 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
Future<void> _run() async {
|
Future<void> _run() async {
|
||||||
final validationError = _validateInputs();
|
final validationError = _validateInputs();
|
||||||
if (validationError != null) {
|
if (validationError != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(validationError);
|
||||||
SnackBar(content: Text(validationError)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!TitleServerConfigHolder().isConfigured) {
|
if (!TitleServerConfigHolder().isConfigured) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(AppStrings.ticketNotConfigured);
|
||||||
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final loginDateTime = widget.loginDateTime;
|
final loginDateTime = widget.loginDateTime;
|
||||||
final loginId = widget.loginId;
|
final loginId = widget.loginId;
|
||||||
if (loginDateTime == null || loginId == null) {
|
if (loginDateTime == null || loginId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(_notLoggedInMessage);
|
||||||
SnackBar(content: Text(_notLoggedInMessage)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_cooldownRemaining > 0) {
|
if (cooldownRemaining > 0) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(_cooldownNotice(cooldownRemaining));
|
||||||
SnackBar(content: Text(_cooldownNotice(_cooldownRemaining))),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_running = true;
|
_running = true;
|
||||||
@@ -364,14 +294,14 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
try {
|
try {
|
||||||
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
|
Map<String, Map<String, dynamic>> data = _userAllData ?? const {};
|
||||||
if (data.isEmpty) {
|
if (data.isEmpty) {
|
||||||
_updateStep(MusicRiskStep.fetchData);
|
_updateStep(RiskStep.fetchData);
|
||||||
data = await service.fetchUserAllData(widget.userId);
|
data = await service.fetchUserAllData(widget.userId);
|
||||||
if (mounted) setState(() => _userAllData = data);
|
if (mounted) setState(() => _userAllData = data);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStep(MusicRiskStep.upload);
|
_updateStep(RiskStep.upload);
|
||||||
final musicData = _buildMusicData();
|
final musicData = _buildMusicData();
|
||||||
final builder = UserAllPayloadBuilder(config);
|
final builder = UserAllPayloadBuilder(service.config);
|
||||||
final packet = builder.build(
|
final packet = builder.build(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
@@ -388,7 +318,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
await service.upsertUserAll(packet, widget.userId);
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
|
|
||||||
_updateStep(
|
_updateStep(
|
||||||
MusicRiskStep.complete,
|
RiskStep.complete,
|
||||||
_isUnlock
|
_isUnlock
|
||||||
? AppStrings.unlockMusicSuccess
|
? AppStrings.unlockMusicSuccess
|
||||||
: AppStrings.collectiblesSuccess,
|
: AppStrings.collectiblesSuccess,
|
||||||
@@ -398,14 +328,14 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
|
// 让成功状态先显示一下,再交给 HomePage 结算、退登并 pop 回主标题。
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_updateStep(MusicRiskStep.logout, AppStrings.exitingToTitle);
|
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||||
await widget.onExitToTitle!();
|
await widget.onExitToTitle!();
|
||||||
}
|
}
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(MusicRiskStep.failed, e.message);
|
_updateStep(RiskStep.failed, e.message);
|
||||||
setState(() => _error = e.message);
|
setState(() => _error = e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_updateStep(MusicRiskStep.failed, e.toString());
|
_updateStep(RiskStep.failed, e.toString());
|
||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _running = false);
|
if (mounted) setState(() => _running = false);
|
||||||
@@ -448,9 +378,17 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
context,
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
if (!isLoggedIn)
|
||||||
if (widget.loginDateTime != null && _cooldownRemaining > 0)
|
AppNotice(
|
||||||
_buildCooldownBanner(theme),
|
_notLoggedInMessage,
|
||||||
|
error: true,
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
),
|
||||||
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
|
AppNotice(
|
||||||
|
_cooldownNotice(cooldownRemaining),
|
||||||
|
icon: Icons.timer_outlined,
|
||||||
|
),
|
||||||
_buildDescCard(theme),
|
_buildDescCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (_isUnlock)
|
if (_isUnlock)
|
||||||
@@ -460,12 +398,30 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildFetchCard(theme),
|
_buildFetchCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildAutoLogoutToggle(theme),
|
AutoLogoutToggle(
|
||||||
|
value: _autoLogout,
|
||||||
|
enabled: widget.onExitToTitle != null && !_running,
|
||||||
|
onChanged: (v) => setState(() => _autoLogout = v),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildRunButton(theme),
|
_buildRunButton(theme),
|
||||||
if (_step != MusicRiskStep.idle) ...[
|
if (_step != RiskStep.idle) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildProgressCard(theme),
|
StepProgressCard(
|
||||||
|
step: _step,
|
||||||
|
message: _stepMessage,
|
||||||
|
error: _error,
|
||||||
|
steps: [
|
||||||
|
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||||
|
(
|
||||||
|
RiskStep.upload,
|
||||||
|
_isUnlock
|
||||||
|
? AppStrings.unlockStepUpload
|
||||||
|
: AppStrings.collectiblesStepUpload,
|
||||||
|
),
|
||||||
|
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -473,60 +429,6 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
|
||||||
_notLoggedInMessage,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
_cooldownNotice(_cooldownRemaining),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDescCard(ThemeData theme) {
|
Widget _buildDescCard(ThemeData theme) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -913,42 +815,8 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAutoLogoutToggle(ThemeData theme) {
|
|
||||||
final enabled = widget.onExitToTitle != 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.autoLogoutAndExit,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: enabled
|
|
||||||
? null
|
|
||||||
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRunButton(ThemeData theme) {
|
Widget _buildRunButton(ThemeData theme) {
|
||||||
final onCooldown = _cooldownRemaining > 0;
|
final onCooldown = cooldownRemaining > 0;
|
||||||
final canRun = widget.loginDateTime != null &&
|
final canRun = widget.loginDateTime != null &&
|
||||||
widget.loginId != null &&
|
widget.loginId != null &&
|
||||||
!_running &&
|
!_running &&
|
||||||
@@ -974,7 +842,7 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
_running
|
_running
|
||||||
? AppStrings.unlockRunning
|
? AppStrings.unlockRunning
|
||||||
: onCooldown
|
: onCooldown
|
||||||
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
|
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||||
: _isUnlock
|
: _isUnlock
|
||||||
? AppStrings.unlockMusicRun
|
? AppStrings.unlockMusicRun
|
||||||
: AppStrings.collectiblesRun,
|
: AppStrings.collectiblesRun,
|
||||||
@@ -989,125 +857,6 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProgressCard(ThemeData theme) {
|
|
||||||
final isFailed = _step == MusicRiskStep.failed;
|
|
||||||
final isDone = _step == MusicRiskStep.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(MusicRiskStep.fetchData, AppStrings.unlockStepFetch, theme),
|
|
||||||
_stepRow(
|
|
||||||
MusicRiskStep.upload,
|
|
||||||
_isUnlock
|
|
||||||
? AppStrings.unlockStepUpload
|
|
||||||
: AppStrings.collectiblesStepUpload,
|
|
||||||
theme,
|
|
||||||
),
|
|
||||||
if (_autoLogout)
|
|
||||||
_stepRow(MusicRiskStep.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(MusicRiskStep step, String label, ThemeData theme) {
|
|
||||||
IconData icon;
|
|
||||||
Color? color;
|
|
||||||
|
|
||||||
if (_step == MusicRiskStep.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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDropdown({
|
Widget _buildDropdown({
|
||||||
required int value,
|
required int value,
|
||||||
required String label,
|
required String label,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -7,6 +6,10 @@ import '../config/strings.dart';
|
|||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
import '../services/user_all_payload_builder.dart';
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/cooldown_mixin.dart';
|
||||||
|
import '../widgets/step_progress.dart';
|
||||||
|
|
||||||
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
|
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
|
||||||
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
|
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
|
||||||
@@ -65,8 +68,6 @@ class NumberPatchPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
|
||||||
|
|
||||||
class NumberPatchView extends StatefulWidget {
|
class NumberPatchView extends StatefulWidget {
|
||||||
final int userId;
|
final int userId;
|
||||||
final String? cookies;
|
final String? cookies;
|
||||||
@@ -96,11 +97,8 @@ class NumberPatchView extends StatefulWidget {
|
|||||||
State<NumberPatchView> createState() => _NumberPatchViewState();
|
State<NumberPatchView> createState() => _NumberPatchViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NumberPatchViewState extends State<NumberPatchView> {
|
class _NumberPatchViewState extends State<NumberPatchView>
|
||||||
/// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩,
|
with CooldownMixin<NumberPatchView> {
|
||||||
/// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。
|
|
||||||
static const int _placeholderMusicId = 11538;
|
|
||||||
|
|
||||||
final _valueController = TextEditingController();
|
final _valueController = TextEditingController();
|
||||||
|
|
||||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||||
@@ -109,68 +107,21 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _autoLogout = true;
|
bool _autoLogout = true;
|
||||||
_Step _step = _Step.idle;
|
RiskStep _step = RiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
|
|
||||||
Timer? _cooldownTimer;
|
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
bool get _isRating => widget.mode == NumberPatchMode.rating;
|
bool get _isRating => widget.mode == NumberPatchMode.rating;
|
||||||
bool get _loggedIn => widget.loginDateTime != null;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant NumberPatchView oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
_valueController.dispose();
|
_valueController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncCooldown() {
|
void _snack(String message) => context.showSnack(message);
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
final loginDateTime = widget.loginDateTime;
|
|
||||||
if (loginDateTime == null) {
|
|
||||||
setState(() => _cooldownRemaining = 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _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 remaining =
|
|
||||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
|
||||||
return remaining < 0 ? 0 : remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _snack(String message) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _toInt(dynamic v) {
|
static int _toInt(dynamic v) {
|
||||||
if (v is int) return v;
|
if (v is int) return v;
|
||||||
@@ -225,8 +176,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final data = await service.fetchUserAllData(widget.userId);
|
final data = await service.fetchUserAllData(widget.userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -251,8 +201,8 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
_snack(AppStrings.numberPatchNotLoggedIn);
|
_snack(AppStrings.numberPatchNotLoggedIn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_cooldownRemaining > 0) {
|
if (cooldownRemaining > 0) {
|
||||||
_snack(AppStrings.numberPatchCooldownNotice(_cooldownRemaining));
|
_snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final inputError = _checkInput();
|
final inputError = _checkInput();
|
||||||
@@ -265,23 +215,22 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
setState(() => _running = true);
|
setState(() => _running = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
final builder = UserAllPayloadBuilder(service.config);
|
||||||
final builder = UserAllPayloadBuilder(config);
|
|
||||||
|
|
||||||
var data = _userAllData;
|
var data = _userAllData;
|
||||||
if (data.isEmpty) {
|
if (data.isEmpty) {
|
||||||
_updateStep(_Step.fetchData);
|
_updateStep(RiskStep.fetchData);
|
||||||
data = await service.fetchUserAllData(widget.userId);
|
data = await service.fetchUserAllData(widget.userId);
|
||||||
if (mounted) setState(() => _userAllData = data);
|
if (mounted) setState(() => _userAllData = data);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStep(_Step.upload);
|
_updateStep(RiskStep.upload);
|
||||||
final packet = builder.build(
|
final packet = builder.build(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
loginDateTime: loginDateTime,
|
loginDateTime: loginDateTime,
|
||||||
musicData: _placeholderMusicData(),
|
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||||
generalUserInfo: data,
|
generalUserInfo: data,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -304,36 +253,24 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await service.upsertUserAll(packet, widget.userId);
|
await service.upsertUserAll(packet, widget.userId);
|
||||||
_updateStep(_Step.complete, resultMessage);
|
_updateStep(RiskStep.complete, resultMessage);
|
||||||
|
|
||||||
if (_autoLogout && widget.onExitToTitle != null) {
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||||
await widget.onExitToTitle!();
|
await widget.onExitToTitle!();
|
||||||
}
|
}
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(_Step.failed, e.message);
|
_updateStep(RiskStep.failed, e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_updateStep(_Step.failed, '$e');
|
_updateStep(RiskStep.failed, '$e');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _running = false);
|
if (mounted) setState(() => _running = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _placeholderMusicData() => {
|
void _updateStep(RiskStep step, [String? message]) {
|
||||||
'musicId': _placeholderMusicId,
|
|
||||||
'level': 0,
|
|
||||||
'playCount': 1,
|
|
||||||
'achievement': 0,
|
|
||||||
'comboStatus': 0,
|
|
||||||
'syncStatus': 0,
|
|
||||||
'deluxscoreMax': 0,
|
|
||||||
'scoreRank': 0,
|
|
||||||
'extNum1': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
void _updateStep(_Step step, [String? message]) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = step;
|
_step = step;
|
||||||
@@ -352,12 +289,11 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!_loggedIn)
|
if (!isLoggedIn)
|
||||||
_notice(theme, AppStrings.numberPatchNotLoggedIn, error: true),
|
const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true),
|
||||||
if (_loggedIn && _cooldownRemaining > 0)
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
_notice(
|
AppNotice(
|
||||||
theme,
|
AppStrings.numberPatchCooldownNotice(cooldownRemaining),
|
||||||
AppStrings.numberPatchCooldownNotice(_cooldownRemaining),
|
|
||||||
),
|
),
|
||||||
_descCard(theme),
|
_descCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -365,12 +301,24 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_fetchCard(theme),
|
_fetchCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_autoLogoutToggle(theme),
|
AutoLogoutToggle(
|
||||||
|
value: _autoLogout,
|
||||||
|
enabled: widget.onExitToTitle != null && !_running,
|
||||||
|
onChanged: (v) => setState(() => _autoLogout = v),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_runButton(theme),
|
_runButton(theme),
|
||||||
if (_step != _Step.idle) ...[
|
if (_step != RiskStep.idle) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_progressCard(theme),
|
StepProgressCard(
|
||||||
|
step: _step,
|
||||||
|
message: _stepMessage,
|
||||||
|
steps: [
|
||||||
|
(RiskStep.fetchData, AppStrings.unlockStepFetch),
|
||||||
|
(RiskStep.upload, AppStrings.numberPatchStepUpload),
|
||||||
|
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -378,65 +326,14 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _card(ThemeData theme, {required Widget child}) {
|
|
||||||
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: child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _descCard(ThemeData theme) {
|
Widget _descCard(ThemeData theme) {
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
SectionTitle(
|
||||||
children: [
|
icon: _isRating ? Icons.speed : Icons.commute,
|
||||||
Icon(
|
title: widget.featureTitle,
|
||||||
_isRating ? Icons.speed : Icons.commute,
|
|
||||||
size: 18,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
widget.featureTitle,
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -455,8 +352,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
final enabled = !_running;
|
final enabled = !_running;
|
||||||
final hasInput = int.tryParse(_valueController.text.trim()) != null;
|
final hasInput = int.tryParse(_valueController.text.trim()) != null;
|
||||||
|
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -541,8 +437,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
? AppStrings.ratingCurrentValue
|
? AppStrings.ratingCurrentValue
|
||||||
: AppStrings.maiMileCurrentBalance;
|
: AppStrings.maiMileCurrentBalance;
|
||||||
|
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -596,38 +491,11 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _autoLogoutToggle(ThemeData theme) {
|
|
||||||
final enabled = widget.onExitToTitle != null && !_running;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(
|
|
||||||
value: _autoLogout,
|
|
||||||
onChanged: enabled
|
|
||||||
? (v) => setState(() => _autoLogout = v ?? false)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
AppStrings.autoLogoutAndExit,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _runButton(ThemeData theme) {
|
Widget _runButton(ThemeData theme) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _running || !_loggedIn ? null : _run,
|
onPressed: _running || !isLoggedIn ? null : _run,
|
||||||
icon: _running
|
icon: _running
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -647,73 +515,4 @@ class _NumberPatchViewState extends State<NumberPatchView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _progressCard(ThemeData theme) {
|
|
||||||
final isFailed = _step == _Step.failed;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_stepRow(theme, _Step.fetchData, AppStrings.unlockStepFetch),
|
|
||||||
_stepRow(theme, _Step.upload, AppStrings.numberPatchStepUpload),
|
|
||||||
if (_autoLogout)
|
|
||||||
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
|
||||||
if (_stepMessage.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_stepMessage,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
|
||||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
|
||||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
|
||||||
final done = order.indexOf(current) > order.indexOf(step);
|
|
||||||
final active = _step == step;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
done
|
|
||||||
? Icons.check_circle
|
|
||||||
: active
|
|
||||||
? Icons.radio_button_checked
|
|
||||||
: Icons.radio_button_unchecked,
|
|
||||||
size: 18,
|
|
||||||
color: done
|
|
||||||
? Colors.green
|
|
||||||
: active
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+52
-292
@@ -1,19 +1,13 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../config/responsive.dart';
|
import '../config/responsive.dart';
|
||||||
import '../config/strings.dart';
|
import '../config/strings.dart';
|
||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
enum TicketStep {
|
import '../widgets/app_notice.dart';
|
||||||
idle,
|
import '../widgets/cooldown_mixin.dart';
|
||||||
chargeTicket,
|
import '../widgets/step_progress.dart';
|
||||||
logout,
|
|
||||||
complete,
|
|
||||||
failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
class TicketPage extends StatefulWidget {
|
class TicketPage extends StatefulWidget {
|
||||||
final int userId;
|
final int userId;
|
||||||
@@ -40,14 +34,15 @@ class TicketPage extends StatefulWidget {
|
|||||||
State<TicketPage> createState() => _TicketPageState();
|
State<TicketPage> createState() => _TicketPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TicketPageState extends State<TicketPage> {
|
class _TicketPageState extends State<TicketPage>
|
||||||
|
with CooldownMixin<TicketPage> {
|
||||||
static const _ticketNameMap = {
|
static const _ticketNameMap = {
|
||||||
2: '2倍票',
|
2: '2倍票',
|
||||||
4: '4倍票',
|
4: '4倍票',
|
||||||
5: '5倍票'
|
5: '5倍票'
|
||||||
};
|
};
|
||||||
|
|
||||||
TicketStep _step = TicketStep.idle;
|
RiskStep _step = RiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
@@ -58,58 +53,13 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
String? _ticketsError;
|
String? _ticketsError;
|
||||||
int? _selectedTicketId;
|
int? _selectedTicketId;
|
||||||
|
|
||||||
Timer? _cooldownTimer;
|
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
String _ticketName(int chargeId) =>
|
String _ticketName(int chargeId) =>
|
||||||
_ticketNameMap[chargeId] ?? '票$chargeId';
|
_ticketNameMap[chargeId] ?? '票$chargeId';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
void _snack(String message) => context.showSnack(message);
|
||||||
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) {
|
Map<String, dynamic>? _findTicket(int chargeId) {
|
||||||
final tickets = _tickets;
|
final tickets = _tickets;
|
||||||
@@ -120,7 +70,7 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateStep(TicketStep step, [String? message]) {
|
void _updateStep(RiskStep step, [String? message]) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = step;
|
_step = step;
|
||||||
@@ -139,8 +89,7 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final data = await service.getUserCharge(widget.userId);
|
final data = await service.getUserCharge(widget.userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -160,44 +109,31 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
|
|
||||||
Future<void> _runTicket() async {
|
Future<void> _runTicket() async {
|
||||||
if (_selectedTicketId == null) {
|
if (_selectedTicketId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(AppStrings.ticketNotSelected);
|
||||||
const SnackBar(content: Text(AppStrings.ticketNotSelected)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!TitleServerConfigHolder().isConfigured) {
|
if (!TitleServerConfigHolder().isConfigured) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(AppStrings.ticketNotConfigured);
|
||||||
const SnackBar(content: Text(AppStrings.ticketNotConfigured)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final loginDateTime = widget.loginDateTime;
|
final loginDateTime = widget.loginDateTime;
|
||||||
if (loginDateTime == null) {
|
if (loginDateTime == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(AppStrings.ticketNotLoggedIn);
|
||||||
const SnackBar(content: Text(AppStrings.ticketNotLoggedIn)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_cooldownRemaining > 0) {
|
if (cooldownRemaining > 0) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(AppStrings.ticketCooldownNotice(cooldownRemaining));
|
||||||
SnackBar(
|
|
||||||
content: Text(AppStrings.ticketCooldownNotice(_cooldownRemaining)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectedTicket = _findTicket(_selectedTicketId!);
|
final selectedTicket = _findTicket(_selectedTicketId!);
|
||||||
if (selectedTicket != null &&
|
if (selectedTicket != null &&
|
||||||
(selectedTicket['stock'] as num?)?.toInt() != 0) {
|
(selectedTicket['stock'] as num?)?.toInt() != 0) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_snack(AppStrings.ticketStockNotEmpty);
|
||||||
const SnackBar(content: Text(AppStrings.ticketStockNotEmpty)),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final ticketId = _selectedTicketId!;
|
final ticketId = _selectedTicketId!;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -206,7 +142,7 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_updateStep(TicketStep.chargeTicket);
|
_updateStep(RiskStep.upload);
|
||||||
await service.upsertUserChargeLog(
|
await service.upsertUserChargeLog(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
ticketId: ticketId,
|
ticketId: ticketId,
|
||||||
@@ -215,20 +151,20 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (_autoLogout && widget.onLogoutRequested != null) {
|
if (_autoLogout && widget.onLogoutRequested != null) {
|
||||||
_updateStep(TicketStep.logout);
|
_updateStep(RiskStep.logout);
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
await widget.onLogoutRequested!();
|
await widget.onLogoutRequested!();
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStep(
|
_updateStep(
|
||||||
TicketStep.complete,
|
RiskStep.complete,
|
||||||
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
|
'${AppStrings.ticketUsed}: ${_ticketName(ticketId)}',
|
||||||
);
|
);
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(TicketStep.failed, e.message);
|
_updateStep(RiskStep.failed, e.message);
|
||||||
setState(() => _error = e.message);
|
setState(() => _error = e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_updateStep(TicketStep.failed, e.toString());
|
_updateStep(RiskStep.failed, e.toString());
|
||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _running = false);
|
if (mounted) setState(() => _running = false);
|
||||||
@@ -261,17 +197,38 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
context,
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.loginDateTime == null) _buildNotLoggedInBanner(theme),
|
if (!isLoggedIn)
|
||||||
if (widget.loginDateTime != null && _cooldownRemaining > 0)
|
const AppNotice(
|
||||||
_buildCooldownBanner(theme),
|
AppStrings.ticketNotLoggedIn,
|
||||||
|
error: true,
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
),
|
||||||
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
|
AppNotice(
|
||||||
|
AppStrings.ticketCooldownNotice(cooldownRemaining),
|
||||||
|
icon: Icons.timer_outlined,
|
||||||
|
),
|
||||||
_buildTicketListCard(theme),
|
_buildTicketListCard(theme),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildAutoLogoutToggle(theme),
|
AutoLogoutToggle(
|
||||||
|
value: _autoLogout,
|
||||||
|
enabled: widget.onLogoutRequested != null && !_running,
|
||||||
|
onChanged: (v) => setState(() => _autoLogout = v),
|
||||||
|
label: AppStrings.ticketAutoLogout,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildRunButton(theme),
|
_buildRunButton(theme),
|
||||||
if (_step != TicketStep.idle) ...[
|
if (_step != RiskStep.idle) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildProgressCard(theme),
|
StepProgressCard(
|
||||||
|
step: _step,
|
||||||
|
message: _stepMessage,
|
||||||
|
error: _error,
|
||||||
|
steps: [
|
||||||
|
(RiskStep.upload, AppStrings.stepChargeTicket),
|
||||||
|
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -279,94 +236,6 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
Widget _buildTicketListCard(ThemeData theme) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -545,7 +414,7 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
final selectedName = _selectedTicketId != null
|
final selectedName = _selectedTicketId != null
|
||||||
? _ticketName(_selectedTicketId!)
|
? _ticketName(_selectedTicketId!)
|
||||||
: null;
|
: null;
|
||||||
final onCooldown = _cooldownRemaining > 0;
|
final onCooldown = cooldownRemaining > 0;
|
||||||
final canRun = widget.loginDateTime != null && !_running && !onCooldown;
|
final canRun = widget.loginDateTime != null && !_running && !onCooldown;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -583,7 +452,7 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
_running
|
_running
|
||||||
? AppStrings.runningTicket
|
? AppStrings.runningTicket
|
||||||
: onCooldown
|
: onCooldown
|
||||||
? AppStrings.ticketCooldownCountdown(_cooldownRemaining)
|
? AppStrings.ticketCooldownCountdown(cooldownRemaining)
|
||||||
: AppStrings.runTicket,
|
: AppStrings.runTicket,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -598,113 +467,4 @@ class _TicketPageState extends State<TicketPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -8,6 +7,10 @@ import '../config/title_server_config.dart';
|
|||||||
import '../models/user_character.dart';
|
import '../models/user_character.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
import '../services/user_all_payload_builder.dart';
|
import '../services/user_all_payload_builder.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/cooldown_mixin.dart';
|
||||||
|
import '../widgets/step_progress.dart';
|
||||||
|
|
||||||
/// 旅行伙伴页:发放角色 + 编组出战槽位。
|
/// 旅行伙伴页:发放角色 + 编组出战槽位。
|
||||||
///
|
///
|
||||||
@@ -40,12 +43,8 @@ class TravelPartnerPage extends StatefulWidget {
|
|||||||
State<TravelPartnerPage> createState() => _TravelPartnerPageState();
|
State<TravelPartnerPage> createState() => _TravelPartnerPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _Step { idle, fetchData, upload, logout, complete, failed }
|
class _TravelPartnerPageState extends State<TravelPartnerPage>
|
||||||
|
with CooldownMixin<TravelPartnerPage> {
|
||||||
class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|
||||||
/// 与解锁/收藏品一致:这些操作不写真实成绩,playlog 沿用同一条占位记录。
|
|
||||||
static const int _placeholderMusicId = 11538;
|
|
||||||
|
|
||||||
final _grantIdController = TextEditingController();
|
final _grantIdController = TextEditingController();
|
||||||
|
|
||||||
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
|
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
|
||||||
@@ -62,31 +61,14 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _autoLogout = true;
|
bool _autoLogout = true;
|
||||||
_Step _step = _Step.idle;
|
RiskStep _step = RiskStep.idle;
|
||||||
String _stepMessage = '';
|
String _stepMessage = '';
|
||||||
|
|
||||||
Timer? _cooldownTimer;
|
|
||||||
int _cooldownRemaining = 0;
|
|
||||||
|
|
||||||
bool get _loggedIn => widget.loginDateTime != null;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
super.initState();
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant TravelPartnerPage oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.loginDateTime != widget.loginDateTime) {
|
|
||||||
_syncCooldown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
_grantIdController.dispose();
|
_grantIdController.dispose();
|
||||||
for (final c in _slotLevelControllers) {
|
for (final c in _slotLevelControllers) {
|
||||||
c.dispose();
|
c.dispose();
|
||||||
@@ -94,38 +76,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncCooldown() {
|
void _snack(String message) => context.showSnack(message);
|
||||||
_cooldownTimer?.cancel();
|
|
||||||
final loginDateTime = widget.loginDateTime;
|
|
||||||
if (loginDateTime == null) {
|
|
||||||
setState(() => _cooldownRemaining = 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _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 remaining =
|
|
||||||
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
|
||||||
return remaining < 0 ? 0 : remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _snack(String message) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(message)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isAvailable(int characterId) =>
|
bool _isAvailable(int characterId) =>
|
||||||
characterId != 0 &&
|
characterId != 0 &&
|
||||||
@@ -137,8 +88,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
service.getUserCharacters(widget.userId),
|
service.getUserCharacters(widget.userId),
|
||||||
service.fetchUserAllData(widget.userId),
|
service.fetchUserAllData(widget.userId),
|
||||||
@@ -252,8 +202,8 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
_snack(AppStrings.travelPartnerNotLoggedIn);
|
_snack(AppStrings.travelPartnerNotLoggedIn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_cooldownRemaining > 0) {
|
if (cooldownRemaining > 0) {
|
||||||
_snack(AppStrings.travelPartnerCooldownNotice(_cooldownRemaining));
|
_snack(AppStrings.travelPartnerCooldownNotice(cooldownRemaining));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final validationError = _validate();
|
final validationError = _validate();
|
||||||
@@ -265,23 +215,22 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
setState(() => _running = true);
|
setState(() => _running = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final config = TitleServerConfigHolder().config!;
|
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
|
||||||
final service = TitleApiService(config, cookies: widget.cookies);
|
final builder = UserAllPayloadBuilder(service.config);
|
||||||
final builder = UserAllPayloadBuilder(config);
|
|
||||||
|
|
||||||
var data = _userAllData;
|
var data = _userAllData;
|
||||||
if (data.isEmpty) {
|
if (data.isEmpty) {
|
||||||
_updateStep(_Step.fetchData);
|
_updateStep(RiskStep.fetchData);
|
||||||
data = await service.fetchUserAllData(widget.userId);
|
data = await service.fetchUserAllData(widget.userId);
|
||||||
if (mounted) setState(() => _userAllData = data);
|
if (mounted) setState(() => _userAllData = data);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStep(_Step.upload);
|
_updateStep(RiskStep.upload);
|
||||||
final packet = builder.build(
|
final packet = builder.build(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
loginDateTime: loginDateTime,
|
loginDateTime: loginDateTime,
|
||||||
musicData: _placeholderMusicData(),
|
musicData: UserAllPayloadBuilder.placeholderMusicData(),
|
||||||
generalUserInfo: data,
|
generalUserInfo: data,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -325,7 +274,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
|
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
|
||||||
// 被客户端 CharacterSelectProces 静默跳过。
|
// 被客户端 CharacterSelectProces 静默跳过。
|
||||||
_updateStep(
|
_updateStep(
|
||||||
_Step.complete,
|
RiskStep.complete,
|
||||||
_pendingGrant.isEmpty
|
_pendingGrant.isEmpty
|
||||||
? AppStrings.travelPartnerSuccess
|
? AppStrings.travelPartnerSuccess
|
||||||
: await _verifyGranted(service),
|
: await _verifyGranted(service),
|
||||||
@@ -334,13 +283,13 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
if (_autoLogout && widget.onExitToTitle != null) {
|
if (_autoLogout && widget.onExitToTitle != null) {
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_updateStep(_Step.logout, AppStrings.exitingToTitle);
|
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
|
||||||
await widget.onExitToTitle!();
|
await widget.onExitToTitle!();
|
||||||
}
|
}
|
||||||
} on TitleApiException catch (e) {
|
} on TitleApiException catch (e) {
|
||||||
_updateStep(_Step.failed, e.message);
|
_updateStep(RiskStep.failed, e.message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_updateStep(_Step.failed, '$e');
|
_updateStep(RiskStep.failed, '$e');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _running = false);
|
if (mounted) setState(() => _running = false);
|
||||||
}
|
}
|
||||||
@@ -362,19 +311,7 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
|
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _placeholderMusicData() => {
|
void _updateStep(RiskStep step, [String? message]) {
|
||||||
'musicId': _placeholderMusicId,
|
|
||||||
'level': 0,
|
|
||||||
'playCount': 1,
|
|
||||||
'achievement': 0,
|
|
||||||
'comboStatus': 0,
|
|
||||||
'syncStatus': 0,
|
|
||||||
'deluxscoreMax': 0,
|
|
||||||
'scoreRank': 0,
|
|
||||||
'extNum1': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
void _updateStep(_Step step, [String? message]) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_step = step;
|
_step = step;
|
||||||
@@ -397,22 +334,19 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!_loggedIn)
|
if (!isLoggedIn)
|
||||||
_notice(
|
const AppNotice(
|
||||||
theme,
|
|
||||||
AppStrings.travelPartnerNotLoggedIn,
|
AppStrings.travelPartnerNotLoggedIn,
|
||||||
error: true,
|
error: true,
|
||||||
),
|
),
|
||||||
if (_loggedIn && _cooldownRemaining > 0)
|
if (isLoggedIn && cooldownRemaining > 0)
|
||||||
_notice(
|
AppNotice(
|
||||||
theme,
|
AppStrings.travelPartnerCooldownNotice(cooldownRemaining),
|
||||||
AppStrings.travelPartnerCooldownNotice(_cooldownRemaining),
|
|
||||||
),
|
),
|
||||||
_sectionCard(
|
_sectionCard(
|
||||||
theme,
|
theme,
|
||||||
icon: Icons.card_giftcard,
|
icon: Icons.card_giftcard,
|
||||||
title: AppStrings.travelPartnerFeatureDesc,
|
title: AppStrings.travelPartnerFeatureDesc,
|
||||||
children: const [],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_fetchCard(theme),
|
_fetchCard(theme),
|
||||||
@@ -423,12 +357,30 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_ownedCard(theme),
|
_ownedCard(theme),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_autoLogoutToggle(theme),
|
AutoLogoutToggle(
|
||||||
|
value: _autoLogout,
|
||||||
|
enabled: widget.onExitToTitle != null && !_running,
|
||||||
|
onChanged: (v) => setState(() => _autoLogout = v),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_runButton(theme),
|
_runButton(theme),
|
||||||
if (_step != _Step.idle) ...[
|
if (_step != RiskStep.idle) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_progressCard(theme),
|
StepProgressCard(
|
||||||
|
step: _step,
|
||||||
|
message: _stepMessage,
|
||||||
|
steps: [
|
||||||
|
(
|
||||||
|
RiskStep.fetchData,
|
||||||
|
AppStrings.travelPartnerStepFetch,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RiskStep.upload,
|
||||||
|
AppStrings.travelPartnerStepUpload,
|
||||||
|
),
|
||||||
|
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -437,77 +389,26 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _notice(ThemeData theme, String text, {bool error = false}) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: error
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _sectionCard(
|
Widget _sectionCard(
|
||||||
ThemeData theme, {
|
ThemeData theme, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
required List<Widget> children,
|
List<Widget> children = const [],
|
||||||
}) {
|
}) {
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
SectionTitle(icon: icon, title: title),
|
||||||
children: [
|
|
||||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
...children,
|
...children,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _card(ThemeData theme, {required Widget child}) {
|
|
||||||
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: child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _fetchCard(ThemeData theme) {
|
Widget _fetchCard(ThemeData theme) {
|
||||||
final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty;
|
final hasData = _userAllData.isNotEmpty && _owned.isNotEmpty;
|
||||||
return _card(
|
return AppCard(
|
||||||
theme,
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -806,38 +707,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _autoLogoutToggle(ThemeData theme) {
|
|
||||||
final enabled = widget.onExitToTitle != null && !_running;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(
|
|
||||||
value: _autoLogout,
|
|
||||||
onChanged: enabled
|
|
||||||
? (v) => setState(() => _autoLogout = v ?? false)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
AppStrings.autoLogoutAndExit,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _runButton(ThemeData theme) {
|
Widget _runButton(ThemeData theme) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _running || !_loggedIn ? null : _run,
|
onPressed: _running || !isLoggedIn ? null : _run,
|
||||||
icon: _running
|
icon: _running
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -857,73 +731,4 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _progressCard(ThemeData theme) {
|
|
||||||
final isFailed = _step == _Step.failed;
|
|
||||||
return _card(
|
|
||||||
theme,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_stepRow(theme, _Step.fetchData, AppStrings.travelPartnerStepFetch),
|
|
||||||
_stepRow(theme, _Step.upload, AppStrings.travelPartnerStepUpload),
|
|
||||||
if (_autoLogout)
|
|
||||||
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
|
|
||||||
if (_stepMessage.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.errorContainer
|
|
||||||
: theme.colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_stepMessage,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: isFailed
|
|
||||||
? theme.colorScheme.onErrorContainer
|
|
||||||
: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _stepRow(ThemeData theme, _Step step, String label) {
|
|
||||||
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
|
|
||||||
final current = _step == _Step.failed ? _Step.upload : _step;
|
|
||||||
final done = order.indexOf(current) > order.indexOf(step);
|
|
||||||
final active = _step == step;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
done
|
|
||||||
? Icons.check_circle
|
|
||||||
: active
|
|
||||||
? Icons.radio_button_checked
|
|
||||||
: Icons.radio_button_unchecked,
|
|
||||||
size: 18,
|
|
||||||
color: done
|
|
||||||
? Colors.green
|
|
||||||
: active
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 传输层调试日志。
|
||||||
|
///
|
||||||
|
/// 之前 `TitleApiService` / `ApiService` 里是 30 多处裸 `print` + `avoid_print`
|
||||||
|
/// 忽略注释,把请求包、响应包、`token`、`JSESSIONID` 全打到 stdout。这里统一
|
||||||
|
/// 收口:
|
||||||
|
/// - 默认只在 debug 构建输出([enabled] 跟随 [kDebugMode]),release 下静默;
|
||||||
|
/// - 需要单独抓包时可以设置 `ApiLog.enabled = true`。
|
||||||
|
class ApiLog {
|
||||||
|
ApiLog._();
|
||||||
|
|
||||||
|
static bool enabled = kDebugMode;
|
||||||
|
|
||||||
|
static void log(String message) {
|
||||||
|
if (enabled) debugPrint(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
|
import 'api_log.dart';
|
||||||
|
|
||||||
class LoginResult {
|
class LoginResult {
|
||||||
final bool success;
|
final bool success;
|
||||||
@@ -71,10 +72,8 @@ class ApiService {
|
|||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ignore: avoid_print
|
ApiLog.log('[login] POST $_aimeUrl');
|
||||||
print('[login] POST $_aimeUrl');
|
ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[login] chipId=$chipId openGameID=$_openGameID');
|
|
||||||
|
|
||||||
final response = await http
|
final response = await http
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:pointycastle/export.dart';
|
|||||||
|
|
||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../models/user_data.dart';
|
import '../models/user_data.dart';
|
||||||
|
import 'api_log.dart';
|
||||||
import '../models/user_character.dart';
|
import '../models/user_character.dart';
|
||||||
import '../models/user_kaleidx_scope.dart';
|
import '../models/user_kaleidx_scope.dart';
|
||||||
import '../models/user_preview.dart';
|
import '../models/user_preview.dart';
|
||||||
@@ -45,8 +46,21 @@ class TitleApiService {
|
|||||||
|
|
||||||
TitleApiService(this._config, {this._cookies});
|
TitleApiService(this._config, {this._cookies});
|
||||||
|
|
||||||
|
/// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。
|
||||||
|
///
|
||||||
|
/// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`,
|
||||||
|
/// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。
|
||||||
|
static TitleApiService? fromHolder({String? cookies}) {
|
||||||
|
final holder = TitleServerConfigHolder();
|
||||||
|
if (!holder.isConfigured) return null;
|
||||||
|
return TitleApiService(holder.config!, cookies: cookies);
|
||||||
|
}
|
||||||
|
|
||||||
String? get cookies => _cookies;
|
String? get cookies => _cookies;
|
||||||
|
|
||||||
|
/// 组装 payload 时需要同一份配置(地点 / 区域 / clientId)。
|
||||||
|
TitleServerConfig get config => _config;
|
||||||
|
|
||||||
Uint8List _aesEncrypt(Uint8List plaintext) {
|
Uint8List _aesEncrypt(Uint8List plaintext) {
|
||||||
final cipher = PaddedBlockCipherImpl(
|
final cipher = PaddedBlockCipherImpl(
|
||||||
PKCS7Padding(),
|
PKCS7Padding(),
|
||||||
@@ -107,8 +121,7 @@ class TitleApiService {
|
|||||||
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
|
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
|
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
|
||||||
// ignore: avoid_print
|
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
|
||||||
print('[$apiName] >>> Captured session cookie: $_cookies');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,31 +148,23 @@ class TitleApiService {
|
|||||||
Future<Map<String, dynamic>> _callApi(
|
Future<Map<String, dynamic>> _callApi(
|
||||||
String apiName,
|
String apiName,
|
||||||
Map<String, dynamic> packet,
|
Map<String, dynamic> packet,
|
||||||
int userId,
|
int userId, {
|
||||||
) async {
|
bool captureRaw = false,
|
||||||
|
}) async {
|
||||||
final hash = _buildHash(apiName);
|
final hash = _buildHash(apiName);
|
||||||
final body = _buildRequestBody(packet);
|
final body = _buildRequestBody(packet);
|
||||||
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
final baseUrl = _normalizeUrl(_config.titleServerUrl);
|
||||||
final url = Uri.parse('$baseUrl/$hash');
|
final url = Uri.parse('$baseUrl/$hash');
|
||||||
|
|
||||||
// ignore: avoid_print
|
ApiLog.log('══════════════════════════════════════');
|
||||||
print('══════════════════════════════════════');
|
ApiLog.log('[$apiName] >>> REQUEST >>>');
|
||||||
// ignore: avoid_print
|
ApiLog.log('[$apiName] URL: $url');
|
||||||
print('[$apiName] >>> REQUEST >>>');
|
ApiLog.log('[$apiName] userId: $userId');
|
||||||
// ignore: avoid_print
|
ApiLog.log('[$apiName] hash: $hash');
|
||||||
print('[$apiName] URL: $url');
|
ApiLog.log('[$apiName] body (encrypted): ${body.length} bytes');
|
||||||
// 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);
|
final packetStr = const JsonEncoder.withIndent(' ').convert(packet);
|
||||||
// ignore: avoid_print
|
ApiLog.log('[$apiName] packet (plain):');
|
||||||
print('[$apiName] packet (plain):');
|
ApiLog.log(packetStr);
|
||||||
// ignore: avoid_print
|
|
||||||
print(packetStr);
|
|
||||||
|
|
||||||
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
|
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{
|
||||||
@@ -174,8 +179,7 @@ class TitleApiService {
|
|||||||
final cookies = _cookies;
|
final cookies = _cookies;
|
||||||
if (cookies != null) {
|
if (cookies != null) {
|
||||||
headers['Cookie'] = cookies;
|
headers['Cookie'] = cookies;
|
||||||
// ignore: avoid_print
|
ApiLog.log('[$apiName] Cookie: $cookies');
|
||||||
print('[$apiName] Cookie: $cookies');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
final response = await http
|
||||||
@@ -188,10 +192,8 @@ class TitleApiService {
|
|||||||
|
|
||||||
_captureCookiesFromResponse(response, apiName);
|
_captureCookiesFromResponse(response, apiName);
|
||||||
|
|
||||||
// ignore: avoid_print
|
ApiLog.log('[$apiName] HTTP status: ${response.statusCode}');
|
||||||
print('[$apiName] HTTP status: ${response.statusCode}');
|
ApiLog.log('[$apiName] response bytes: ${response.bodyBytes.length}');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[$apiName] response bytes: ${response.bodyBytes.length}');
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw TitleApiException('$apiName returned ${response.statusCode}');
|
throw TitleApiException('$apiName returned ${response.statusCode}');
|
||||||
@@ -203,12 +205,10 @@ class TitleApiService {
|
|||||||
|
|
||||||
final json = _processResponseBody(response.bodyBytes);
|
final json = _processResponseBody(response.bodyBytes);
|
||||||
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
final raw = const JsonEncoder.withIndent(' ').convert(json);
|
||||||
// ignore: avoid_print
|
if (captureRaw) lastRawResponse = raw;
|
||||||
print('[$apiName] <<< RESPONSE <<<');
|
ApiLog.log('[$apiName] <<< RESPONSE <<<');
|
||||||
// ignore: avoid_print
|
ApiLog.log(raw);
|
||||||
print(raw);
|
ApiLog.log('══════════════════════════════════════');
|
||||||
// ignore: avoid_print
|
|
||||||
print('══════════════════════════════════════');
|
|
||||||
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -243,99 +243,20 @@ class TitleApiService {
|
|||||||
required int userId,
|
required int userId,
|
||||||
required String token,
|
required String token,
|
||||||
}) async {
|
}) async {
|
||||||
// ignore: avoid_print
|
// 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi
|
||||||
print('[getUserPreview] ===== START =====');
|
// 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。
|
||||||
// ignore: avoid_print
|
final json = await _callApi(
|
||||||
print('[getUserPreview] userId=$userId');
|
'GetUserPreviewApi',
|
||||||
// ignore: avoid_print
|
{
|
||||||
print('[getUserPreview] token=$token');
|
'userId': userId,
|
||||||
// ignore: avoid_print
|
'segaIdAuthKey': '',
|
||||||
print('[getUserPreview] clientId=${_config.clientId}');
|
'token': token,
|
||||||
// ignore: avoid_print
|
'clientId': _config.clientId,
|
||||||
print('[getUserPreview] titleServerUrl=${_config.titleServerUrl}');
|
},
|
||||||
// ignore: avoid_print
|
userId,
|
||||||
print('[getUserPreview] aesKey.len=${_config.aesKeyBytes.length} aesIv.len=${_config.aesIvBytes.length}');
|
captureRaw: true,
|
||||||
|
);
|
||||||
const apiName = 'GetUserPreviewApi';
|
return UserPreviewDataBean.fromJson(json);
|
||||||
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',
|
|
||||||
'number': '0',
|
|
||||||
};
|
|
||||||
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 ----
|
// ---- Generic data APIs ----
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ class UserAllPayloadBuilder {
|
|||||||
static const int minCharacterLevel = 1;
|
static const int minCharacterLevel = 1;
|
||||||
static const int maxCharacterLevel = 999999;
|
static const int maxCharacterLevel = 999999;
|
||||||
|
|
||||||
|
/// 高危操作不写真实成绩,playlog 统一沿用这一条占位记录
|
||||||
|
/// (eaquira `settings.musicData` 的默认曲 Amber Chronicle)。
|
||||||
|
static const int placeholderMusicId = 11538;
|
||||||
|
|
||||||
|
/// 占位 `musicData`。解锁歌曲时用待解锁曲目覆盖 [musicId],其余字段照旧。
|
||||||
|
static Map<String, dynamic> placeholderMusicData({int? musicId}) => {
|
||||||
|
'musicId': musicId ?? placeholderMusicId,
|
||||||
|
'level': 0,
|
||||||
|
'playCount': 1,
|
||||||
|
'achievement': 0,
|
||||||
|
'comboStatus': 0,
|
||||||
|
'syncStatus': 0,
|
||||||
|
'deluxscoreMax': 0,
|
||||||
|
'scoreRank': 0,
|
||||||
|
'extNum1': 0,
|
||||||
|
};
|
||||||
|
|
||||||
final TitleServerConfig config;
|
final TitleServerConfig config;
|
||||||
|
|
||||||
const UserAllPayloadBuilder(this.config);
|
const UserAllPayloadBuilder(this.config);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../config/strings.dart';
|
||||||
|
|
||||||
|
/// 全站统一的卡片外壳:无阴影、16 圆角、outline 描边。
|
||||||
|
///
|
||||||
|
/// 之前每个页面各写一份 `Card(elevation: 0, shape: RoundedRectangleBorder(...))`,
|
||||||
|
/// 改一处圆角/描边要改十几个文件。
|
||||||
|
class AppCard extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final Color? borderColor;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
|
||||||
|
const AppCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.all(20),
|
||||||
|
this.borderColor,
|
||||||
|
this.margin,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
margin: margin,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: borderColor ??
|
||||||
|
theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(padding: padding, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卡片内的标题行:图标 + 彩色标题。
|
||||||
|
class SectionTitle extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Color? color;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
const SectionTitle({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.color,
|
||||||
|
this.iconSize = 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final tint = color ?? theme.colorScheme.primary;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: iconSize, color: tint),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: tint,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「完成后自动退出登录并返回标题页」勾选框。
|
||||||
|
///
|
||||||
|
/// 六个功能页原本各抄一份 `InkWell + Checkbox + Text`,现在统一成一个卡片。
|
||||||
|
class AutoLogoutToggle extends StatelessWidget {
|
||||||
|
final bool value;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<bool>? onChanged;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const AutoLogoutToggle({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onChanged,
|
||||||
|
this.label = AppStrings.autoLogoutAndExit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return AppCard(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: enabled ? () => onChanged?.call(!value) : null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: value,
|
||||||
|
onChanged:
|
||||||
|
enabled ? (v) => onChanged?.call(v ?? false) : null,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: enabled
|
||||||
|
? null
|
||||||
|
: theme.colorScheme.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 页面顶部横幅(未登录 / 冷却 / 错误)。
|
||||||
|
///
|
||||||
|
/// [error] 为 true 用 errorContainer,否则用 tertiaryContainer;传了 [icon]
|
||||||
|
/// 会渲染成「图标 + 文本」,否则退化成纯文本块(保持各页原有外观)。
|
||||||
|
class AppNotice extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final bool error;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
const AppNotice(
|
||||||
|
this.text, {
|
||||||
|
super.key,
|
||||||
|
this.error = false,
|
||||||
|
this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final bg = error
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5);
|
||||||
|
final fg = error
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onTertiaryContainer;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: icon == null
|
||||||
|
? Text(text, style: theme.textTheme.bodySmall?.copyWith(color: fg))
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: fg),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: fg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一的 SnackBar 入口,替代各页重复的 `ScaffoldMessenger...showSnackBar`。
|
||||||
|
extension AppSnack on BuildContext {
|
||||||
|
void showSnack(String message) {
|
||||||
|
ScaffoldMessenger.of(this).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../config/strings.dart';
|
||||||
|
|
||||||
|
/// 功能页共用的「登录后冷却」计时。
|
||||||
|
///
|
||||||
|
/// 六个功能页(功能票 / 数值修改 / 旅行伙伴 / 一键跑图 / 万花筒 / 解锁)原本
|
||||||
|
/// 各自抄了一份 `Timer.periodic` + `_computeRemaining`,这里收口成一个 mixin。
|
||||||
|
///
|
||||||
|
/// 用法:
|
||||||
|
/// ```dart
|
||||||
|
/// class _FooState extends State<Foo> with CooldownMixin<Foo> {
|
||||||
|
/// @override
|
||||||
|
/// int? get cooldownLoginDateTime => widget.loginDateTime;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
/// 页面**不要**再自己覆写 `initState` / `didUpdateWidget` / `dispose` 里与冷却
|
||||||
|
/// 相关的逻辑;如需覆写,记得调用 `super`。
|
||||||
|
mixin CooldownMixin<T extends StatefulWidget> on State<T> {
|
||||||
|
Timer? _cooldownTimer;
|
||||||
|
int _cooldownRemaining = 0;
|
||||||
|
int? _appliedLoginDateTime;
|
||||||
|
|
||||||
|
/// 当前会话的 `UserLoginApi` 时间戳;`null` 表示没有有效登录态。
|
||||||
|
int? get cooldownLoginDateTime;
|
||||||
|
|
||||||
|
bool get isLoggedIn => cooldownLoginDateTime != null;
|
||||||
|
|
||||||
|
int get cooldownRemaining => _cooldownRemaining;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_applyCooldown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant T oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (cooldownLoginDateTime != _appliedLoginDateTime) {
|
||||||
|
_applyCooldown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyCooldown() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
_appliedLoginDateTime = cooldownLoginDateTime;
|
||||||
|
|
||||||
|
final loginDateTime = _appliedLoginDateTime;
|
||||||
|
if (loginDateTime == null) {
|
||||||
|
_setRemaining(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setRemaining(_computeRemaining(loginDateTime));
|
||||||
|
if (_cooldownRemaining <= 0) return;
|
||||||
|
|
||||||
|
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_setRemaining(_computeRemaining(loginDateTime));
|
||||||
|
if (_cooldownRemaining <= 0) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setRemaining(int value) {
|
||||||
|
if (!mounted) {
|
||||||
|
_cooldownRemaining = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _cooldownRemaining = value);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _computeRemaining(int loginDateTime) {
|
||||||
|
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final remaining =
|
||||||
|
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_card.dart';
|
||||||
|
|
||||||
|
/// 高危操作共用的执行阶段。
|
||||||
|
///
|
||||||
|
/// 六个功能页原来各自定义 `_Step` / `TicketStep` / `MusicRiskStep`,值完全一样
|
||||||
|
/// (功能票用 `chargeTicket` 表示「发包」,语义上就是 [upload])。
|
||||||
|
enum RiskStep { idle, fetchData, upload, logout, complete, failed }
|
||||||
|
|
||||||
|
/// 执行进度卡:按顺序展示各阶段,前/当前/后三种状态。
|
||||||
|
///
|
||||||
|
/// 说明文字 [message] 用错误色高亮;[error] 用于额外展示底层异常文本。
|
||||||
|
class StepProgressCard extends StatelessWidget {
|
||||||
|
final RiskStep step;
|
||||||
|
final String message;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
/// 要展示的 (阶段, 文案) 列表,按执行顺序给出。不含 [RiskStep.idle] / [RiskStep.failed]。
|
||||||
|
final List<(RiskStep, String)> steps;
|
||||||
|
|
||||||
|
const StepProgressCard({
|
||||||
|
super.key,
|
||||||
|
required this.step,
|
||||||
|
required this.steps,
|
||||||
|
this.message = '',
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isFailed = step == RiskStep.failed;
|
||||||
|
final isDone = step == RiskStep.complete;
|
||||||
|
|
||||||
|
return AppCard(
|
||||||
|
borderColor: isFailed
|
||||||
|
? theme.colorScheme.error.withValues(alpha: 0.5)
|
||||||
|
: isDone
|
||||||
|
? Colors.green.withValues(alpha: 0.5)
|
||||||
|
: null,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final (stage, label) in steps) _row(theme, stage, label),
|
||||||
|
if (message.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_banner(
|
||||||
|
theme,
|
||||||
|
message,
|
||||||
|
background: isFailed
|
||||||
|
? theme.colorScheme.errorContainer
|
||||||
|
: isDone
|
||||||
|
? Colors.green.withValues(alpha: 0.1)
|
||||||
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
foreground: isFailed
|
||||||
|
? theme.colorScheme.onErrorContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_banner(
|
||||||
|
theme,
|
||||||
|
error!,
|
||||||
|
background: theme.colorScheme.errorContainer,
|
||||||
|
foreground: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _banner(
|
||||||
|
ThemeData theme,
|
||||||
|
String text, {
|
||||||
|
required Color background,
|
||||||
|
required Color foreground,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(ThemeData theme, RiskStep stage, String label) {
|
||||||
|
// failed 卡在发包阶段,按原逻辑回退到 upload。
|
||||||
|
final current = step == RiskStep.failed ? RiskStep.upload : step;
|
||||||
|
const order = [
|
||||||
|
RiskStep.fetchData,
|
||||||
|
RiskStep.upload,
|
||||||
|
RiskStep.logout,
|
||||||
|
RiskStep.complete,
|
||||||
|
];
|
||||||
|
final done = order.indexOf(current) > order.indexOf(stage);
|
||||||
|
final active = step == stage;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
done
|
||||||
|
? Icons.check_circle
|
||||||
|
: active
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
size: 18,
|
||||||
|
color: done
|
||||||
|
? Colors.green
|
||||||
|
: active
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user