Files
Rapollo/lib/pages/home_page.dart
chuxuehaocai 4ad57dc1cf fix: correct ticket charge API body, logout type, and login/logout flow
- Fix upsertUserChargeLog: use separate userCharge + userChargelog objects
  matching Lionheart reference, validDate 90 days at 4AM
- Fix userLogout: use LogoutType.Logout = 1 (was incorrectly changed to 0)
- Fix loginDateTime tracking: userLoginFull now returns the exact timestamp
  sent to server, ensuring logout uses matching value
- Fix home page logout: call UserLogoutApi before navigating back
- Add force-logout for stale sessions before ticket flow login
- Add comprehensive debug logging across entire ticket flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 13:10:10 +08:00

675 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../models/user_preview.dart';
import '../services/title_api_service.dart';
import 'about_page.dart';
import 'settings_page.dart';
import 'ticket_page.dart';
class HomePage extends StatefulWidget {
final int userId;
final String token;
const HomePage({
super.key,
required this.userId,
required this.token,
});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentTab = 0;
UserPreviewDataBean? _data;
String? _error;
bool _loading = true;
bool _loggingOut = false;
static const _tabTitles = [AppStrings.tabHome, AppStrings.tabTickets, AppStrings.tabSettings, AppStrings.tabAbout];
@override
void initState() {
super.initState();
_loadPreview();
}
Future<void> _loadPreview() async {
// ignore: avoid_print
print('[_loadPreview] called, isConfigured=${TitleServerConfigHolder().isConfigured}');
if (!TitleServerConfigHolder().isConfigured) {
// ignore: avoid_print
print('[_loadPreview] config not set, bailing');
setState(() {
_loading = false;
_error = null;
});
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
// ignore: avoid_print
print('[_loadPreview] calling getUserPreview userId=${widget.userId} token=${widget.token}');
final service = TitleApiService(TitleServerConfigHolder().config!);
final data = await service.getUserPreview(
userId: widget.userId,
token: widget.token,
);
if (!mounted) return;
// ignore: avoid_print
print('[_loadPreview] success, errorId=${data.errorId}');
setState(() {
_data = data;
_loading = false;
});
} on TitleApiException catch (e) {
if (!mounted) return;
// ignore: avoid_print
print('[_loadPreview] TitleApiException: $e');
setState(() {
_error = e.message;
_loading = false;
});
} catch (e) {
if (!mounted) return;
// ignore: avoid_print
print('[_loadPreview] exception: $e');
setState(() {
_error = e.toString();
_loading = false;
});
}
}
Future<void> _performLogout() async {
setState(() => _loggingOut = true);
try {
if (TitleServerConfigHolder().isConfigured && _data != null && _data!.isLogin) {
final service = TitleApiService(TitleServerConfigHolder().config!);
final loginDateTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
await service.userLogout(userId: widget.userId, loginDateTime: loginDateTime);
}
} catch (_) {
// Proceed with navigation even if logout fails
}
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(_tabTitles[_currentTab]),
actions: [
IconButton(
icon: _loggingOut
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.logout),
tooltip: AppStrings.logoutTooltip,
onPressed: _loggingOut ? null : _performLogout,
),
],
),
body: IndexedStack(
index: _currentTab,
children: [
_buildBody(theme),
TicketPage(userId: widget.userId, token: widget.token),
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.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 = 2),
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: _loadPreview,
icon: const Icon(Icons.refresh, size: 20),
label: const Text(AppStrings.retry),
),
],
),
),
);
}
final data = _data!;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
children: [
if (data.errorId != 0) _ErrorIdBanner(theme: theme, errorId: data.errorId),
_ProfileCard(theme: theme, data: data),
const SizedBox(height: 12),
_GameInfoCard(theme: theme, data: data),
const SizedBox(height: 12),
_StatusCard(theme: theme, data: data),
const SizedBox(height: 12),
_DetailCard(theme: theme, data: data),
if (TitleApiService.lastRawResponse != null) ...[
const SizedBox(height: 12),
_DebugRawJsonCard(
theme: theme,
rawJson: TitleApiService.lastRawResponse!,
),
],
],
),
),
);
}
}
class _ProfileCard extends StatelessWidget {
final ThemeData theme;
final UserPreviewDataBean data;
const _ProfileCard({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: [
CircleAvatar(
radius: 30,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: data.iconId > 0
? NetworkImage('https://assets2.lxns.net/maimai/icon/${data.iconId}.png')
: null,
child: data.iconId == 0
? Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer)
: null,
),
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,
),
),
],
),
),
Icon(
data.isLogin ? Icons.circle : Icons.circle_outlined,
color: data.isLogin ? Colors.green : Colors.grey,
size: 14,
),
const SizedBox(width: 4),
Text(
data.isLogin ? AppStrings.online : AppStrings.offline,
style: theme.textTheme.labelSmall?.copyWith(
color: data.isLogin ? Colors.green : Colors.grey,
),
),
],
),
),
);
}
}
class _GameInfoCard extends StatelessWidget {
final ThemeData theme;
final UserPreviewDataBean data;
const _GameInfoCard({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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.gameInfo,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
_infoRow(theme, AppStrings.lastGame, data.lastGameId),
_infoRow(theme, AppStrings.lastPlay, data.lastPlayDate),
_infoRow(theme, AppStrings.lastLogin, data.lastLoginDate),
_infoRow(theme, AppStrings.romVersion, data.lastRomVersion),
_infoRow(theme, AppStrings.dataVersion, data.lastDataVersion),
],
),
),
);
}
Widget _infoRow(ThemeData theme, String label, String value) {
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,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '-' : value,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}
}
class _StatusCard extends StatelessWidget {
final ThemeData theme;
final UserPreviewDataBean data;
const _StatusCard({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: 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),
_statusChip(theme, AppStrings.inherit, data.isInherit),
_statusChip(theme, AppStrings.banState, data.banState == 0, trueText: AppStrings.clean, falseText: '已封禁 (${data.banState})'),
_statusChip(theme, AppStrings.dailyBonus, data.dailyBonusDate.isNotEmpty, trueText: data.dailyBonusDate, falseText: AppStrings.notClaimed),
],
),
),
);
}
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 _DetailCard extends StatefulWidget {
final ThemeData theme;
final UserPreviewDataBean data;
const _DetailCard({required this.theme, required this.data});
@override
State<_DetailCard> createState() => _DetailCardState();
}
class _DetailCardState extends State<_DetailCard> {
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.totalAwake, '${data.totalAwake}'),
_detailRow(theme, AppStrings.displayRate, '${data.dispRate}'),
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
_detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'),
_detailRow(theme, AppStrings.trophyId, '${data.trophyId}'),
_detailRow(theme, AppStrings.headphoneVol, '${data.headPhoneVolume}'),
_detailRow(theme, AppStrings.errorId, '${data.errorId}'),
],
],
),
),
),
);
}
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),
),
],
),
);
}
}
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,
),
),
),
],
),
);
}
}