Files
MaliangAINovalWriter/AINoval/lib/widgets/common/user_avatar_menu.dart
2025-09-10 00:07:52 +08:00

612 lines
19 KiB
Dart
Raw Permalink 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 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/blocs/auth/auth_bloc.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'dart:math' as math;
import 'package:ainoval/screens/auth/enhanced_login_screen.dart';
import 'package:ainoval/screens/user/user_settings_screen.dart';
import 'package:ainoval/screens/settings/settings_panel.dart';
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
import 'package:ainoval/models/editor_settings.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
/// 用户头像下拉菜单组件
class UserAvatarMenu extends StatefulWidget {
const UserAvatarMenu({
Key? key,
this.size = 16,
this.showName = false,
this.onMySubscription,
this.onProfile,
this.onAccountSettings,
this.onHelp,
this.onLogout,
this.onOpenSettings,
}) : super(key: key);
final double size;
final bool showName;
final VoidCallback? onMySubscription;
final VoidCallback? onProfile;
final VoidCallback? onAccountSettings;
final VoidCallback? onHelp;
final VoidCallback? onLogout;
final VoidCallback? onOpenSettings;
@override
State<UserAvatarMenu> createState() => _UserAvatarMenuState();
}
class _UserAvatarMenuState extends State<UserAvatarMenu> {
final GlobalKey _buttonKey = GlobalKey();
OverlayEntry? _overlayEntry;
bool _isMenuOpen = false;
final GlobalKey _menuContentKey = GlobalKey();
double? _resolvedMenuTop;
double? _resolvedMenuLeft;
@override
void dispose() {
// 只关闭overlay不调用setState
_overlayEntry?.remove();
_overlayEntry = null;
super.dispose();
}
void _toggleMenu() {
if (_isMenuOpen) {
_closeMenu();
} else {
_openMenu();
}
}
void _openMenu() {
if (_buttonKey.currentContext == null) {
return;
}
final RenderBox renderBox = _buttonKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;
final double screenWidth = MediaQuery.of(context).size.width;
const double baseMenuWidth = 240.0;
// 默认对齐按钮右侧,向左展开,并作水平边界夹紧
final double initialDesiredLeft = offset.dx + size.width - baseMenuWidth;
final double initialLeft = initialDesiredLeft.clamp(8.0, screenWidth - baseMenuWidth - 8.0);
_resolvedMenuLeft = initialLeft;
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// 透明层,点击关闭菜单
Positioned.fill(
child: GestureDetector(
onTap: _closeMenu,
child: Container(
color: Colors.transparent,
),
),
),
// 菜单内容
Positioned(
top: _resolvedMenuTop ?? (offset.dy + size.height + 8),
left: _resolvedMenuLeft,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: WebTheme.getBackgroundColor(context),
shadowColor: WebTheme.getShadowColor(context, opacity: 0.2),
child: Container(
key: _menuContentKey,
width: 240,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: _buildMenuContent(),
),
),
),
],
),
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {
_isMenuOpen = true;
});
// 计算菜单高度,若底部空间不足则向上展开
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final menuSize = _menuContentKey.currentContext?.size;
if (menuSize == null) return;
final media = MediaQuery.of(context);
final screenHeight = media.size.height;
final screenWidth = media.size.width;
final spaceBelow = screenHeight - (offset.dy + size.height) - 8;
if (spaceBelow < menuSize.height + 8) {
final newTop = math.max(8.0, offset.dy - menuSize.height - 8);
if (_resolvedMenuTop != newTop) {
_resolvedMenuTop = newTop;
_overlayEntry?.markNeedsBuild();
}
} else {
final newTop = offset.dy + size.height + 8;
if (_resolvedMenuTop != newTop) {
_resolvedMenuTop = newTop;
_overlayEntry?.markNeedsBuild();
}
}
// 根据实际菜单宽度再次夹紧水平位置,避免左/右越界
final menuWidth = menuSize.width;
final desiredLeft = offset.dx + size.width - menuWidth; // 右对齐按钮
final clampedLeft = desiredLeft.clamp(8.0, screenWidth - menuWidth - 8.0);
if (_resolvedMenuLeft != clampedLeft) {
_resolvedMenuLeft = clampedLeft;
_overlayEntry?.markNeedsBuild();
}
});
}
void _closeMenu() {
_overlayEntry?.remove();
_overlayEntry = null;
if (mounted) {
setState(() {
_isMenuOpen = false;
_resolvedMenuTop = null;
_resolvedMenuLeft = null;
});
}
}
Widget _buildMenuContent() {
final username = AppConfig.username ?? '游客';
final userId = AppConfig.userId ?? '游客';
final bool isAuthed = context.read<AuthBloc>().state is AuthAuthenticated;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 用户信息头部
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: WebTheme.getPrimaryColor(context).withOpacity(WebTheme.isDarkMode(context) ? 0.2 : 0.1),
child: Icon(
Icons.person,
size: 24,
color: WebTheme.getPrimaryColor(context),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
username,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'ID: $userId',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
// 分割线
Divider(
height: 1,
color: WebTheme.getBorderColor(context),
thickness: 1,
),
// 菜单项
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
if (isAuthed) ...[
_buildMenuItem(
icon: Icons.person_outline,
label: '个人资料',
onTap: () {
_closeMenu();
if (widget.onProfile != null) {
widget.onProfile!.call();
} else {
_handleProfileTap();
}
},
),
_buildMenuItem(
icon: Icons.workspace_premium,
label: '我的订阅',
onTap: () {
_closeMenu();
if (widget.onMySubscription != null) {
widget.onMySubscription!.call();
} else {
_openMySubscriptionPanel();
}
},
),
_buildMenuItem(
icon: Icons.settings_outlined,
label: '账户设置',
onTap: () {
_closeMenu();
if (widget.onAccountSettings != null) {
widget.onAccountSettings!.call();
} else {
_handleSettingsTap();
}
},
),
_buildMenuItem(
icon: Icons.help_outline,
label: '帮助中心',
onTap: () {
_closeMenu();
if (widget.onHelp != null) {
widget.onHelp!.call();
} else {
_handleHelpTap();
}
},
),
const SizedBox(height: 8),
Divider(
height: 1,
color: WebTheme.getBorderColor(context),
thickness: 1,
indent: 16,
endIndent: 16,
),
const SizedBox(height: 8),
_buildMenuItem(
icon: Icons.logout,
label: '退出登录',
onTap: () {
_closeMenu();
if (widget.onLogout != null) {
widget.onLogout!.call();
} else {
_handleLogout();
}
},
isDestructive: true,
),
] else ...[
_buildMenuItem(
icon: Icons.login,
label: '登录账号',
onTap: () {
_closeMenu();
_openLoginDialog();
},
),
_buildMenuItem(
icon: Icons.help_outline,
label: '帮助中心',
onTap: () {
_closeMenu();
if (widget.onHelp != null) {
widget.onHelp!.call();
} else {
_handleHelpTap();
}
},
),
],
],
),
),
],
);
}
Widget _buildMenuItem({
required IconData icon,
required String label,
required VoidCallback onTap,
bool isDestructive = false,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
icon,
size: 20,
color: isDestructive
? Theme.of(context).colorScheme.error
: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 14,
color: isDestructive
? Theme.of(context).colorScheme.error
: WebTheme.getTextColor(context),
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
void _handleProfileTap() {
// 通过onOpenSettings回调打开设置面板并定位到账户管理
if (widget.onOpenSettings != null) {
widget.onOpenSettings!.call();
return;
}
// 回退:如果缺少回调,则尝试在当前上下文直接打开设置面板
try {
_openSettingsPanelFallback();
} catch (_) {
TopToast.info(context, '请通过设置面板查看个人资料');
}
}
void _handleSettingsTap() {
if (widget.onOpenSettings != null) {
widget.onOpenSettings!.call();
return;
}
// 回退:优先尝试打开设置面板,其次再退回旧的设置页
try {
_openSettingsPanelFallback();
return;
} catch (_) {}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const UserSettingsScreen(),
),
);
}
void _handleHelpTap() {
// TODO: 导航到帮助页面
TopToast.info(context, '帮助中心功能开发中');
}
void _openMySubscriptionPanel() {
// 简单实现:打开设置面板并定位到“会员与订阅”标签页
// 如果现有页面没有路由,先给出提示
TopToast.info(context, '打开“我的订阅”,请在设置面板中查看会员与订阅标签');
// TODO: 若有全局状态或路由可直接跳转到 SettingsPanel 并定位到会员页
}
// 回退:在没有 onOpenSettings 的页面尝试直接弹出 SettingsPanel
void _openSettingsPanelFallback() {
// 需要 EditorLayoutManager/StateManager 等依赖在构造 SettingsPanel
// 在非编辑器页面使用最小依赖构造并通过 Dialog 弹出
showDialog(
context: context,
barrierDismissible: true,
builder: (dialogContext) {
// 延迟导入,避免循环依赖
return Dialog(
insetPadding: const EdgeInsets.all(16),
backgroundColor: Colors.transparent,
child: SettingsPanel(
stateManager: EditorStateManager(),
userId: AppConfig.userId ?? 'current_user',
onClose: () => Navigator.of(dialogContext).pop(),
editorSettings: const EditorSettings(),
onEditorSettingsChanged: (_) {},
initialCategoryIndex: SettingsPanel.accountManagementCategoryIndex,
),
);
},
);
}
void _handleLogout() {
_showLogoutConfirmDialog();
}
void _openLoginDialog() {
showDialog(
context: context,
barrierDismissible: true,
builder: (context) => Dialog(
insetPadding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SizedBox(
width: MediaQuery.of(context).size.width >= 992
? 960
: MediaQuery.of(context).size.width - 32,
height: MediaQuery.of(context).size.height - 32,
child: const EnhancedLoginScreen(),
),
),
);
}
void _showLogoutConfirmDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: WebTheme.getBackgroundColor(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
size: 24,
),
const SizedBox(width: 12),
Text(
'确认退出',
style: TextStyle(
color: WebTheme.getTextColor(context),
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
],
),
content: Text(
'您确定要退出登录吗?退出后需要重新登录才能使用。',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 16,
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
foregroundColor: WebTheme.getSecondaryTextColor(context),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
_performLogoutAndClose();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('退出登录'),
),
],
),
);
}
void _performLogoutAndClose() async {
// 立即关闭对话框
Navigator.of(context).pop();
// 显示简短的退出提示
if (mounted) {
TopToast.info(context, '正在退出登录...');
}
// 稍微延迟后执行退出确保UI更新完成
await Future.delayed(Duration(milliseconds: 100));
if (mounted) {
// 调用AuthBloc执行登出
context.read<AuthBloc>().add(AuthLogout());
// 强制导航到登录页面,确保退出后立即跳转
await Future.delayed(Duration(milliseconds: 200)); // 等待AuthBloc处理完毕
if (mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(
'/', // 回到根路由(登录页面)
(route) => false, // 清除所有路由栈
);
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
key: _buttonKey,
onTap: _toggleMenu,
behavior: HitTestBehavior.opaque, // 确保整个区域都可点击
child: Container(
padding: const EdgeInsets.all(8), // 增大点击区域
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isMenuOpen
? WebTheme.getSurfaceColor(context)
: Colors.transparent,
),
child: widget.showName
? Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: widget.size,
backgroundColor: WebTheme.getEmptyStateColor(context),
child: Icon(
Icons.person,
size: widget.size * 1.2,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(width: 8),
Text(
AppConfig.username ?? '游客',
style: TextStyle(
color: WebTheme.getTextColor(context),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
Icon(
_isMenuOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
],
)
: CircleAvatar(
radius: widget.size,
backgroundColor: WebTheme.getEmptyStateColor(context),
child: Icon(
Icons.person,
size: widget.size * 1.2,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
);
}
}