- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层 - 修复所有相对路径导入错误,统一调整为扁平化模块引用 - 更新多平台 pubspec 版本号与依赖库版本 - 补充后端功能问题管理后台与脚本工具 - 调整部分页面的快捷方式文案适配新功能 - 更新部分翻译覆盖率与API文档
554 lines
20 KiB
Dart
554 lines
20 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 个人中心页面
|
||
/// 创建时间: 2026-05-01
|
||
/// 更新时间: 2026-06-07
|
||
/// 作用: 用户个人中心 — 头像信息 + 统计栏 + 快捷入口 + 可编辑信息 + 账户设置 + 调试
|
||
/// 上次更新: 修复accountInsightsProvider未初始化导致Bad state崩溃,增加authState守卫;修复_loadDashboard异常静默吞掉问题;修复score类型安全
|
||
/// ============================================================
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_animate/flutter_animate.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:xianyan/core/router/app_nav_extension.dart';
|
||
import 'package:xianyan/core/router/navigator_extension.dart';
|
||
import 'package:xianyan/core/utils/logger.dart';
|
||
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../core/theme/app_spacing.dart';
|
||
import '../../../core/theme/app_typography.dart';
|
||
import '../../../core/theme/app_radius.dart';
|
||
import '../../../core/router/app_routes.dart';
|
||
import '../../../shared/widgets/containers/glass_container.dart';
|
||
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
|
||
import '../../../shared/widgets/feedback/offline_banner.dart';
|
||
import '../../../shared/widgets/feedback/app_toast.dart';
|
||
import '../../../shared/widgets/adaptive/responsive_layout.dart';
|
||
import '../../../l10n/translation_resolver.dart';
|
||
import '../../auth/providers/auth_provider.dart';
|
||
import '../services/user_center_service.dart';
|
||
import '../providers/account_insights_provider.dart';
|
||
import 'favorite_search_sheet.dart';
|
||
import 'widgets/account_insights_sheet.dart';
|
||
import 'widgets/profile_header_row.dart';
|
||
import 'widgets/user_stats_bar.dart';
|
||
import 'widgets/quick_action_grid.dart';
|
||
import 'widgets/editable_info_section.dart';
|
||
import 'widgets/account_section.dart';
|
||
|
||
class UserCenterPage extends ConsumerStatefulWidget {
|
||
const UserCenterPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<UserCenterPage> createState() => _UserCenterPageState();
|
||
}
|
||
|
||
class _UserCenterPageState extends ConsumerState<UserCenterPage> {
|
||
Map<String, dynamic>? _dashboardData;
|
||
bool _isDashboardLoading = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadDashboard();
|
||
}
|
||
|
||
Future<void> _loadDashboard() async {
|
||
if (!ref.read(authProvider).isLoggedIn) return;
|
||
try {
|
||
final data = await UserCenterService.getDashboard();
|
||
if (mounted) {
|
||
setState(() {
|
||
_dashboardData = data;
|
||
_isDashboardLoading = false;
|
||
});
|
||
}
|
||
} catch (e, st) {
|
||
Log.e('UserCenterPage: 加载面板数据失败', e, st);
|
||
if (mounted) setState(() => _isDashboardLoading = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
await Future.wait([
|
||
_loadDashboard(),
|
||
ref.read(authProvider.notifier).refreshUser(),
|
||
]);
|
||
}
|
||
|
||
/// 构建账户洞察按钮 — 仅在已登录时监听 accountInsightsProvider
|
||
/// 未初始化/未登录时展示简单图标,避免 provider 未初始化崩溃
|
||
Widget _buildInsightButton(AppThemeExtension ext, AuthState authState) {
|
||
// 未初始化或未登录时,不监听 accountInsightsProvider
|
||
if (!authState.isInitialized || !authState.isLoggedIn) {
|
||
return Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: ext.bgSecondary,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: const Icon(
|
||
CupertinoIcons.shield_fill,
|
||
size: 18,
|
||
color: CupertinoColors.systemGrey,
|
||
),
|
||
);
|
||
}
|
||
|
||
final unreadCount = ref.watch(accountInsightsProvider).unreadCount;
|
||
return GestureDetector(
|
||
onTap: () => AccountInsightsSheet.show(context, ref),
|
||
child: Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: ext.bgSecondary,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
const Icon(
|
||
CupertinoIcons.shield_fill,
|
||
size: 18,
|
||
color: CupertinoColors.systemGrey,
|
||
),
|
||
if (unreadCount > 0)
|
||
Positioned(
|
||
top: 2,
|
||
right: -2,
|
||
child: Container(
|
||
constraints: const BoxConstraints(
|
||
minWidth: 16,
|
||
minHeight: 16,
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
decoration: BoxDecoration(
|
||
color: ext.errorColor,
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.fromBorderSide(
|
||
BorderSide(color: ext.bgCard, width: 1.5),
|
||
),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
unreadCount > 9 ? '9+' : '$unreadCount',
|
||
style: const TextStyle(
|
||
color: CupertinoColors.white,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showAvatarPicker() {
|
||
final ext = AppTheme.ext(context);
|
||
final t = ref.read(translationsProvider);
|
||
showCupertinoModalPopup<void>(
|
||
context: context,
|
||
builder: (ctx) => CupertinoActionSheet(
|
||
title: Text(
|
||
t.profile.changeAvatar,
|
||
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
|
||
),
|
||
actions: [
|
||
CupertinoActionSheetAction(
|
||
onPressed: () {
|
||
Navigator.pop(ctx);
|
||
_showAvatarUrlInput();
|
||
},
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(CupertinoIcons.link, size: 18, color: ext.accent),
|
||
const SizedBox(width: 8),
|
||
Text(t.profile.inputAvatarUrl),
|
||
],
|
||
),
|
||
),
|
||
CupertinoActionSheetAction(
|
||
onPressed: () {},
|
||
isDefaultAction: true,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(CupertinoIcons.lock, size: 18, color: ext.textHint),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
t.profile.selectFromAlbum,
|
||
style: TextStyle(color: ext.textHint),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
cancelButton: CupertinoActionSheetAction(
|
||
isDestructiveAction: true,
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: Text(t.common.cancel),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showAvatarUrlInput() {
|
||
final controller = TextEditingController();
|
||
final dialogExt = AppTheme.ext(context);
|
||
final t = ref.read(translationsProvider);
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => CupertinoAlertDialog(
|
||
title: Text(t.profile.avatarUnderReview.replaceAll('🔍 ', '🔗 ')),
|
||
content: Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CupertinoTextField(
|
||
controller: controller,
|
||
placeholder: 'https://example.com/avatar.jpg',
|
||
keyboardType: TextInputType.url,
|
||
maxLength: 2048,
|
||
clearButtonMode: OverlayVisibilityMode.editing,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.systemGrey6,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
t.profile.avatarUrlHint,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: dialogExt.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: Text(t.common.cancel),
|
||
onPressed: () {
|
||
controller.dispose();
|
||
ctx.dismissDialog();
|
||
},
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
child: Text(t.common.confirm),
|
||
onPressed: () async {
|
||
final url = controller.text.trim();
|
||
ctx.dismissDialog();
|
||
controller.dispose();
|
||
if (url.isEmpty) {
|
||
AppToast.showWarning(t.profile.pleaseInputUrl);
|
||
return;
|
||
}
|
||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||
AppToast.showWarning(t.profile.urlMustStartWithHttp);
|
||
return;
|
||
}
|
||
if (url.length > 2048) {
|
||
AppToast.showWarning(t.profile.urlTooLong);
|
||
return;
|
||
}
|
||
try {
|
||
Uri.parse(url);
|
||
} catch (_) {
|
||
AppToast.showWarning(t.profile.invalidUrlFormat);
|
||
return;
|
||
}
|
||
final ext = AppTheme.ext(context);
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (dCtx) => CupertinoAlertDialog(
|
||
title: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
CupertinoActivityIndicator(color: ext.accent, radius: 12),
|
||
const SizedBox(width: 10),
|
||
Text(t.profile.avatarUnderReview),
|
||
],
|
||
),
|
||
content: Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(
|
||
t.profile.avatarReviewing,
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
try {
|
||
await UserCenterService.updateProfile(avatarUrl: url);
|
||
await ref.read(authProvider.notifier).refreshUser();
|
||
if (mounted) {
|
||
Navigator.of(context, rootNavigator: true).pop();
|
||
_showAvatarResult(true, t.profile.avatarChangeSuccess);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
Navigator.of(context, rootNavigator: true).pop();
|
||
_showAvatarResult(
|
||
false,
|
||
'${t.profile.avatarChangeFailed}: $e',
|
||
);
|
||
}
|
||
}
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showAvatarResult(bool success, String msg) {
|
||
final t = ref.read(translationsProvider);
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => CupertinoAlertDialog(
|
||
title: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
success
|
||
? CupertinoIcons.checkmark_circle_fill
|
||
: CupertinoIcons.xmark_circle_fill,
|
||
size: 20,
|
||
color: success
|
||
? CupertinoColors.systemGreen
|
||
: CupertinoColors.systemRed,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(success ? t.profile.success : t.profile.failed),
|
||
],
|
||
),
|
||
content: Text(msg),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: Text(t.profile.ok),
|
||
onPressed: () => ctx.dismissDialog(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
final authState = ref.watch(authProvider);
|
||
final user = authState.user;
|
||
final t = ref.watch(translationsProvider);
|
||
|
||
return CupertinoPageScaffold(
|
||
backgroundColor: ext.bgPrimary,
|
||
navigationBar: CupertinoNavigationBar(
|
||
middle: Text(
|
||
t.profile.title,
|
||
style: AppTypography.title3.copyWith(color: ext.textPrimary),
|
||
),
|
||
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
|
||
border: null,
|
||
leading: const AdaptiveBackButton(),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// 搜索入口
|
||
CupertinoButton(
|
||
padding: EdgeInsets.zero,
|
||
onPressed: () => FavoriteSearchPage.show(context),
|
||
child: Container(
|
||
width: 34,
|
||
height: 34,
|
||
decoration: BoxDecoration(
|
||
color: ext.bgSecondary,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Icon(
|
||
CupertinoIcons.search,
|
||
size: 18,
|
||
color: ext.accent,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_buildInsightButton(ext, authState),
|
||
],
|
||
),
|
||
),
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
children: [
|
||
const OfflineBanner(),
|
||
Expanded(
|
||
child: ResponsiveMaxWidth(
|
||
maxWidth: 900,
|
||
padding: AppSpacing.md,
|
||
child: CustomScrollView(
|
||
physics: const BouncingScrollPhysics(),
|
||
keyboardDismissBehavior:
|
||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||
slivers: [
|
||
if (!authState.isInitialized && user == null)
|
||
SliverFillRemaining(
|
||
hasScrollBody: false,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CupertinoActivityIndicator(color: ext.accent),
|
||
const SizedBox(height: AppSpacing.md),
|
||
Text(
|
||
t.profile.loading,
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
else if (authState.isLoggedIn && user != null) ...[
|
||
CupertinoSliverRefreshControl(onRefresh: _refresh),
|
||
SliverToBoxAdapter(
|
||
child: RepaintBoundary(
|
||
child:
|
||
ProfileHeaderRow(
|
||
ext: ext,
|
||
user: user,
|
||
displayScore:
|
||
(_dashboardData?['score'] as num?)
|
||
?.toInt() ??
|
||
user.score,
|
||
tp: t.profile,
|
||
onAvatarTap: _showAvatarPicker,
|
||
)
|
||
.animate()
|
||
.fadeIn(duration: 300.ms)
|
||
.slideY(begin: 0.05, end: 0),
|
||
),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: UserStatsBar(
|
||
ext: ext,
|
||
user: user,
|
||
dashboardData: _dashboardData,
|
||
isLoading: _isDashboardLoading,
|
||
).animate().fadeIn(duration: 300.ms, delay: 80.ms),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child:
|
||
QuickActionGrid(
|
||
ext: ext,
|
||
ref: ref,
|
||
profileTranslations: t.profile,
|
||
)
|
||
.animate()
|
||
.fadeIn(duration: 300.ms, delay: 130.ms)
|
||
.slideY(begin: 0.05, end: 0),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child:
|
||
EditableInfoSection(
|
||
ext: ext,
|
||
user: user,
|
||
onAvatarTap: _showAvatarPicker,
|
||
)
|
||
.animate()
|
||
.fadeIn(duration: 300.ms, delay: 160.ms)
|
||
.slideY(begin: 0.05, end: 0),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: AccountSection(ext: ext, tp: t.profile)
|
||
.animate()
|
||
.fadeIn(duration: 300.ms, delay: 180.ms)
|
||
.slideY(begin: 0.05, end: 0),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: DebugSection(ext: ext, tp: t.profile)
|
||
.animate()
|
||
.fadeIn(duration: 300.ms, delay: 220.ms)
|
||
.slideY(begin: 0.05, end: 0),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: AppInfoFooter(
|
||
ext: ext,
|
||
).animate().fadeIn(duration: 300.ms, delay: 260.ms),
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: SizedBox(
|
||
height: MediaQuery.of(context).viewInsets.bottom + 40,
|
||
),
|
||
),
|
||
] else ...[
|
||
SliverToBoxAdapter(
|
||
child: _buildLoggedOutContent(context, ext),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLoggedOutContent(BuildContext context, AppThemeExtension ext) {
|
||
final t = ref.read(translationsProvider);
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.md,
|
||
vertical: AppSpacing.xxl,
|
||
),
|
||
child: GlassContainer(
|
||
child: Column(
|
||
children: [
|
||
Icon(
|
||
CupertinoIcons.person_crop_circle_badge_xmark,
|
||
size: 48,
|
||
color: ext.textHint,
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
Text(
|
||
t.profile.tapToLogin,
|
||
style: AppTypography.title3.copyWith(color: ext.textPrimary),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Text(
|
||
t.profile.loginToViewProfile,
|
||
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
CupertinoButton(
|
||
color: ext.accent,
|
||
borderRadius: AppRadius.pillBorder,
|
||
onPressed: () => context.appPush(AppRoutes.login),
|
||
child: Text(
|
||
t.profile.goLogin,
|
||
style: AppTypography.body.copyWith(
|
||
color: CupertinoColors.white,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|