Files
Ichino/lib/pages/home_page.dart
T
Cookiues 5d16984e4e 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 库)
2026-09-29 02:43:39 +08:00

1247 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show SystemNavigator;
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../models/session_model.dart';
import '../models/user_data.dart';
import '../models/user_preview.dart';
import '../services/title_api_service.dart';
import '../widgets/app_notice.dart';
import '../widgets/connection_share_card.dart';
import 'about_page.dart';
import 'best50_page.dart';
import 'high_risk_feature_page.dart';
import 'settings_page.dart';
import 'ticket_page.dart';
class HomePage extends StatefulWidget {
final int userId;
final String token;
final bool forcePreviewApi;
/// 会话由登录页粘贴「连接信息」恢复而来:登录态(或 Cookie)已经是现成的,
/// 主页不要再发一次 UserLoginApi 去顶掉它。
final bool sessionRestored;
/// 恢复会话时验证 Cookie 顺手拉到的用户数据,直接展示、省一次请求。
final UserDataBean? initialUserData;
const HomePage({
super.key,
required this.userId,
required this.token,
this.forcePreviewApi = false,
this.sessionRestored = false,
this.initialUserData,
});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentTab = 0;
UserPreviewDataBean? _preview;
UserDataBean? _userData;
String? _error;
bool _loading = true;
bool _loggingOut = false;
SessionModel get _session => SessionModel.instance;
static const _tabTitles = [
AppStrings.tabHome,
AppStrings.tabTickets,
AppStrings.tabBest50,
AppStrings.tabRisk,
AppStrings.tabSettings,
AppStrings.tabAbout,
];
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
if (!TitleServerConfigHolder().isConfigured) {
setState(() {
_loading = false;
_error = null;
});
return;
}
setState(() {
_loading = true;
_error = null;
_preview = null;
_userData = 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)!;
try {
final preview = await service.getUserPreview(
userId: widget.userId,
token: widget.token,
);
if (!mounted) return;
_session.updateCookies(service.cookies);
setState(() => _preview = preview);
// isLogin == true => 已有人登录, 不再走完整登录, 直接展示 preview 概要
// forcePreviewApi => 跳过 UserLoginApi, 仅展示 preview + Login 按钮
// 但恢复来的登录态就是自己这次会话,isLogin 为 true 不代表「别人在玩」。
final ownsLogin = _session.gameLogin != null;
if (!ownsLogin && (preview.isLogin || widget.forcePreviewApi)) {
setState(() => _loading = false);
return;
}
if (!ownsLogin) {
await _session.loginGame(userId: widget.userId, token: widget.token);
if (!mounted) return;
}
final userDataService = TitleApiService.fromHolder(
cookies: _session.cookies,
)!;
final userData = await userDataService.getUserDataTyped(widget.userId);
if (!mounted) return;
_session.updateCookies(userDataService.cookies);
setState(() {
_userData = userData;
_loading = false;
});
} on TitleApiException catch (e) {
if (!mounted) return;
setState(() {
_error = e.message;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
Future<void> _performLogin() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() {
_loading = true;
_error = null;
});
try {
await _session.loginGame(userId: widget.userId, token: widget.token);
if (!mounted) return;
final service = TitleApiService.fromHolder(cookies: _session.cookies)!;
final userData = await service.getUserDataTyped(widget.userId);
if (!mounted) return;
_session.updateCookies(service.cookies);
setState(() {
_userData = userData;
_loading = false;
});
} on TitleApiException catch (e) {
if (!mounted) return;
setState(() {
_error = e.message;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
Future<void> _logoutSession() async {
await _session.logoutGame(userId: widget.userId);
}
/// 退登并回到主标题页。供 AppBar 的退出按钮与风险操作完成后的
/// onExitToTitle 使用——这两处都是「明确要回标题」,与主页上的
/// 系统返回手势不同(后者见 [_exitApp])。
Future<void> _performLogoutAndExit() async {
setState(() => _loggingOut = true);
if (_session.gameLogin != null) {
await Future.delayed(const Duration(seconds: 2));
await _logoutSession();
}
_session.reset();
if (mounted) {
// 解锁/收藏品/旅行伙伴这些页是 push 在 HomePage 之上的,只 pop() 一层会
// 退回「风险」hub,而此时 _loggingOut 仍为 true,PopScope(canPop:false) +
// 返回键转圈 = 卡死。必须一路 pop 回根路由(主标题)。
Navigator.of(context).popUntil((route) => route.isFirst);
}
}
/// 主页上的原生返回手势 = 结束应用。
void _exitApp() {
// iOS 不允许程序化退出,且根路由本来也没有返回手势。
if (_session.gameLogin != null) {
_logoutSession();
}
if (Platform.isIOS) return;
SystemNavigator.pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListenableBuilder(
listenable: _session,
builder: (context, _) {
return PopScope(
// 根路由锁死在主页。之前 canPop 为 !_loggingOut(平时即 true),系统返回
// 手势会直接把 HomePage 弹掉、落回 Login Page,而且因为 didPop==true,
// 回调里的退登根本不会执行——既回错页面又没退登。
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop || _loggingOut) return;
_exitApp();
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: _loggingOut
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_back),
onPressed: _loggingOut ? null : _performLogoutAndExit,
),
title: Text(_tabTitles[_currentTab]),
),
body: IndexedStack(
index: _currentTab,
children: [
_buildBody(theme),
TicketPage(
userId: widget.userId,
cookies: _session.cookies,
loginDateTime: _session.gameLogin?.loginDateTime,
playerRating:
_userData?.playerRating ?? _preview?.playerRating ?? 0,
onLogoutRequested: _session.gameLogin != null
? _logoutSession
: null,
),
Best50Page(
userId: widget.userId,
cookies: _session.cookies,
userName: _userData?.userName ?? _preview?.userName ?? '',
iconId: _userData?.iconId ?? _preview?.iconId ?? 0,
playerRating:
_userData?.playerRating ?? _preview?.playerRating ?? 0,
),
HighRiskFeaturePage(
userId: widget.userId,
cookies: _session.cookies,
loginDateTime: _session.gameLogin?.loginDateTime,
loginId: _session.gameLogin?.loginId,
lastLoginDate: _session.gameLogin?.lastLoginDate,
onExitToTitle: _session.gameLogin != null
? _performLogoutAndExit
: null,
),
SettingsPage(showAppBar: false),
const AboutPage(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentTab,
onDestinationSelected: (i) => setState(() => _currentTab = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: AppStrings.tabHome,
),
NavigationDestination(
icon: Icon(Icons.confirmation_number_outlined),
selectedIcon: Icon(Icons.confirmation_number),
label: AppStrings.tabTickets,
),
NavigationDestination(
icon: Icon(Icons.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,
),
],
),
),
);
},
);
}
Widget _buildBody(ThemeData theme) {
if (!TitleServerConfigHolder().isConfigured) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_ethernet,
size: 64,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
AppStrings.titleServerNotConfigured,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
AppStrings.titleServerNotConfiguredDesc,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => setState(() => _currentTab = 4),
icon: const Icon(Icons.settings, size: 20),
label: const Text(AppStrings.openSettings),
),
],
),
),
);
}
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 12),
Text(AppStrings.loadFailed, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _bootstrap,
icon: const Icon(Icons.refresh, size: 20),
label: const Text(AppStrings.retry),
),
],
),
),
);
}
final userData = _userData;
if (userData != null) {
return _buildFullData(theme, _preview, userData);
}
final preview = _preview;
if (preview == null) {
return const SizedBox.shrink();
}
if (preview.isLogin ||
(widget.forcePreviewApi && _session.gameLogin == null)) {
return _buildPreviewOnly(
theme,
preview,
showLoginButton: widget.forcePreviewApi && _session.gameLogin == null,
onLoginPressed: _performLogin,
);
}
return const SizedBox.shrink();
}
Widget _buildPreviewOnly(
ThemeData theme,
UserPreviewDataBean preview, {
bool showLoginButton = false,
VoidCallback? onLoginPressed,
}) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (preview.errorId != 0)
_ErrorIdBanner(theme: theme, errorId: preview.errorId),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(10),
),
child: Text(
showLoginButton
? 'Preview API 模式,点击下方按钮登录。'
: AppStrings.inheritedAccountNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onTertiaryContainer,
),
),
),
if (showLoginButton) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loading ? null : onLoginPressed,
icon: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login, 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: 12),
_ProfileFromPreviewCard(theme: theme, preview: preview),
const SizedBox(height: 12),
...responsiveGrid(
context: context,
children: [
_PreviewGameInfoCard(theme: theme, preview: preview),
_PreviewStatusCard(theme: theme, preview: preview),
],
),
const SizedBox(height: 12),
ConnectionShareCard(userId: widget.userId, token: widget.token),
if (TitleApiService.lastRawResponse != null) ...[
const SizedBox(height: 12),
_DebugRawJsonCard(
theme: theme,
rawJson: TitleApiService.lastRawResponse!,
),
],
],
),
),
);
}
Widget _buildFullData(
ThemeData theme,
UserPreviewDataBean? preview,
UserDataBean data,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
children: [
if (widget.sessionRestored && _session.gameLogin == null)
AppNotice(
AppStrings.sessionRestoredNotice,
icon: Icons.science_outlined,
),
_ProfileFromUserDataCard(theme: theme, data: data),
const SizedBox(height: 12),
...responsiveGrid(
context: context,
children: [
_RatingBreakdownCard(theme: theme, data: data),
_FirstPlayCard(theme: theme, data: data),
_GameInfoFromUserDataCard(theme: theme, data: data),
_PlayStatsCard(theme: theme, data: data),
_StatusFromUserDataCard(
theme: theme,
preview: preview,
data: data,
),
_UserDataDetailCard(theme: theme, data: data),
],
),
const SizedBox(height: 12),
ConnectionShareCard(userId: widget.userId, token: widget.token),
if (TitleApiService.lastRawResponse != null) ...[
const SizedBox(height: 12),
_DebugRawJsonCard(
theme: theme,
rawJson: TitleApiService.lastRawResponse!,
),
],
],
),
),
);
}
}
// =============================================================================
// Preview-only (isLogin == true) widgets
// =============================================================================
class _ProfileFromPreviewCard extends StatelessWidget {
final ThemeData theme;
final UserPreviewDataBean preview;
const _ProfileFromPreviewCard({required this.theme, required this.preview});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
_Avatar(theme: theme, iconId: preview.iconId),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
preview.userName.isEmpty
? AppStrings.unknownUser
: preview.userName,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
AppStrings.rating(preview.playerRating),
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
);
}
}
class _PreviewGameInfoCard extends StatelessWidget {
final ThemeData theme;
final UserPreviewDataBean preview;
const _PreviewGameInfoCard({required this.theme, required this.preview});
@override
Widget build(BuildContext context) {
return _SectionCard(
theme: theme,
title: AppStrings.gameInfo,
rows: [
(AppStrings.lastGame, preview.lastGameId),
(AppStrings.lastPlay, preview.lastPlayDate),
(AppStrings.lastLogin, preview.lastLoginDate),
(AppStrings.romVersion, preview.lastRomVersion),
(AppStrings.dataVersion, preview.lastDataVersion),
],
);
}
}
class _PreviewStatusCard extends StatelessWidget {
final ThemeData theme;
final UserPreviewDataBean preview;
const _PreviewStatusCard({required this.theme, required this.preview});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.status,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
_statusChip(theme, AppStrings.netMember, preview.isNetMember),
_statusChip(theme, AppStrings.inherit, preview.isInherit),
_statusChip(
theme,
AppStrings.banState,
preview.banState == 0,
trueText: AppStrings.clean,
falseText: '已封禁 (${preview.banState})',
),
],
),
),
);
}
}
// =============================================================================
// Full UserData widgets
// =============================================================================
class _ProfileFromUserDataCard extends StatelessWidget {
final ThemeData theme;
final UserDataBean data;
const _ProfileFromUserDataCard({required this.theme, required this.data});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
_Avatar(theme: theme, iconId: data.iconId),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.userName.isEmpty
? AppStrings.unknownUser
: data.userName,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
AppStrings.rating(data.playerRating),
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'${AppStrings.highestRating}: ${data.highestRating}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
class _RatingBreakdownCard extends StatelessWidget {
final ThemeData theme;
final UserDataBean data;
const _RatingBreakdownCard({required this.theme, required this.data});
@override
Widget build(BuildContext context) {
return _SectionCard(
theme: theme,
title: AppStrings.ratingBreakdown,
rows: [
(AppStrings.musicRating, '${data.musicRating}'),
(AppStrings.newChartRating, '${data.playerNewRating}'),
(AppStrings.oldChartRating, '${data.playerOldRating}'),
(AppStrings.highestRating, '${data.highestRating}'),
],
);
}
}
class _FirstPlayCard extends StatelessWidget {
final ThemeData theme;
final UserDataBean data;
const _FirstPlayCard({required this.theme, required this.data});
@override
Widget build(BuildContext context) {
return _SectionCard(
theme: theme,
title: AppStrings.firstPlay,
rows: [
(AppStrings.joinDate, data.firstPlayDate),
(AppStrings.firstGame, data.firstGameId),
(AppStrings.firstRomVersion, data.firstRomVersion),
(AppStrings.firstDataVersion, data.firstDataVersion),
],
);
}
}
class _GameInfoFromUserDataCard extends StatelessWidget {
final ThemeData theme;
final UserDataBean data;
const _GameInfoFromUserDataCard({required this.theme, required this.data});
@override
Widget build(BuildContext context) {
return _SectionCard(
theme: theme,
title: AppStrings.gameInfo,
rows: [
(AppStrings.lastGame, data.lastGameId),
(AppStrings.lastPlay, data.lastPlayDate),
(AppStrings.lastLogin, data.lastLoginDate),
(AppStrings.romVersion, data.lastRomVersion),
(AppStrings.dataVersion, data.lastDataVersion),
(
AppStrings.lastRegion,
'${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}',
),
],
);
}
}
class _PlayStatsCard extends StatelessWidget {
final ThemeData theme;
final UserDataBean data;
const _PlayStatsCard({required this.theme, required this.data});
@override
Widget build(BuildContext context) {
return _SectionCard(
theme: theme,
title: AppStrings.playStats,
rows: [
(AppStrings.playCount, '${data.playCount}'),
(AppStrings.currentPlayCount, '${data.currentPlayCount}'),
(AppStrings.totalAchievement, '${data.totalAchievement}'),
(AppStrings.totalDxScore, '${data.totalDeluxscore}'),
(AppStrings.totalSync, '${data.totalSync}'),
(AppStrings.totalAwake, '${data.totalAwake}'),
],
);
}
}
class _StatusFromUserDataCard extends StatelessWidget {
final ThemeData theme;
/// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。
final UserPreviewDataBean? preview;
final UserDataBean data;
const _StatusFromUserDataCard({
required this.theme,
required this.preview,
required this.data,
});
@override
Widget build(BuildContext context) {
final p = preview;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.status,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
_statusChip(theme, AppStrings.netMember, data.isNetMember),
if (p != null) _statusChip(theme, AppStrings.inherit, p.isInherit),
_statusChip(
theme,
AppStrings.banState,
data.banState == 0,
trueText: AppStrings.clean,
falseText: '已封禁 (${data.banState})',
),
_statusChip(
theme,
AppStrings.dailyBonus,
data.dailyBonusDate.isNotEmpty &&
!data.dailyBonusDate.startsWith('1970'),
trueText: data.dailyBonusDate,
falseText: AppStrings.notClaimed,
),
],
),
),
);
}
}
class _UserDataDetailCard extends StatefulWidget {
final ThemeData theme;
final UserDataBean data;
const _UserDataDetailCard({required this.theme, required this.data});
@override
State<_UserDataDetailCard> createState() => _UserDataDetailCardState();
}
class _UserDataDetailCardState extends State<_UserDataDetailCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = widget.theme;
final data = widget.data;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
AppStrings.moreDetails,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
if (_expanded) ...[
const SizedBox(height: 14),
_detailRow(theme, AppStrings.userId, '${data.userId}'),
_detailRow(theme, AppStrings.point, '${data.point}'),
_detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'),
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
_detailRow(
theme,
AppStrings.nameplateId,
'${data.nameplateId}',
),
_detailRow(theme, AppStrings.plateId, '${data.plateId}'),
_detailRow(theme, AppStrings.frameId, '${data.frameId}'),
_detailRow(theme, AppStrings.titleId, '${data.titleId}'),
_detailRow(theme, AppStrings.trophyId, '${data.trophyId}'),
_detailRow(theme, AppStrings.partnerId, '${data.partnerId}'),
_detailRow(theme, AppStrings.gradeRank, '${data.gradeRank}'),
_detailRow(theme, AppStrings.courseRank, '${data.courseRank}'),
_detailRow(theme, AppStrings.classRank, '${data.classRank}'),
],
],
),
),
),
);
}
Widget _detailRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 130,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
);
}
}
// =============================================================================
// Shared helpers
// =============================================================================
class _SectionCard extends StatelessWidget {
final ThemeData theme;
final String title;
final List<(String, String)> rows;
const _SectionCard({
required this.theme,
required this.title,
required this.rows,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
for (final (label, value) in rows) _infoRow(theme, label, value),
],
),
),
);
}
Widget _infoRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '-' : value,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}
}
class _Avatar extends StatelessWidget {
final ThemeData theme;
final int iconId;
const _Avatar({required this.theme, required this.iconId});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: 30,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: iconId > 0
? NetworkImage('https://assets2.lxns.net/maimai/icon/$iconId.png')
: null,
child: iconId == 0
? Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer)
: null,
);
}
}
Widget _statusChip(
ThemeData theme,
String label,
dynamic value, {
String trueText = 'Yes',
String falseText = 'No',
}) {
String text;
Color color;
if (value is bool) {
text = value ? trueText : falseText;
color = value ? Colors.green : Colors.grey;
} else {
text = '$value';
color = theme.colorScheme.onSurface;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(color: color),
),
),
],
),
);
}
class _DebugRawJsonCard extends StatefulWidget {
final ThemeData theme;
final String rawJson;
const _DebugRawJsonCard({required this.theme, required this.rawJson});
@override
State<_DebugRawJsonCard> createState() => _DebugRawJsonCardState();
}
class _DebugRawJsonCardState extends State<_DebugRawJsonCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = widget.theme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.orange.withValues(alpha: 0.5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, size: 16, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.rawApiResponse,
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: Colors.orange,
),
],
),
if (_expanded) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
widget.rawJson,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
),
),
],
],
),
),
),
);
}
}
class _ErrorIdBanner extends StatelessWidget {
final ThemeData theme;
final int errorId;
const _ErrorIdBanner({required this.theme, required this.errorId});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 20,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'API returned errorId: $errorId',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}