From 049cb7b5c468e06f7e60e9439b3d82475a2758bd Mon Sep 17 00:00:00 2001 From: "guangfei.zhao" Date: Mon, 22 Sep 2025 17:15:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=80=E4=B8=AAchat=5Fpopu?= =?UTF-8?q?p=20=E6=9D=A5=E6=98=BE=E7=A4=BA=E8=81=8A=E5=A4=A9=E7=AA=97?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README_ChatPopup.md | 127 +++++++++++ lib/ai_chat_assistant.dart | 2 +- lib/{screens => pages}/full_screen.dart | 8 +- lib/screens/main_screen.dart | 5 +- lib/screens/part_screen.dart | 45 ++-- lib/widgets/_hole_overlay.dart | 80 +++++++ lib/widgets/chat/chat_window_content.dart | 188 ++++++++++++++++ lib/widgets/chat_floating_icon.dart | 250 ++++++++++++++++++++++ lib/widgets/chat_popup.dart | 223 +++++++++++++++++++ lib/widgets/floating_icon.dart | 4 +- 10 files changed, 912 insertions(+), 20 deletions(-) create mode 100644 README_ChatPopup.md rename lib/{screens => pages}/full_screen.dart (93%) create mode 100644 lib/widgets/_hole_overlay.dart create mode 100644 lib/widgets/chat/chat_window_content.dart create mode 100644 lib/widgets/chat_floating_icon.dart create mode 100644 lib/widgets/chat_popup.dart diff --git a/README_ChatPopup.md b/README_ChatPopup.md new file mode 100644 index 0000000..d67bc12 --- /dev/null +++ b/README_ChatPopup.md @@ -0,0 +1,127 @@ +# ChatFloatingIcon 和 ChatPopup 组件说明 + +这两个组件用于替代原来的 `FloatingIcon`,解决了在 Stack 中会被上层遮挡的问题。 + +## 组件结构 + +### ChatFloatingIcon +可拖拽的悬浮图标组件,支持: +- 自定义图标和大小 +- 拖拽功能(自动吸附屏幕边缘) +- 长按开始语音输入 +- 点击进入全屏聊天 +- 多种动画效果 + +### ChatPopup +基于 Overlay 的弹窗容器,负责: +- 管理 PartScreen 的显示/隐藏 +- 根据图标位置自动计算弹窗位置 +- 处理背景点击关闭 +- 动画效果管理 + +## 使用方法 + +### 基本使用(默认AI图标) +```dart +ChatPopup( + child: ChatFloatingIcon( + iconSize: 80, // 可选,默认80 + ), +), +``` + +### 自定义图标 +```dart +ChatPopup( + child: ChatFloatingIcon( + iconSize: 60, + icon: Container( + width: 60, + height: 60, + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + child: const Icon(Icons.chat, color: Colors.white), + ), + ), +), +``` + +### 添加自定义回调 +```dart +ChatPopup( + child: ChatFloatingIcon( + iconSize: 70, + onTap: () { + // 自定义点击事件 + }, + onPositionChanged: (position) { + // 位置变化回调 + }, + onDragStart: () { + // 拖拽开始 + }, + onDragEnd: () { + // 拖拽结束 + }, + ), +), +``` + +## 参数说明 + +### ChatFloatingIcon 参数 +- `iconSize`: 图标大小,默认80 +- `icon`: 自定义图标Widget,不传则使用默认AI图标 +- `onTap`: 点击回调,默认进入全屏聊天 +- `onLongPress`: 长按开始回调,默认开始语音输入 +- `onLongPressUp`: 长按结束回调,默认结束语音输入 +- `onLongPressEnd`: 长按结束详细回调 +- `onDragStart`: 拖拽开始回调 +- `onDragEnd`: 拖拽结束回调 +- `onPositionChanged`: 位置变化回调 + +### ChatPopup 参数 +- `child`: 子组件,通常是ChatFloatingIcon +- `chatSize`: 聊天框大小,默认320x450 + +## 功能特性 + +1. **避免遮挡**: 使用 Overlay 显示弹窗,不会被 Stack 上层遮挡 +2. **位置自适应**: 根据图标位置自动计算弹窗显示位置 +3. **保持原有逻辑**: 与原 FloatingIcon 功能完全兼容 +4. **灵活自定义**: 支持自定义图标、回调等 +5. **动画丰富**: 保持原有的各种动画效果 + +## 迁移指南 + +### 从 FloatingIcon 迁移 +原来的代码: +```dart +Stack( + children: [ + // 其他内容 + FloatingIcon(), + ], +) +``` + +新的代码: +```dart +Stack( + children: [ + // 其他内容 + ChatPopup( + child: ChatFloatingIcon(), + ), + ], +) +``` + +## 注意事项 + +1. ChatPopup 必须在有 Overlay 的 Widget 树中使用(通常是 MaterialApp 下) +2. 如果要自定义图标,建议保持圆形设计以匹配拖拽动画 +3. 长按语音输入功能需要 MessageService 正确配置 +4. 位置计算基于屏幕坐标,确保在正确的上下文中使用 \ No newline at end of file diff --git a/lib/ai_chat_assistant.dart b/lib/ai_chat_assistant.dart index 42f1ea9..ce3c6ff 100644 --- a/lib/ai_chat_assistant.dart +++ b/lib/ai_chat_assistant.dart @@ -1,6 +1,6 @@ // widgets -export 'screens/full_screen.dart'; +export 'pages/full_screen.dart'; export 'screens/part_screen.dart'; export 'app.dart'; export 'widgets/floating_icon.dart'; diff --git a/lib/screens/full_screen.dart b/lib/pages/full_screen.dart similarity index 93% rename from lib/screens/full_screen.dart rename to lib/pages/full_screen.dart index ff7b280..8a5d579 100644 --- a/lib/screens/full_screen.dart +++ b/lib/pages/full_screen.dart @@ -9,14 +9,14 @@ import 'package:provider/provider.dart'; import '../widgets/chat_header.dart'; import '../widgets/chat_footer.dart'; -class FullScreen extends StatefulWidget { - const FullScreen({super.key}); +class FullScreenPage extends StatefulWidget { + const FullScreenPage({super.key}); @override - State createState() => _FullScreenState(); + State createState() => _FullScreenState(); } -class _FullScreenState extends State { +class _FullScreenState extends State { final ScrollController _scrollController = ScrollController(); @override diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 79fb72e..f8b0927 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../widgets/floating_icon.dart'; import '../utils/assets_util.dart'; +import '../widgets/chat_popup.dart'; +import '../widgets/chat_floating_icon.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -34,7 +36,8 @@ class _MainScreenState extends State { ), ), ), - FloatingIcon(), + // FloatingIcon(), + ChatPopup() ], ), ); diff --git a/lib/screens/part_screen.dart b/lib/screens/part_screen.dart index 485dc3f..de07282 100644 --- a/lib/screens/part_screen.dart +++ b/lib/screens/part_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'dart:ui'; import '../widgets/chat_box.dart'; -import '../screens/full_screen.dart'; import 'package:provider/provider.dart'; import '../services/message_service.dart'; import '../widgets/gradient_background.dart'; import '../utils/assets_util.dart'; +import '../pages/full_screen.dart'; class PartScreen extends StatefulWidget { final VoidCallback? onHide; @@ -66,7 +66,7 @@ class _PartScreenState extends State { MaterialPageRoute( builder: (context) => ChangeNotifierProvider.value( value: messageService, // 传递同一个单例实例 - child: const FullScreen(), + child: const FullScreenPage(), ), ), ); @@ -76,13 +76,21 @@ class _PartScreenState extends State { EdgeInsets _calculatePosition(BoxConstraints constraints) { final screenWidth = constraints.maxWidth; final screenHeight = constraints.maxHeight; - final iconX = screenWidth - widget.floatingIconPosition.dx; - final iconY = screenHeight - widget.floatingIconPosition.dy; - // 聊天框的尺寸 final chatWidth = screenWidth * 0.94; final maxChatHeight = screenHeight * 0.4; + final iconX = screenWidth - widget.floatingIconPosition.dx; + final iconY = screenHeight - widget.floatingIconPosition.dy; + + // 1. 先将icon的right/bottom转为屏幕坐标(icon左下角) + var iconPosition = widget.floatingIconPosition; + + final iconLeft = screenWidth - iconPosition.dx - widget.iconSize; + final iconBottom = iconPosition.dy; + final iconTop = iconBottom + widget.iconSize; + final iconCenterX = iconLeft + widget.iconSize / 2; + // 判断FloatingIcon在屏幕的哪一边 final isOnRightSide = iconX < screenWidth / 2; @@ -96,18 +104,31 @@ class _PartScreenState extends State { leftPadding = screenWidth - chatWidth - (screenWidth - chatWidth) * 0.1; } - // 计算垂直位置,确保聊天框在图标上方 - double bottomPadding = iconY + widget.iconSize + 20; + // 优先尝试放在icon上方 + double chatBottom = iconTop + 12; // 聊天框底部距离icon顶部12px + double chatTop = screenHeight - chatBottom - maxChatHeight; - // 确保聊天框不会超出屏幕 - final minBottomPadding = screenHeight * 0.15; - final maxBottomPadding = screenHeight - maxChatHeight - 50; - bottomPadding = bottomPadding.clamp(minBottomPadding, maxBottomPadding); + // 如果上方空间不足,则放在icon下方 + if (chatTop < 0) { + chatBottom = iconBottom - 12 - maxChatHeight / 2; // 聊天框顶部距离icon底部12px + // 如果下方也不足,则贴底 + if (chatBottom < 0) { + chatBottom = 20; // 距离底部20px + } + } + + // // 计算垂直位置,确保聊天框在图标上方 + // double bottomPadding = iconY + widget.iconSize + 20; + // + // // 确保聊天框不会超出屏幕 + // final minBottomPadding = screenHeight * 0.15; + // final maxBottomPadding = screenHeight - maxChatHeight - 50; + // bottomPadding = bottomPadding.clamp(minBottomPadding, maxBottomPadding); return EdgeInsets.only( left: leftPadding, right: screenWidth - leftPadding - chatWidth, - bottom: bottomPadding, + bottom: chatBottom, ); } diff --git a/lib/widgets/_hole_overlay.dart b/lib/widgets/_hole_overlay.dart new file mode 100644 index 0000000..4097cfc --- /dev/null +++ b/lib/widgets/_hole_overlay.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; + +/// 遮罩层,icon 区域挖洞可交互 +class HoleOverlay extends StatelessWidget { + final Offset iconPosition; + final double iconSize; + final VoidCallback? onTap; + + const HoleOverlay({ + super.key, + required this.iconPosition, + required this.iconSize, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (PointerDownEvent event) { + final RenderBox box = context.findRenderObject() as RenderBox; + final Offset local = box.globalToLocal(event.position); + final iconRect = Rect.fromLTWH( + iconPosition.dx, + iconPosition.dy, + iconSize, + iconSize, + ); + if (!iconRect.contains(local)) { + // 遮罩区域,拦截并关闭弹窗 + if (onTap != null) { + onTap!(); + } + } + // 如果点在icon区域,什么都不做,事件会传递到下层 + }, + child: CustomPaint( + size: Size.infinite, + painter: _HolePainter( + iconPosition: iconPosition, + iconSize: iconSize, + ), + ), + ); + } +} + +class _HolePainter extends CustomPainter { + final Offset iconPosition; + final double iconSize; + + _HolePainter({required this.iconPosition, required this.iconSize}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white.withOpacity(0.0) + ..blendMode = BlendMode.srcOver; + + // 画全屏遮罩 + canvas.drawRect(Offset.zero & size, paint); + + // 挖洞 + final holeRect = Rect.fromLTWH( + iconPosition.dx, + iconPosition.dy, + iconSize, + iconSize, + ); + final holePath = Path()..addOval(holeRect); + paint.blendMode = BlendMode.clear; + canvas.drawPath(holePath, paint); + } + + @override + bool shouldRepaint(covariant _HolePainter oldDelegate) { + return iconPosition != oldDelegate.iconPosition || iconSize != oldDelegate.iconSize; + } +} \ No newline at end of file diff --git a/lib/widgets/chat/chat_window_content.dart b/lib/widgets/chat/chat_window_content.dart new file mode 100644 index 0000000..addc59c --- /dev/null +++ b/lib/widgets/chat/chat_window_content.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'dart:ui'; +import '../../widgets/chat_box.dart'; +import 'package:provider/provider.dart'; +import '../../services/message_service.dart'; +import '../../widgets/gradient_background.dart'; +import '../../utils/assets_util.dart'; +import '../../pages/full_screen.dart'; + +class ChatWindowContent extends StatefulWidget { + final AnimationController? animationController; + final double? minHeight; + final double? maxHeight; + final double? chatWidth; + + const ChatWindowContent({ + super.key, + this.animationController, + this.minHeight, + this.maxHeight, + this.chatWidth, + }); + + @override + State createState() => _ChatWindowContentState(); +} + +class _ChatWindowContentState extends State { + final ScrollController _scrollController = ScrollController(); + bool _isInitialized = false; + int _lastMessageCount = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final messageService = Provider.of(context, listen: false); + messageService.removeNonListeningMessages(); + setState(() { + _isInitialized = true; + }); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + void _openFullScreen() async { + final messageService = context.read(); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: messageService, // 传递同一个单例实例 + child: const FullScreenPage(), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return const SizedBox.shrink(); + } + + final minHeight = widget.minHeight ?? 0; + final maxHeight = widget.maxHeight ?? double.infinity; + final chatWidth = widget.chatWidth ?? double.infinity; + final animationController = widget.animationController; + + return Consumer( + builder: (context, messageService, child) { + final messageCount = messageService.messages.length; + if (messageCount > _lastMessageCount) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); + } + _lastMessageCount = messageCount; + var chatContent = _buildChatContent(messageService); + + // 包裹弹窗动画 + if (animationController != null) { + Widget content = FadeTransition( + opacity: animationController, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController, + curve: Curves.easeOutQuart, + )), + child: TweenAnimationBuilder( + tween: Tween(begin: 0.8, end: 1.0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + builder: (context, scale, child) { + return Transform.scale( + scale: scale, + child: Container( + width: chatWidth, + constraints: BoxConstraints( + minHeight: minHeight, + maxHeight: maxHeight, + ), + child: child, + ), + ); + }, + child: chatContent, + ), + ), + ); + } + Widget content = Container( + width: chatWidth, + constraints: BoxConstraints( + minHeight: minHeight, + maxHeight: maxHeight, + ), + child: chatContent, + ); + return content; + }, + ); + } + + Widget _buildChatContent(MessageService messageService) { + Widget content = ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: GradientBackground( + colors: const [ + Color(0XBF3B0A3F), + Color(0xBF0E0E24), + Color(0xBF0C0B33), + ], + borderRadius: BorderRadius.circular(6), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 50, left: 6, right: 6, bottom: 30), + child: LayoutBuilder( + builder: (context, boxConstraints) { + return ChatBox( + scrollController: _scrollController, + messages: messageService.messages, + ); + }, + ), + ), + Positioned( + top: 6, + right: 6, + child: IconButton( + icon: AssetsUtil.getImageWidget( + 'open_in_full.png', + width: 24, + height: 24, + ), + onPressed: _openFullScreen, + padding: EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ); + return content; + } +} diff --git a/lib/widgets/chat_floating_icon.dart b/lib/widgets/chat_floating_icon.dart new file mode 100644 index 0000000..b6ea66a --- /dev/null +++ b/lib/widgets/chat_floating_icon.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/message_service.dart'; +import '../pages/full_screen.dart'; +import 'floating_icon_with_wave.dart'; +import 'package:basic_intl/intl.dart'; +import '../utils/assets_util.dart'; + +class ChatFloatingIcon extends StatefulWidget { + final double? iconSize; + final Widget? icon; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onLongPressUp; + final ValueChanged? onLongPressEnd; + final VoidCallback? onDragStart; + final VoidCallback? onDragEnd; + final ValueChanged? onPositionChanged; + + const ChatFloatingIcon({ + super.key, + this.iconSize, + this.icon, + this.onTap, + this.onLongPress, + this.onLongPressUp, + this.onLongPressEnd, + this.onDragStart, + this.onDragEnd, + this.onPositionChanged, + }); + + @override + State createState() => _ChatFloatingIconState(); +} + +class _ChatFloatingIconState extends State with TickerProviderStateMixin { + double iconSize = 0.0; + Offset _position = const Offset(10, 120); + bool _isDragging = false; + bool _isAnimating = false; + Offset _targetPosition = const Offset(10, 120); + + // 水波纹动画控制器 + late AnimationController _waveAnimationController; + // 图片切换定时器 + Timer? _imageTimer; + // 添加长按状态标记 + bool _isLongPressing = false; + + // 图片切换相关 + int _imageIndex = 0; + late final List _iconImages = [ + 'ai1_hd.png', + 'ai0_hd.png', + ]; + + late final List _iconImagesEn = [ + 'ai1_hd_en.png', + 'ai0_hd_en.png', + ]; + + @override + void initState() { + super.initState(); + + iconSize = widget.iconSize ?? 80.0; + + _waveAnimationController = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + + // 如果没有提供自定义图标,则使用默认的图片切换逻辑 + if (widget.icon == null) { + _imageTimer = Timer.periodic(const Duration(seconds: 3), (timer) { + if (mounted && !_isDragging) { + setState(() { + _imageIndex = (_imageIndex + 1) % _iconImages.length; + }); + } + }); + } + } + + @override + void dispose() { + _waveAnimationController.dispose(); + _imageTimer?.cancel(); + super.dispose(); + } + + // 更新位置 + void _updatePosition(Offset delta) { + if (!_isDragging) return; + + setState(() { + final screenSize = MediaQuery.of(context).size; + double newX = (_position.dx - delta.dx).clamp(0.0, screenSize.width - iconSize); + double newY = (_position.dy - delta.dy).clamp(0.0, screenSize.height - iconSize); + _position = Offset(newX, newY); + }); + + // 通知父组件位置变化 + widget.onPositionChanged?.call(_position); + } + + // 吸附到屏幕边缘 + void _snapToEdge() { + final screenSize = MediaQuery.of(context).size; + final centerX = screenSize.width / 2; + + // 判断应该吸到左边还是右边 + final shouldSnapToLeft = _position.dx < centerX - iconSize / 2; + final targetX = shouldSnapToLeft ? 0.0 : screenSize.width - iconSize; + + setState(() { + _targetPosition = Offset(targetX, _position.dy); + _isAnimating = true; + }); + + // 使用定时器在动画完成后更新状态 + Timer(const Duration(milliseconds: 100), () { + if (mounted) { + setState(() { + _position = _targetPosition; + _isAnimating = false; + }); + // 通知父组件位置变化 + widget.onPositionChanged?.call(_position); + } + }); + } + + void _onPanStart(DragStartDetails details) { + _isDragging = true; + _waveAnimationController.stop(); + widget.onDragStart?.call(); + } + + void _onPanEnd(DragEndDetails details) { + _isDragging = false; + _snapToEdge(); + widget.onDragEnd?.call(); + } + + // 显示全屏界面 + void _showFullScreen() async { + final messageService = MessageService.instance; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: messageService, + child: const FullScreenPage(), + ), + ), + ); + } + + Widget _buildIconContent(MessageService messageService) { + // 如果提供了自定义图标,优先使用 + if (widget.icon != null) { + return widget.icon!; + } + + // 使用默认的图标逻辑 + if (messageService.isRecording) { + return FloatingIconWithWave( + animationController: _waveAnimationController, + iconSize: iconSize, + waveColor: Colors.white, + ); + } else { + return AssetsUtil.getImageWidget( + Intl.getCurrentLocale().startsWith('zh') + ? _iconImages[_imageIndex] + : _iconImagesEn[_imageIndex], + width: iconSize, + height: iconSize, + ); + } + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: MessageService.instance, + child: AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + bottom: _isAnimating ? _targetPosition.dy : _position.dy, + right: _isAnimating ? _targetPosition.dx : _position.dx, + child: Consumer( + builder: (context, messageService, child) { + return GestureDetector( + onTap: widget.onTap ?? _showFullScreen, + onLongPress: () async { + _isLongPressing = true; + _waveAnimationController.repeat(); + + if (widget.onLongPress != null) { + widget.onLongPress!(); + } else { + await messageService.startVoiceInput(); + } + }, + onLongPressUp: () async { + _isLongPressing = false; + _waveAnimationController.stop(); + + if (widget.onLongPressUp != null) { + widget.onLongPressUp!(); + } else { + await messageService.stopAndProcessVoiceInput(); + } + }, + onLongPressEnd: (details) { + _isLongPressing = false; + widget.onLongPressEnd?.call(details); + }, + onPanStart: (details) { + if (!_isLongPressing) { + _onPanStart(details); + } + }, + onPanUpdate: (details) { + if (_isDragging && !_isLongPressing) { + _updatePosition(details.delta); + } + }, + onPanEnd: (details) { + if (!_isLongPressing) { + _onPanEnd(details); + } + }, + child: AnimatedScale( + scale: _isDragging ? 1.1 : 1.0, + duration: const Duration(milliseconds: 150), + child: _buildIconContent(messageService), + ), + ); + }, + ), + ), + ); + } +} + diff --git a/lib/widgets/chat_popup.dart b/lib/widgets/chat_popup.dart new file mode 100644 index 0000000..0a2e979 --- /dev/null +++ b/lib/widgets/chat_popup.dart @@ -0,0 +1,223 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/message_service.dart'; +import 'chat/chat_window_content.dart'; +import 'chat_floating_icon.dart'; + +class ChatPopup extends StatefulWidget { + final double? iconSize; + final Widget? icon; + final Size chatSize; + + const ChatPopup({ + super.key, + this.iconSize, + this.icon, + this.chatSize = const Size(320, 450), + }); + + @override + State createState() => _ChatPopupState(); +} + +class _ChatPopupState extends State with SingleTickerProviderStateMixin { + OverlayEntry? _overlayEntry; + Offset _iconPosition = const Offset(10, 120); + final double _iconSizeDefault = 80.0; + bool _isShowingPopup = false; + late AnimationController _partScreenAnimationController; + + @override + void initState() { + super.initState(); + _partScreenAnimationController = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + _insertOverlay(); + }); + } + + @override + void dispose() { + _removeOverlay(); + _partScreenAnimationController.dispose(); + super.dispose(); + } + + void _insertOverlay() { + if (_overlayEntry != null) return; + _overlayEntry = OverlayEntry( + builder: (context) => _buildOverlayContent(), + ); + Overlay.of(context).insert(_overlayEntry!); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _showPopup() { + if (_isShowingPopup) return; + setState(() { + _isShowingPopup = true; + }); + _partScreenAnimationController.forward(); + } + + void _hidePopup() { + if (!_isShowingPopup) return; + _partScreenAnimationController.reverse().then((_) { + if (mounted) { + setState(() { + _isShowingPopup = false; + }); + } + }); + } + + Widget _buildOverlayContent() { + return ChangeNotifierProvider.value( + value: MessageService.instance, + child: Material( + color: Colors.transparent, + child: Stack( + children: [ + // 1. 底层:弹窗内容(只有在显示时才渲染) + if (_isShowingPopup) ...[ + _buildPopupBackground(), + LayoutBuilder( + builder: (context, constraints) { + final position = _calculatePosition(constraints); + final double minHeight = constraints.maxHeight * 0.16; + final double maxHeight = constraints.maxHeight * 0.4; + final double chatWidth = constraints.maxWidth * 0.94; + // 只在 isInitialized 时返回弹窗,否则返回空 + final chatWindow = ChatWindowContent( + animationController: _partScreenAnimationController, + minHeight: minHeight, + maxHeight: maxHeight, + chatWidth: chatWidth, + ); + if ((chatWindow as dynamic)._isInitialized == false) { + return const SizedBox.shrink(); + } + return Positioned( + left: position.left, + right: position.right, + bottom: position.bottom, + child: chatWindow, + ); + }, + ), + ], + // 2. 顶层:FloatingIcon(始终在最上层,事件不被遮挡) + _buildFloatingIcon(), + ], + ), + ), + ); + } + + Widget _buildPopupBackground() { + return Positioned.fill( + child: GestureDetector( + onTap: () { + final messageService = MessageService.instance; + _hidePopup(); + messageService.abortReply(); + messageService.initializeEmpty(); + }, + child: Container( + color: Colors.black.withOpacity(0.1), + ), + ), + ); + } + + Widget _buildFloatingIcon() { + return ChatFloatingIcon( + iconSize: widget.iconSize ?? _iconSizeDefault, + icon: widget.icon, + onTap: () { + // 默认点击弹出全屏 + }, + onLongPress: () async { + final messageService = MessageService.instance; + _showPopup(); + await messageService.startVoiceInput(); + }, + onLongPressUp: () async { + final messageService = MessageService.instance; + await messageService.stopAndProcessVoiceInput(); + final hasMessage = messageService.messages.isNotEmpty; + if (!hasMessage) { + _hidePopup(); + } + }, + onPositionChanged: (position) { + setState(() { + _iconPosition = position; + }); + // 重新构建 overlay 以更新弹窗位置 + _overlayEntry?.markNeedsBuild(); + }, + ); + } + + + // 计算PartScreen的位置,使其显示在FloatingIcon附近 + EdgeInsets _calculatePosition(BoxConstraints constraints) { + final screenWidth = constraints.maxWidth; + final screenHeight = constraints.maxHeight; + final chatWidth = screenWidth * 0.94; + final chatHeight = screenHeight * 0.4; + + // 1. 先将icon的right/bottom转为屏幕坐标(icon左下角) + final iconLeft = screenWidth - _iconPosition.dx - (widget.iconSize ?? _iconSizeDefault); + final iconBottom = _iconPosition.dy; + final iconTop = iconBottom + (widget.iconSize ?? _iconSizeDefault); + final iconCenterX = iconLeft + (widget.iconSize ?? _iconSizeDefault) / 2; + + // 判断FloatingIcon在屏幕的哪一边 + final isOnRightSide = iconCenterX > screenWidth / 2; + + // 计算水平位置 + double leftPadding; + if (isOnRightSide) { + // 图标在右边,聊天框显示在左边 + leftPadding = (screenWidth - chatWidth) * 0.1; + } else { + // 图标在左边,聊天框显示在右边 + leftPadding = screenWidth - chatWidth - (screenWidth - chatWidth) * 0.1; + } + + // 优先尝试放在icon上方 + double chatBottom = iconTop + 12; // 聊天框底部距离icon顶部12px + double chatTop = screenHeight - chatBottom - chatHeight; + + // 如果上方空间不足,则放在icon下方 + if (chatTop < 0) { + chatBottom = iconBottom - 12 - chatHeight / 2; // 聊天框顶部距离icon底部12px + // 如果下方也不足,则贴底 + if (chatBottom < 0) { + chatBottom = 20; // 距离底部20px + } + } + + return EdgeInsets.only( + left: leftPadding, + right: screenWidth - leftPadding - chatWidth, + bottom: chatBottom, + ); + } + + @override + Widget build(BuildContext context) { + // 只返回空容器,实际内容都在 Overlay 中 + return const SizedBox.shrink(); + } +} \ No newline at end of file diff --git a/lib/widgets/floating_icon.dart b/lib/widgets/floating_icon.dart index a5bd55f..bcc37ed 100644 --- a/lib/widgets/floating_icon.dart +++ b/lib/widgets/floating_icon.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/message_service.dart'; -import '../screens/full_screen.dart'; +import '../pages/full_screen.dart'; import '../screens/part_screen.dart'; import 'floating_icon_with_wave.dart'; import 'dart:async'; @@ -103,7 +103,7 @@ class _FloatingIconState extends State with TickerProviderStateMix MaterialPageRoute( builder: (context) => ChangeNotifierProvider.value( value: messageService, // 传递同一个单例实例 - child: const FullScreen(), + child: const FullScreenPage(), ), ), );