612 lines
19 KiB
Dart
612 lines
19 KiB
Dart
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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
} |