feat: 实验性连接信息导出与会话恢复,重写关于页
- SessionShare:把 userId / JSESSIONID / token 打成 Base64(JSON),Cookie 只保留 JSESSIONID 一项,粘贴时容忍换行与空白,QR 令牌与乱码解不出来 - 主页底部新增「导出连接信息」卡片,标记实验性:内容未加密,字符串在手即可操作账号 - 登录页识别到连接信息时把登录按钮换成「恢复会话」,不必再扫 QR 令牌 - 恢复优先沿用导入的 JSESSIONID(不重发 UserLoginApi,免得顶掉机台上的登录), Cookie 失效才退回用 token 登录;验证 Cookie 时顺手拉到的 GetUserDataApi 结果 直接交给主页展示,省一次请求 - 仅凭 Cookie 恢复的会话没有 loginId / loginDateTime,_gameLogin 保持 null, 票据与风险页照旧提示「尚未登录游戏服务器」,主页顶部给出说明横幅 - 关于页改用 AppCard / SectionTitle 脚手架,补基本信息与风险提示,把过时的 「jsQR (web)」致谢换成原生 MethodChannel(QR 解码早已不走 web 库)
This commit is contained in:
1 parent
4dddfdc00c
commit
5d16984e4e
9 files changed
+736
-154
No files matched your search
@@ -17,6 +17,7 @@ Android / iOS / Windows / macOS / Linux。
|
|||||||
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
|
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
|
||||||
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 |
|
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 |
|
||||||
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 |
|
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 |
|
||||||
|
| 连接信息(实验性) | 主页导出 uid + JSESSIONID + token 的 Base64 快照,登录页粘贴即可恢复会话;内容未加密,请勿外传 |
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
+31
-6
@@ -11,10 +11,8 @@ class AppStrings {
|
|||||||
|
|
||||||
// Login
|
// Login
|
||||||
static const appTitle = 'Ichino';
|
static const appTitle = 'Ichino';
|
||||||
static const loginSubtitle = '扫描或输入二维码进行登录';
|
|
||||||
static const qrCodeToken = 'QR Code 令牌';
|
static const qrCodeToken = 'QR Code 令牌';
|
||||||
static const qrHint = '粘贴二维码解析内容...';
|
static const qrHint = '粘贴二维码解析内容... / 粘贴连接信息';
|
||||||
static const qrParsedResult = '解析结果(截取后 64 位)';
|
|
||||||
static const uploadQR = '上传二维码';
|
static const uploadQR = '上传二维码';
|
||||||
static const login = '登录';
|
static const login = '登录';
|
||||||
static const loggingIn = '登录中...';
|
static const loggingIn = '登录中...';
|
||||||
@@ -26,6 +24,26 @@ class AppStrings {
|
|||||||
static const requestFailed = '请求失败';
|
static const requestFailed = '请求失败';
|
||||||
static const settingsTooltip = '设置';
|
static const settingsTooltip = '设置';
|
||||||
|
|
||||||
|
// 连接信息(实验性):uid + JSESSIONID + token 的 Base64 快照
|
||||||
|
static const experimentalBadge = '实验性';
|
||||||
|
static const connectionShareTitle = '导出连接信息';
|
||||||
|
static const connectionShareDesc =
|
||||||
|
'把用户 ID、JSESSIONID Cookie 与登录令牌打包成 Base64,'
|
||||||
|
'在其他设备的登录页粘贴即可恢复会话。';
|
||||||
|
static const connectionShareSecurity = '内容未加密,拿到这串字符的人可以直接操作该账号。';
|
||||||
|
static const connectionShareCopy = '复制到剪贴板';
|
||||||
|
static const connectionShareCopied = '连接信息已复制到剪贴板';
|
||||||
|
static const connectionShareEmpty = '当前会话既没有 Cookie 也没有令牌,无法导出。';
|
||||||
|
static const restoreSession = '恢复会话';
|
||||||
|
static const restoringSession = '恢复中...';
|
||||||
|
static const sessionShareDetected = '已识别为连接信息(实验性功能),点击「恢复会话」直接续上这次登录。';
|
||||||
|
static const sessionRestoreFailed = '会话恢复失败';
|
||||||
|
static const sessionRestoreCookie = '已复用连接信息里的 JSESSIONID,没有重新登录';
|
||||||
|
static const sessionRestoreToken = 'Cookie 不可用,已用令牌重新登录';
|
||||||
|
static const sessionRestoredNotice =
|
||||||
|
'这是用连接信息恢复的会话:JSESSIONID 只用于读取数据,'
|
||||||
|
'票据与风险页的写入操作需要先重新登录。';
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
static const logoutTooltip = '退出登录';
|
static const logoutTooltip = '退出登录';
|
||||||
static const titleServerNotConfigured = '未配置 Title Server';
|
static const titleServerNotConfigured = '未配置 Title Server';
|
||||||
@@ -115,8 +133,7 @@ class AppStrings {
|
|||||||
static const selectedTicket = '已选票';
|
static const selectedTicket = '已选票';
|
||||||
static const ticketUsed = '功能票使用完成';
|
static const ticketUsed = '功能票使用完成';
|
||||||
static const ticketCooldownSeconds = 60;
|
static const ticketCooldownSeconds = 60;
|
||||||
static String ticketCooldownCountdown(int remaining) =>
|
static String ticketCooldownCountdown(int remaining) => '冷却中,剩余 $remaining 秒';
|
||||||
'冷却中,剩余 $remaining 秒';
|
|
||||||
static String ticketCooldownNotice(int remaining) =>
|
static String ticketCooldownNotice(int remaining) =>
|
||||||
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。';
|
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可使用功能票。';
|
||||||
|
|
||||||
@@ -434,14 +451,22 @@ class AppStrings {
|
|||||||
static String best50Exported(String? path) =>
|
static String best50Exported(String? path) =>
|
||||||
path == null ? '图片已导出' : '图片已导出到 $path';
|
path == null ? '图片已导出' : '图片已导出到 $path';
|
||||||
static String best50ExportFailed(String error) => '导出失败: $error';
|
static String best50ExportFailed(String error) => '导出失败: $error';
|
||||||
static const best50Footer = 'Generated by Ichino. UI designed by chuxuehaocai.';
|
static const best50Footer =
|
||||||
|
'Generated by Ichino. UI designed by chuxuehaocai.';
|
||||||
|
|
||||||
// About
|
// About
|
||||||
static const aboutTitle = 'Ichino';
|
static const aboutTitle = 'Ichino';
|
||||||
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
|
static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。';
|
||||||
|
static const aboutBasics = '基本信息';
|
||||||
|
static const aboutPlatforms = '支持平台';
|
||||||
|
static const aboutPackageId = '应用标识';
|
||||||
|
static const aboutRiskNotice =
|
||||||
|
'「风险」页会向服务器发送伪造的 UpsertUserAllApi 数据包,属于高危操作,可能封号。';
|
||||||
static const credits = '致谢';
|
static const credits = '致谢';
|
||||||
static const creditBuiltWith = '构建框架';
|
static const creditBuiltWith = '构建框架';
|
||||||
static const creditQRDecode = 'QR 解码';
|
static const creditQRDecode = 'QR 解码';
|
||||||
|
static const creditB50 = 'B50 成绩图';
|
||||||
|
static const creditProtocol = '协议实现参考';
|
||||||
static const creditIconAssets = '图标资源';
|
static const creditIconAssets = '图标资源';
|
||||||
static const creditLicense = '许可证';
|
static const creditLicense = '许可证';
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,20 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../config/title_server_config.dart';
|
import '../config/title_server_config.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
|
import 'session_share.dart';
|
||||||
|
import 'user_data.dart';
|
||||||
|
|
||||||
|
/// [SessionModel.restoreFromShare] 的结果。
|
||||||
|
class SessionRestoreResult {
|
||||||
|
/// `true` 表示沿用了连接信息里的 JSESSIONID;`false` 表示用令牌重新登录,
|
||||||
|
/// 拿到了一份新 Cookie。
|
||||||
|
final bool reusedCookie;
|
||||||
|
|
||||||
|
/// 沿用旧会话时顺带拉到的用户数据(HomePage 直接展示,省一次请求)。
|
||||||
|
final UserDataBean? userData;
|
||||||
|
|
||||||
|
const SessionRestoreResult({required this.reusedCookie, this.userData});
|
||||||
|
}
|
||||||
|
|
||||||
class SessionModel extends ChangeNotifier {
|
class SessionModel extends ChangeNotifier {
|
||||||
static final SessionModel instance = SessionModel._();
|
static final SessionModel instance = SessionModel._();
|
||||||
@@ -48,10 +62,7 @@ class SessionModel extends ChangeNotifier {
|
|||||||
return login;
|
return login;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logoutGame({
|
Future<void> logoutGame({required int userId, String? cookies}) async {
|
||||||
required int userId,
|
|
||||||
String? cookies,
|
|
||||||
}) async {
|
|
||||||
final login = _gameLogin;
|
final login = _gameLogin;
|
||||||
if (login == null) return;
|
if (login == null) return;
|
||||||
final config = TitleServerConfigHolder().config;
|
final config = TitleServerConfigHolder().config;
|
||||||
@@ -75,6 +86,51 @@ class SessionModel extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 实验性:导出连接信息(用户 ID + JSESSIONID + 令牌)。
|
||||||
|
SessionShare exportConnectionInfo({
|
||||||
|
required int userId,
|
||||||
|
required String token,
|
||||||
|
}) => SessionShare(userId: userId, token: token, cookie: _cookies);
|
||||||
|
|
||||||
|
/// 实验性:用连接信息恢复会话。
|
||||||
|
///
|
||||||
|
/// 带着可用的 JSESSIONID 时沿用这个会话继续交互,不重发 `UserLoginApi`;
|
||||||
|
/// 没有 Cookie 或 Cookie 已失效,才退回去用令牌登录一次拿新 Cookie。
|
||||||
|
Future<SessionRestoreResult> restoreFromShare(SessionShare share) async {
|
||||||
|
final cookie = share.jsessionid;
|
||||||
|
if (cookie != null) {
|
||||||
|
final userData = await _probeCookieSession(share.userId, cookie);
|
||||||
|
if (userData != null) {
|
||||||
|
// _gameLogin 保持 null:这次没有 UserLoginApi,拿不到 loginId /
|
||||||
|
// loginDateTime,写入类功能页照旧提示「尚未登录游戏服务器」。
|
||||||
|
notifyListeners();
|
||||||
|
return SessionRestoreResult(reusedCookie: true, userData: userData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!share.hasToken) {
|
||||||
|
throw const TitleApiException('连接信息里的 Cookie 已失效,且没有可用于重新登录的令牌');
|
||||||
|
}
|
||||||
|
await loginGame(userId: share.userId, token: share.token);
|
||||||
|
return const SessionRestoreResult(reusedCookie: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用导入的 Cookie 试拉一次 GetUserDataApi,会话不可用时返回 `null`。
|
||||||
|
Future<UserDataBean?> _probeCookieSession(int userId, String cookie) async {
|
||||||
|
final service = TitleApiService.fromHolder(cookies: cookie);
|
||||||
|
if (service == null) return null;
|
||||||
|
try {
|
||||||
|
final json = await service.getUserData(userId);
|
||||||
|
// 会话失效时服务器仍可能回一个不含 userData 的包,不能当成登录成功。
|
||||||
|
if (json['userData'] is! Map) return null;
|
||||||
|
final userData = UserDataBean.fromJson(json);
|
||||||
|
updateCookies(service.cookies);
|
||||||
|
return userData;
|
||||||
|
} on TitleApiException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void updateCookies(String? cookies) {
|
void updateCookies(String? cookies) {
|
||||||
if (cookies != null && cookies.isNotEmpty) {
|
if (cookies != null && cookies.isNotEmpty) {
|
||||||
_cookies = cookies;
|
_cookies = cookies;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// 实验性:一次登录的可搬运快照(用户 ID + JSESSIONID Cookie + 令牌),
|
||||||
|
/// 编码方式是 Base64(JSON),用于在别的设备上恢复会话、免重新扫码。
|
||||||
|
///
|
||||||
|
/// 没有任何加密,字符串在谁手里谁就能操作这个账号,只适合自己传递。
|
||||||
|
class SessionShare {
|
||||||
|
static const String _kind = 'ichino.session';
|
||||||
|
static const int _version = 1;
|
||||||
|
|
||||||
|
final int userId;
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
/// 原始 Cookie 串,可能是 `JSESSIONID=...`,也可能带别的字段。
|
||||||
|
final String? cookie;
|
||||||
|
|
||||||
|
const SessionShare({required this.userId, required this.token, this.cookie});
|
||||||
|
|
||||||
|
/// 只取 `JSESSIONID`:传输层后续请求就靠它维持会话
|
||||||
|
/// (见 `TitleApiService._captureCookiesFromResponse`)。
|
||||||
|
String? get jsessionid {
|
||||||
|
final raw = cookie;
|
||||||
|
if (raw == null) return null;
|
||||||
|
for (final part in raw.split(';')) {
|
||||||
|
final pair = part.trim();
|
||||||
|
final eq = pair.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
if (pair.substring(0, eq).toUpperCase() != 'JSESSIONID') continue;
|
||||||
|
final value = pair.substring(eq + 1).trim();
|
||||||
|
return value.isEmpty ? null : 'JSESSIONID=$value';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasCookie => jsessionid != null;
|
||||||
|
bool get hasToken => token.isNotEmpty;
|
||||||
|
|
||||||
|
String encode() {
|
||||||
|
final map = <String, dynamic>{
|
||||||
|
'kind': _kind,
|
||||||
|
'version': _version,
|
||||||
|
'userId': userId,
|
||||||
|
'token': token,
|
||||||
|
'cookie': jsessionid,
|
||||||
|
};
|
||||||
|
return base64Encode(utf8.encode(jsonEncode(map)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 不是连接信息时返回 `null`——登录页靠它区分 QR 令牌和会话快照。
|
||||||
|
static SessionShare? tryDecode(String input) {
|
||||||
|
var text = input.replaceAll(RegExp(r'\s'), '');
|
||||||
|
if (text.length < 16) return null;
|
||||||
|
final pad = text.length % 4;
|
||||||
|
if (pad != 0) text += '=' * (4 - pad);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(utf8.decode(base64Decode(text)));
|
||||||
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
if (decoded['kind'] != _kind) return null;
|
||||||
|
if ((decoded['version'] as num?)?.toInt() != _version) return null;
|
||||||
|
|
||||||
|
final share = SessionShare(
|
||||||
|
userId: (decoded['userId'] as num?)?.toInt() ?? 0,
|
||||||
|
token: decoded['token'] as String? ?? '',
|
||||||
|
cookie: decoded['cookie'] as String?,
|
||||||
|
);
|
||||||
|
// 两头都空的话这串信息什么都恢复不了。
|
||||||
|
if (share.userId <= 0 || (!share.hasCookie && !share.hasToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return share;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-30
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../config/responsive.dart';
|
import '../config/responsive.dart';
|
||||||
import '../config/strings.dart';
|
import '../config/strings.dart';
|
||||||
|
import '../widgets/app_card.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
|
||||||
class AboutPage extends StatelessWidget {
|
class AboutPage extends StatelessWidget {
|
||||||
const AboutPage({super.key});
|
const AboutPage({super.key});
|
||||||
@@ -16,16 +18,20 @@ class AboutPage extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 24),
|
||||||
Icon(Icons.qr_code_scanner, size: 64, color: theme.colorScheme.primary),
|
Icon(
|
||||||
const SizedBox(height: 16),
|
Icons.qr_code_scanner,
|
||||||
|
size: 56,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
AppStrings.aboutTitle,
|
AppStrings.aboutTitle,
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
AppStrings.aboutDesc,
|
AppStrings.aboutDesc,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
@@ -33,35 +39,48 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 28),
|
||||||
Card(
|
AppCard(
|
||||||
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const SectionTitle(
|
||||||
AppStrings.credits,
|
icon: Icons.info_outline,
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
title: AppStrings.aboutBasics,
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_row(theme, AppStrings.creditBuiltWith, 'Flutter'),
|
||||||
|
_row(
|
||||||
|
theme,
|
||||||
|
AppStrings.aboutPlatforms,
|
||||||
|
'Android · iOS · Windows · macOS · Linux',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_row(theme, AppStrings.aboutPackageId, 'dev.naominet.ichino'),
|
||||||
_creditRow(theme, AppStrings.creditBuiltWith, 'Flutter'),
|
_row(theme, AppStrings.creditLicense, 'MIT'),
|
||||||
_creditRow(theme, AppStrings.creditQRDecode, 'jsQR (web)'),
|
|
||||||
_creditRow(theme, AppStrings.creditIconAssets, 'assets2.lxns.net'),
|
|
||||||
_creditRow(theme, AppStrings.creditLicense, 'MIT'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AppCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SectionTitle(
|
||||||
|
icon: Icons.group_outlined,
|
||||||
|
title: AppStrings.credits,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_row(theme, AppStrings.creditProtocol, 'empurple · eaquira'),
|
||||||
|
_row(theme, AppStrings.creditB50, 'Empurple'),
|
||||||
|
_row(theme, AppStrings.creditQRDecode, '原生 MethodChannel'),
|
||||||
|
_row(theme, AppStrings.creditIconAssets, 'assets2.lxns.net'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const AppNotice(
|
||||||
|
AppStrings.aboutRiskNotice,
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -69,13 +88,14 @@ class AboutPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _creditRow(ThemeData theme, String label, String value) {
|
Widget _row(ThemeData theme, String label, String value) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 120,
|
width: 104,
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
@@ -83,9 +103,7 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
|
||||||
child: Text(value, style: theme.textTheme.bodyMedium),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+176
-64
@@ -10,23 +10,33 @@ import '../models/session_model.dart';
|
|||||||
import '../models/user_data.dart';
|
import '../models/user_data.dart';
|
||||||
import '../models/user_preview.dart';
|
import '../models/user_preview.dart';
|
||||||
import '../services/title_api_service.dart';
|
import '../services/title_api_service.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
|
import '../widgets/connection_share_card.dart';
|
||||||
import 'about_page.dart';
|
import 'about_page.dart';
|
||||||
import 'best50_page.dart';
|
import 'best50_page.dart';
|
||||||
import 'high_risk_feature_page.dart';
|
import 'high_risk_feature_page.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'ticket_page.dart';
|
import 'ticket_page.dart';
|
||||||
|
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
final int userId;
|
final int userId;
|
||||||
final String token;
|
final String token;
|
||||||
final bool forcePreviewApi;
|
final bool forcePreviewApi;
|
||||||
|
|
||||||
|
/// 会话由登录页粘贴「连接信息」恢复而来:登录态(或 Cookie)已经是现成的,
|
||||||
|
/// 主页不要再发一次 UserLoginApi 去顶掉它。
|
||||||
|
final bool sessionRestored;
|
||||||
|
|
||||||
|
/// 恢复会话时验证 Cookie 顺手拉到的用户数据,直接展示、省一次请求。
|
||||||
|
final UserDataBean? initialUserData;
|
||||||
|
|
||||||
const HomePage({
|
const HomePage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.token,
|
required this.token,
|
||||||
this.forcePreviewApi = false,
|
this.forcePreviewApi = false,
|
||||||
|
this.sessionRestored = false,
|
||||||
|
this.initialUserData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -73,7 +83,19 @@ class _HomePageState extends State<HomePage> {
|
|||||||
_preview = null;
|
_preview = null;
|
||||||
_userData = null;
|
_userData = null;
|
||||||
});
|
});
|
||||||
_session.setGameLogin(null);
|
|
||||||
|
final seed = widget.initialUserData;
|
||||||
|
if (seed != null) {
|
||||||
|
// 恢复来的 JSESSIONID 已在 SessionModel._probeCookieSession 里验证过并
|
||||||
|
// 带回全量数据,这里直接展示,不再发 GetUserPreviewApi / UserLoginApi。
|
||||||
|
setState(() {
|
||||||
|
_userData = seed;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!widget.sessionRestored) _session.setGameLogin(null);
|
||||||
|
|
||||||
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||||
|
|
||||||
@@ -88,19 +110,21 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
// isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要
|
// isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要
|
||||||
// forcePreviewApi => 跳过 UserLoginApi, 仅展示 preview + Login 按钮
|
// forcePreviewApi => 跳过 UserLoginApi, 仅展示 preview + Login 按钮
|
||||||
if (preview.isLogin || widget.forcePreviewApi) {
|
// 但恢复来的登录态就是自己这次会话,isLogin 为 true 不代表「别人在玩」。
|
||||||
|
final ownsLogin = _session.gameLogin != null;
|
||||||
|
if (!ownsLogin && (preview.isLogin || widget.forcePreviewApi)) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _session.loginGame(
|
if (!ownsLogin) {
|
||||||
userId: widget.userId,
|
await _session.loginGame(userId: widget.userId, token: widget.token);
|
||||||
token: widget.token,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
final userDataService =
|
final userDataService = TitleApiService.fromHolder(
|
||||||
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);
|
||||||
@@ -131,10 +155,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _session.loginGame(
|
await _session.loginGame(userId: widget.userId, token: widget.token);
|
||||||
userId: widget.userId,
|
|
||||||
token: widget.token,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
|
||||||
@@ -170,7 +191,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
Future<void> _performLogoutAndExit() async {
|
Future<void> _performLogoutAndExit() async {
|
||||||
setState(() => _loggingOut = true);
|
setState(() => _loggingOut = true);
|
||||||
if (_session.gameLogin != null) {
|
if (_session.gameLogin != null) {
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
await _logoutSession();
|
await _logoutSession();
|
||||||
}
|
}
|
||||||
_session.reset();
|
_session.reset();
|
||||||
@@ -185,6 +206,9 @@ class _HomePageState extends State<HomePage> {
|
|||||||
/// 主页上的原生返回手势 = 结束应用。
|
/// 主页上的原生返回手势 = 结束应用。
|
||||||
void _exitApp() {
|
void _exitApp() {
|
||||||
// iOS 不允许程序化退出,且根路由本来也没有返回手势。
|
// iOS 不允许程序化退出,且根路由本来也没有返回手势。
|
||||||
|
if (_session.gameLogin != null) {
|
||||||
|
_logoutSession();
|
||||||
|
}
|
||||||
if (Platform.isIOS) return;
|
if (Platform.isIOS) return;
|
||||||
SystemNavigator.pop();
|
SystemNavigator.pop();
|
||||||
}
|
}
|
||||||
@@ -227,15 +251,19 @@ class _HomePageState extends State<HomePage> {
|
|||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
cookies: _session.cookies,
|
cookies: _session.cookies,
|
||||||
loginDateTime: _session.gameLogin?.loginDateTime,
|
loginDateTime: _session.gameLogin?.loginDateTime,
|
||||||
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
|
playerRating:
|
||||||
onLogoutRequested: _session.gameLogin != null ? _logoutSession : null,
|
_userData?.playerRating ?? _preview?.playerRating ?? 0,
|
||||||
|
onLogoutRequested: _session.gameLogin != null
|
||||||
|
? _logoutSession
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
Best50Page(
|
Best50Page(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
cookies: _session.cookies,
|
cookies: _session.cookies,
|
||||||
userName: _userData?.userName ?? _preview?.userName ?? '',
|
userName: _userData?.userName ?? _preview?.userName ?? '',
|
||||||
iconId: _userData?.iconId ?? _preview?.iconId ?? 0,
|
iconId: _userData?.iconId ?? _preview?.iconId ?? 0,
|
||||||
playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0,
|
playerRating:
|
||||||
|
_userData?.playerRating ?? _preview?.playerRating ?? 0,
|
||||||
),
|
),
|
||||||
HighRiskFeaturePage(
|
HighRiskFeaturePage(
|
||||||
userId: widget.userId,
|
userId: widget.userId,
|
||||||
@@ -243,8 +271,9 @@ class _HomePageState extends State<HomePage> {
|
|||||||
loginDateTime: _session.gameLogin?.loginDateTime,
|
loginDateTime: _session.gameLogin?.loginDateTime,
|
||||||
loginId: _session.gameLogin?.loginId,
|
loginId: _session.gameLogin?.loginId,
|
||||||
lastLoginDate: _session.gameLogin?.lastLoginDate,
|
lastLoginDate: _session.gameLogin?.lastLoginDate,
|
||||||
onExitToTitle:
|
onExitToTitle: _session.gameLogin != null
|
||||||
_session.gameLogin != null ? _performLogoutAndExit : null,
|
? _performLogoutAndExit
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
SettingsPage(showAppBar: false),
|
SettingsPage(showAppBar: false),
|
||||||
const AboutPage(),
|
const AboutPage(),
|
||||||
@@ -254,12 +283,36 @@ class _HomePageState extends State<HomePage> {
|
|||||||
selectedIndex: _currentTab,
|
selectedIndex: _currentTab,
|
||||||
onDestinationSelected: (i) => setState(() => _currentTab = i),
|
onDestinationSelected: (i) => setState(() => _currentTab = i),
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: AppStrings.tabHome),
|
NavigationDestination(
|
||||||
NavigationDestination(icon: Icon(Icons.confirmation_number_outlined), selectedIcon: Icon(Icons.confirmation_number), label: AppStrings.tabTickets),
|
icon: Icon(Icons.home_outlined),
|
||||||
NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: AppStrings.tabBest50),
|
selectedIcon: Icon(Icons.home),
|
||||||
NavigationDestination(icon: Icon(Icons.warning_outlined), selectedIcon: Icon(Icons.inventory_2), label: AppStrings.tabRisk),
|
label: AppStrings.tabHome,
|
||||||
NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings),
|
),
|
||||||
NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout),
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.confirmation_number_outlined),
|
||||||
|
selectedIcon: Icon(Icons.confirmation_number),
|
||||||
|
label: AppStrings.tabTickets,
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.bar_chart_outlined),
|
||||||
|
selectedIcon: Icon(Icons.bar_chart),
|
||||||
|
label: AppStrings.tabBest50,
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.warning_outlined),
|
||||||
|
selectedIcon: Icon(Icons.inventory_2),
|
||||||
|
label: AppStrings.tabRisk,
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: AppStrings.tabSettings,
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.info_outlined),
|
||||||
|
selectedIcon: Icon(Icons.info),
|
||||||
|
label: AppStrings.tabAbout,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -276,7 +329,11 @@ class _HomePageState extends State<HomePage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.settings_ethernet, size: 64, color: theme.colorScheme.primary),
|
Icon(
|
||||||
|
Icons.settings_ethernet,
|
||||||
|
size: 64,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
AppStrings.titleServerNotConfigured,
|
AppStrings.titleServerNotConfigured,
|
||||||
@@ -313,7 +370,11 @@ class _HomePageState extends State<HomePage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium),
|
Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -337,13 +398,18 @@ class _HomePageState extends State<HomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final preview = _preview;
|
|
||||||
final userData = _userData;
|
final userData = _userData;
|
||||||
|
if (userData != null) {
|
||||||
|
return _buildFullData(theme, _preview, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
final preview = _preview;
|
||||||
if (preview == null) {
|
if (preview == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((preview.isLogin || (widget.forcePreviewApi && _session.gameLogin == null)) && userData == null) {
|
if (preview.isLogin ||
|
||||||
|
(widget.forcePreviewApi && _session.gameLogin == null)) {
|
||||||
return _buildPreviewOnly(
|
return _buildPreviewOnly(
|
||||||
theme,
|
theme,
|
||||||
preview,
|
preview,
|
||||||
@@ -352,10 +418,6 @@ class _HomePageState extends State<HomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userData != null) {
|
|
||||||
return _buildFullData(theme, preview, userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,16 +433,21 @@ class _HomePageState extends State<HomePage> {
|
|||||||
context,
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (preview.errorId != 0) _ErrorIdBanner(theme: theme, errorId: preview.errorId),
|
if (preview.errorId != 0)
|
||||||
|
_ErrorIdBanner(theme: theme, errorId: preview.errorId),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4),
|
color: theme.colorScheme.tertiaryContainer.withValues(
|
||||||
|
alpha: 0.4,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
showLoginButton ? 'Preview API 模式,点击下方按钮登录。' : AppStrings.inheritedAccountNotice,
|
showLoginButton
|
||||||
|
? 'Preview API 模式,点击下方按钮登录。'
|
||||||
|
: AppStrings.inheritedAccountNotice,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onTertiaryContainer,
|
color: theme.colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -399,7 +466,9 @@ class _HomePageState extends State<HomePage> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.login, size: 20),
|
: const Icon(Icons.login, size: 20),
|
||||||
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
|
label: Text(
|
||||||
|
_loading ? AppStrings.loggingIn : AppStrings.login,
|
||||||
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -419,6 +488,8 @@ class _HomePageState extends State<HomePage> {
|
|||||||
_PreviewStatusCard(theme: theme, preview: preview),
|
_PreviewStatusCard(theme: theme, preview: preview),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ConnectionShareCard(userId: widget.userId, token: widget.token),
|
||||||
if (TitleApiService.lastRawResponse != null) ...[
|
if (TitleApiService.lastRawResponse != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_DebugRawJsonCard(
|
_DebugRawJsonCard(
|
||||||
@@ -434,7 +505,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
Widget _buildFullData(
|
Widget _buildFullData(
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
UserPreviewDataBean preview,
|
UserPreviewDataBean? preview,
|
||||||
UserDataBean data,
|
UserDataBean data,
|
||||||
) {
|
) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@@ -443,6 +514,11 @@ class _HomePageState extends State<HomePage> {
|
|||||||
context,
|
context,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.sessionRestored && _session.gameLogin == null)
|
||||||
|
AppNotice(
|
||||||
|
AppStrings.sessionRestoredNotice,
|
||||||
|
icon: Icons.science_outlined,
|
||||||
|
),
|
||||||
_ProfileFromUserDataCard(theme: theme, data: data),
|
_ProfileFromUserDataCard(theme: theme, data: data),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
...responsiveGrid(
|
...responsiveGrid(
|
||||||
@@ -452,10 +528,16 @@ class _HomePageState extends State<HomePage> {
|
|||||||
_FirstPlayCard(theme: theme, data: data),
|
_FirstPlayCard(theme: theme, data: data),
|
||||||
_GameInfoFromUserDataCard(theme: theme, data: data),
|
_GameInfoFromUserDataCard(theme: theme, data: data),
|
||||||
_PlayStatsCard(theme: theme, data: data),
|
_PlayStatsCard(theme: theme, data: data),
|
||||||
_StatusFromUserDataCard(theme: theme, preview: preview, data: data),
|
_StatusFromUserDataCard(
|
||||||
|
theme: theme,
|
||||||
|
preview: preview,
|
||||||
|
data: data,
|
||||||
|
),
|
||||||
_UserDataDetailCard(theme: theme, data: data),
|
_UserDataDetailCard(theme: theme, data: data),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ConnectionShareCard(userId: widget.userId, token: widget.token),
|
||||||
if (TitleApiService.lastRawResponse != null) ...[
|
if (TitleApiService.lastRawResponse != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_DebugRawJsonCard(
|
_DebugRawJsonCard(
|
||||||
@@ -486,7 +568,9 @@ class _ProfileFromPreviewCard extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -499,8 +583,12 @@ class _ProfileFromPreviewCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
preview.userName.isEmpty ? AppStrings.unknownUser : preview.userName,
|
preview.userName.isEmpty
|
||||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
? AppStrings.unknownUser
|
||||||
|
: preview.userName,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -554,7 +642,9 @@ class _PreviewStatusCard extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -601,7 +691,9 @@ class _ProfileFromUserDataCard extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -614,8 +706,12 @@ class _ProfileFromUserDataCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
data.userName.isEmpty ? AppStrings.unknownUser : data.userName,
|
data.userName.isEmpty
|
||||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
? AppStrings.unknownUser
|
||||||
|
: data.userName,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -701,7 +797,10 @@ class _GameInfoFromUserDataCard extends StatelessWidget {
|
|||||||
(AppStrings.lastLogin, data.lastLoginDate),
|
(AppStrings.lastLogin, data.lastLoginDate),
|
||||||
(AppStrings.romVersion, data.lastRomVersion),
|
(AppStrings.romVersion, data.lastRomVersion),
|
||||||
(AppStrings.dataVersion, data.lastDataVersion),
|
(AppStrings.dataVersion, data.lastDataVersion),
|
||||||
(AppStrings.lastRegion, '${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}'),
|
(
|
||||||
|
AppStrings.lastRegion,
|
||||||
|
'${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -732,7 +831,9 @@ class _PlayStatsCard extends StatelessWidget {
|
|||||||
|
|
||||||
class _StatusFromUserDataCard extends StatelessWidget {
|
class _StatusFromUserDataCard extends StatelessWidget {
|
||||||
final ThemeData theme;
|
final ThemeData theme;
|
||||||
final UserPreviewDataBean preview;
|
|
||||||
|
/// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。
|
||||||
|
final UserPreviewDataBean? preview;
|
||||||
final UserDataBean data;
|
final UserDataBean data;
|
||||||
|
|
||||||
const _StatusFromUserDataCard({
|
const _StatusFromUserDataCard({
|
||||||
@@ -743,11 +844,15 @@ class _StatusFromUserDataCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final p = preview;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -763,7 +868,7 @@ class _StatusFromUserDataCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_statusChip(theme, AppStrings.netMember, data.isNetMember),
|
_statusChip(theme, AppStrings.netMember, data.isNetMember),
|
||||||
_statusChip(theme, AppStrings.inherit, preview.isInherit),
|
if (p != null) _statusChip(theme, AppStrings.inherit, p.isInherit),
|
||||||
_statusChip(
|
_statusChip(
|
||||||
theme,
|
theme,
|
||||||
AppStrings.banState,
|
AppStrings.banState,
|
||||||
@@ -808,7 +913,9 @@ class _UserDataDetailCardState extends State<_UserDataDetailCard> {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -841,7 +948,11 @@ class _UserDataDetailCardState extends State<_UserDataDetailCard> {
|
|||||||
_detailRow(theme, AppStrings.point, '${data.point}'),
|
_detailRow(theme, AppStrings.point, '${data.point}'),
|
||||||
_detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'),
|
_detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'),
|
||||||
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
|
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
|
||||||
_detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'),
|
_detailRow(
|
||||||
|
theme,
|
||||||
|
AppStrings.nameplateId,
|
||||||
|
'${data.nameplateId}',
|
||||||
|
),
|
||||||
_detailRow(theme, AppStrings.plateId, '${data.plateId}'),
|
_detailRow(theme, AppStrings.plateId, '${data.plateId}'),
|
||||||
_detailRow(theme, AppStrings.frameId, '${data.frameId}'),
|
_detailRow(theme, AppStrings.frameId, '${data.frameId}'),
|
||||||
_detailRow(theme, AppStrings.titleId, '${data.titleId}'),
|
_detailRow(theme, AppStrings.titleId, '${data.titleId}'),
|
||||||
@@ -872,9 +983,7 @@ class _UserDataDetailCardState extends State<_UserDataDetailCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
|
||||||
child: Text(value, style: theme.textTheme.bodyMedium),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -902,7 +1011,9 @@ class _SectionCard extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -1021,10 +1132,7 @@ class _DebugRawJsonCard extends StatefulWidget {
|
|||||||
final ThemeData theme;
|
final ThemeData theme;
|
||||||
final String rawJson;
|
final String rawJson;
|
||||||
|
|
||||||
const _DebugRawJsonCard({
|
const _DebugRawJsonCard({required this.theme, required this.rawJson});
|
||||||
required this.theme,
|
|
||||||
required this.rawJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_DebugRawJsonCard> createState() => _DebugRawJsonCardState();
|
State<_DebugRawJsonCard> createState() => _DebugRawJsonCardState();
|
||||||
@@ -1076,8 +1184,9 @@ class _DebugRawJsonCardState extends State<_DebugRawJsonCard> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHighest
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
.withValues(alpha: 0.5),
|
alpha: 0.5,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
@@ -1115,8 +1224,11 @@ class _ErrorIdBanner extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_amber_rounded,
|
Icon(
|
||||||
size: 20, color: theme.colorScheme.onErrorContainer),
|
Icons.warning_amber_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
+83
-50
@@ -3,12 +3,16 @@ import 'package:flutter/material.dart';
|
|||||||
import '../config/responsive.dart';
|
import '../config/responsive.dart';
|
||||||
import '../config/strings.dart';
|
import '../config/strings.dart';
|
||||||
import '../models/session_model.dart';
|
import '../models/session_model.dart';
|
||||||
|
import '../models/session_share.dart';
|
||||||
import '../services/file_picker_service.dart';
|
import '../services/file_picker_service.dart';
|
||||||
import '../services/qr_service.dart';
|
import '../services/qr_service.dart';
|
||||||
|
import '../widgets/app_notice.dart';
|
||||||
import 'home_page.dart';
|
import 'home_page.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
|
|
||||||
/// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。
|
/// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。
|
||||||
|
///
|
||||||
|
/// 也接受主页导出的「连接信息」(实验性):那是一串 Base64,直接续上原有会话。
|
||||||
class LoginPage extends StatefulWidget {
|
class LoginPage extends StatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ class LoginPage extends StatefulWidget {
|
|||||||
class _LoginPageState extends State<LoginPage> {
|
class _LoginPageState extends State<LoginPage> {
|
||||||
final _qrController = TextEditingController();
|
final _qrController = TextEditingController();
|
||||||
String _qrCode = '';
|
String _qrCode = '';
|
||||||
|
SessionShare? _share;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _forcePreviewApi = false;
|
bool _forcePreviewApi = false;
|
||||||
|
|
||||||
@@ -29,8 +34,10 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onQRContentChanged(String value) {
|
void _onQRContentChanged(String value) {
|
||||||
|
final text = value.trim();
|
||||||
setState(() {
|
setState(() {
|
||||||
_qrCode = SessionModel.extractQRCode(value.trim());
|
_qrCode = SessionModel.extractQRCode(text);
|
||||||
|
_share = SessionShare.tryDecode(text);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +52,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
_qrController.text = result;
|
_qrController.text = result;
|
||||||
setState(() {
|
setState(() {
|
||||||
_qrCode = SessionModel.extractQRCode(result);
|
_qrCode = SessionModel.extractQRCode(result);
|
||||||
|
_share = SessionShare.tryDecode(result);
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -70,6 +78,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
Future<void> _onLogin() async {
|
Future<void> _onLogin() async {
|
||||||
if (_qrCode.isEmpty) return;
|
if (_qrCode.isEmpty) return;
|
||||||
|
|
||||||
|
if (_qrCode.startsWith("SGWCMAID")) {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -89,7 +98,11 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${AppStrings.loginFailed} (errorID: ${result.errorId})',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -104,10 +117,46 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实验性:用主页导出的连接信息恢复会话。带可用 JSESSIONID 就直接续上那次登录,
|
||||||
|
/// 否则退回用令牌重新登录。
|
||||||
|
Future<void> _onRestoreSession() async {
|
||||||
|
final share = _share;
|
||||||
|
if (share == null) return;
|
||||||
|
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final result = await SessionModel.instance.restoreFromShare(share);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showSnack(
|
||||||
|
result.reusedCookie
|
||||||
|
? AppStrings.sessionRestoreCookie
|
||||||
|
: AppStrings.sessionRestoreToken,
|
||||||
|
);
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => HomePage(
|
||||||
|
userId: share.userId,
|
||||||
|
token: share.token,
|
||||||
|
sessionRestored: true,
|
||||||
|
initialUserData: result.userData,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
context.showSnack('${AppStrings.sessionRestoreFailed}: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final isRestore = _share != null;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -117,9 +166,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
tooltip: AppStrings.settingsTooltip,
|
tooltip: AppStrings.settingsTooltip,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
context,
|
||||||
);
|
).push(MaterialPageRoute(builder: (_) => const SettingsPage()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -145,14 +194,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 48),
|
||||||
Text(
|
|
||||||
AppStrings.loginSubtitle,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -194,6 +236,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
hintText: AppStrings.qrHint,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
@@ -205,40 +248,14 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
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),
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
|
if (isRestore)
|
||||||
|
const AppNotice(
|
||||||
|
AppStrings.sessionShareDetected,
|
||||||
|
icon: Icons.science_outlined,
|
||||||
|
),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -257,8 +274,11 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed:
|
onPressed: isRestore
|
||||||
(_qrCode.isNotEmpty && !_loading) ? _onLogin : null,
|
? (_loading ? null : _onRestoreSession)
|
||||||
|
: (_qrCode.isNotEmpty && !_loading)
|
||||||
|
? _onLogin
|
||||||
|
: null,
|
||||||
icon: _loading
|
icon: _loading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -268,8 +288,19 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.arrow_forward, size: 20),
|
: Icon(
|
||||||
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
|
isRestore ? Icons.link : Icons.arrow_forward,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
isRestore
|
||||||
|
? (_loading
|
||||||
|
? AppStrings.restoringSession
|
||||||
|
: AppStrings.restoreSession)
|
||||||
|
: (_loading
|
||||||
|
? AppStrings.loggingIn
|
||||||
|
: AppStrings.login),
|
||||||
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -281,9 +312,11 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (!isRestore)
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
value: _forcePreviewApi,
|
value: _forcePreviewApi,
|
||||||
onChanged: (v) => setState(() => _forcePreviewApi = v ?? false),
|
onChanged: (v) =>
|
||||||
|
setState(() => _forcePreviewApi = v ?? false),
|
||||||
title: const Text(AppStrings.forcePreviewApi),
|
title: const Text(AppStrings.forcePreviewApi),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
|
||||||
|
|
||||||
|
import '../config/strings.dart';
|
||||||
|
import '../models/session_model.dart';
|
||||||
|
import '../models/session_share.dart';
|
||||||
|
import 'app_card.dart';
|
||||||
|
import 'app_notice.dart';
|
||||||
|
|
||||||
|
/// 实验性:把当前会话打包成 Base64「连接信息」,在别的设备粘贴即可恢复。
|
||||||
|
class ConnectionShareCard extends StatefulWidget {
|
||||||
|
final int userId;
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
const ConnectionShareCard({
|
||||||
|
super.key,
|
||||||
|
required this.userId,
|
||||||
|
required this.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConnectionShareCard> createState() => _ConnectionShareCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectionShareCardState extends State<ConnectionShareCard> {
|
||||||
|
String? _encoded;
|
||||||
|
|
||||||
|
SessionShare get _share => SessionModel.instance.exportConnectionInfo(
|
||||||
|
userId: widget.userId,
|
||||||
|
token: widget.token,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _copy() async {
|
||||||
|
final share = _share;
|
||||||
|
if (!share.hasCookie && !share.hasToken) {
|
||||||
|
context.showSnack(AppStrings.connectionShareEmpty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = share.encode();
|
||||||
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _encoded = text);
|
||||||
|
context.showSnack(AppStrings.connectionShareCopied);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AppCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SectionTitle(
|
||||||
|
icon: Icons.link,
|
||||||
|
title: AppStrings.connectionShareTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const _ExperimentalBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
AppStrings.connectionShareDesc,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: _copy,
|
||||||
|
icon: const Icon(Icons.copy_all, size: 20),
|
||||||
|
label: const Text(AppStrings.connectionShareCopy),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_encoded != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
_encoded!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
AppStrings.connectionShareSecurity,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExperimentalBadge extends StatelessWidget {
|
||||||
|
const _ExperimentalBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final tint = theme.colorScheme.tertiary;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tint.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: tint.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
AppStrings.experimentalBadge,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: tint,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ichino/config/strings.dart';
|
||||||
|
import 'package:ichino/models/session_model.dart';
|
||||||
|
import 'package:ichino/models/session_share.dart';
|
||||||
|
import 'package:ichino/widgets/connection_share_card.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SessionShare', () {
|
||||||
|
test('编码后能解回同样的会话', () {
|
||||||
|
const share = SessionShare(
|
||||||
|
userId: 1234567,
|
||||||
|
token: 'SGWCMAIDABCDEF',
|
||||||
|
cookie: 'JSESSIONID=abc123DEF',
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = SessionShare.tryDecode(share.encode());
|
||||||
|
|
||||||
|
expect(decoded, isNotNull);
|
||||||
|
expect(decoded!.userId, 1234567);
|
||||||
|
expect(decoded.token, 'SGWCMAIDABCDEF');
|
||||||
|
expect(decoded.jsessionid, 'JSESSIONID=abc123DEF');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只保留 JSESSIONID,忽略其它 Cookie', () {
|
||||||
|
const share = SessionShare(
|
||||||
|
userId: 1,
|
||||||
|
token: 't',
|
||||||
|
cookie: 'OTHER=x; JSESSIONID=keep-me; Path=/',
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = SessionShare.tryDecode(share.encode())!;
|
||||||
|
|
||||||
|
expect(decoded.jsessionid, 'JSESSIONID=keep-me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('换行与空白不影响解析', () {
|
||||||
|
const share = SessionShare(
|
||||||
|
userId: 42,
|
||||||
|
token: 'tok',
|
||||||
|
cookie: 'JSESSIONID=z',
|
||||||
|
);
|
||||||
|
final wrapped =
|
||||||
|
' ${share.encode().replaceAllMapped(RegExp(r'.{8}'), (m) => '${m[0]}\n')} ';
|
||||||
|
|
||||||
|
expect(SessionShare.tryDecode(wrapped)?.userId, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QR 令牌与乱码不是连接信息', () {
|
||||||
|
final qrToken = 'SGWCMAID${'0123456789ABCDEF' * 3}01234567';
|
||||||
|
expect(qrToken.length, 64);
|
||||||
|
expect(SessionShare.tryDecode(qrToken), isNull);
|
||||||
|
expect(SessionShare.tryDecode('not base64 at all !!'), isNull);
|
||||||
|
expect(SessionShare.tryDecode(''), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie 与 token 都空、或 userId 非法时拒绝', () {
|
||||||
|
expect(
|
||||||
|
SessionShare.tryDecode(
|
||||||
|
const SessionShare(
|
||||||
|
userId: 0,
|
||||||
|
token: '',
|
||||||
|
cookie: 'JSESSIONID=x',
|
||||||
|
).encode(),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
SessionShare.tryDecode(
|
||||||
|
const SessionShare(userId: 7, token: '', cookie: null).encode(),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ConnectionShareCard', () {
|
||||||
|
Widget wrap(Widget child) => MaterialApp(
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('复制到剪贴板并展开 Base64 内容与外传警示', (tester) async {
|
||||||
|
final copied = <String>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||||
|
if (call.method == 'Clipboard.setData') {
|
||||||
|
copied.add(((call.arguments as Map)['text'] as String?) ?? '');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(const ConnectionShareCard(userId: 7, token: 'tok')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text(AppStrings.experimentalBadge), findsOneWidget);
|
||||||
|
expect(find.text(AppStrings.connectionShareSecurity), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text(AppStrings.connectionShareCopy));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final encoded = SessionModel.instance
|
||||||
|
.exportConnectionInfo(userId: 7, token: 'tok')
|
||||||
|
.encode();
|
||||||
|
expect(copied, [encoded]);
|
||||||
|
expect(find.text(AppStrings.connectionShareSecurity), findsOneWidget);
|
||||||
|
expect(find.text(encoded), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user