From 6523f8e51210591d6c7d667b4bb673b1fbfb8a05 Mon Sep 17 00:00:00 2001 From: "guangfei.zhao" Date: Tue, 23 Sep 2025 09:53:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=92=8C=E9=81=AE=E7=BD=A9=E5=B1=82=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=EF=BC=8C=E4=BC=98=E5=8C=96=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=92=8C=E7=BB=98=E5=88=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/_hole_overlay.dart | 106 +++++++++++------- lib/widgets/chat_popup.dart | 191 ++++++++++++++++++++++----------- 2 files changed, 197 insertions(+), 100 deletions(-) diff --git a/lib/widgets/_hole_overlay.dart b/lib/widgets/_hole_overlay.dart index 4097cfc..1135bf0 100644 --- a/lib/widgets/_hole_overlay.dart +++ b/lib/widgets/_hole_overlay.dart @@ -1,46 +1,52 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; -/// 遮罩层,icon 区域挖洞可交互 +// 一个带洞的遮罩层,点击洞外区域触发onTap事件 class HoleOverlay extends StatelessWidget { final Offset iconPosition; final double iconSize; final VoidCallback? onTap; + final Widget? child; const HoleOverlay({ super.key, required this.iconPosition, required this.iconSize, this.onTap, + this.child, }); @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!(); + debugPrint('🔧 HoleOverlay build: iconPosition=$iconPosition, iconSize=$iconSize'); + + final holeRect = Rect.fromLTWH( + iconPosition.dx, + iconPosition.dy, + iconSize, + iconSize, + ); + + return CustomPaint( + painter: _HolePainter( + iconPosition: iconPosition, + iconSize: iconSize, + ), + child: GestureDetector( + onTapDown: (details) { + final tapPosition = details.localPosition; + final isInHole = holeRect.contains(tapPosition); + debugPrint('🎯 HoleOverlay onTapDown: position=$tapPosition, holeRect=$holeRect, isInHole=$isInHole'); + + if (!isInHole) { + debugPrint('🔥 HoleOverlay: 点击洞外,触发onTap'); + onTap?.call(); + } else { + debugPrint('🚫 HoleOverlay: 点击洞内,不触发onTap'); } - } - // 如果点在icon区域,什么都不做,事件会传递到下层 - }, - child: CustomPaint( - size: Size.infinite, - painter: _HolePainter( - iconPosition: iconPosition, - iconSize: iconSize, - ), + }, + child: child, + behavior: HitTestBehavior.translucent, ), ); } @@ -50,31 +56,51 @@ class _HolePainter extends CustomPainter { final Offset iconPosition; final double iconSize; - _HolePainter({required this.iconPosition, required this.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); + + debugPrint('🎨 HolePainter paint: size=$size, holeRect=$holeRect'); + + // 使用 saveLayer 确保混合模式正确 + canvas.saveLayer(Offset.zero & size, Paint()); + + // 画半透明背景 + final backgroundPaint = Paint() + ..color = Colors.black.withOpacity(0.3); + canvas.drawRect(Offset.zero & size, backgroundPaint); + debugPrint('🔲 HolePainter paint: 绘制背景完成'); + + // 挖洞:使用 BlendMode.clear + final holePaint = Paint() + ..blendMode = BlendMode.clear; + canvas.drawOval(holeRect, holePaint); + debugPrint('⭕ HolePainter paint: 挖洞完成,holeRect=$holeRect'); + + canvas.restore(); + + // 调试:画红色边框显示洞的位置 + final debugPaint = Paint() + ..color = Colors.red + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + canvas.drawOval(holeRect, debugPaint); + debugPrint('🔴 HolePainter paint: 绘制调试边框完成'); } @override bool shouldRepaint(covariant _HolePainter oldDelegate) { - return iconPosition != oldDelegate.iconPosition || iconSize != oldDelegate.iconSize; + return oldDelegate.iconPosition != iconPosition || + oldDelegate.iconSize != iconSize; } -} \ No newline at end of file +} diff --git a/lib/widgets/chat_popup.dart b/lib/widgets/chat_popup.dart index 8b41c42..0041337 100644 --- a/lib/widgets/chat_popup.dart +++ b/lib/widgets/chat_popup.dart @@ -26,9 +26,9 @@ class ChatPopup extends StatefulWidget { } 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 @@ -42,71 +42,47 @@ class _ChatPopupState extends State with SingleTickerProviderStateMix @override void dispose() { - _removeOverlay(); _partScreenAnimationController.dispose(); super.dispose(); } - void _openFullScreen() async { - final messageService = context.read(); - _removeOverlay(); - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChangeNotifierProvider.value( - value: messageService, // 传递同一个单例实例 - child: const FullScreenPage(), - ), - ), - ); - } - void _insertOverlay() { - if (_overlayEntry != null) return; - _overlayEntry = OverlayEntry( - builder: (context) => _buildOverlayContent(), - ); - Overlay.of(context).insert(_overlayEntry!); + if (_isShowingPopup) return; + setState(() { + _isShowingPopup = true; + }); _partScreenAnimationController.forward(); } void _removeOverlay() { - if (_overlayEntry == null) return; + if (!_isShowingPopup) return; _partScreenAnimationController.reverse().then((_) { - _overlayEntry?.remove(); - _overlayEntry = null; + if (mounted) { + setState(() { + _isShowingPopup = false; + }); + } }); } - Widget _buildOverlayContent() { + @override + Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: MessageService.instance, - child: Material( - color: Colors.transparent, - child: 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; - return Stack( - children: [ + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + // 1. 底层:弹窗内容(只有在显示时才渲染) + if (_isShowingPopup) ...[ _buildPopupBackground(), - Positioned( - left: position.left, - right: position.right, - bottom: position.bottom, - child: ChatWindowContent( - animationController: _partScreenAnimationController, - minHeight: minHeight, - maxHeight: maxHeight, - chatWidth: chatWidth, - ), - ), + _buildPopupContent(constraints), ], - ); - }, - ), + // 2. 顶层:FloatingIcon(始终在最上层,事件不被遮挡) + _buildFloatingIcon(), + ], + ); + }, ), ); } @@ -120,23 +96,125 @@ class _ChatPopupState extends State with SingleTickerProviderStateMix messageService.abortReply(); messageService.initializeEmpty(); }, - child: Container( - color: Colors.black.withOpacity(0.1), - ), + child: SizedBox.expand() + ), + ); + } + + Widget _buildPopupContent(BoxConstraints 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; + + return Positioned( + left: position.left, + right: position.right, + bottom: position.bottom, + child: ChatWindowContent( + animationController: _partScreenAnimationController, + minHeight: minHeight, + maxHeight: maxHeight, + chatWidth: chatWidth, ), ); } Widget _buildFloatingIcon() { + // 如果外部传入了 child,优先使用它的属性 + if (widget.child != null) { + return ChatFloatingIcon( + iconSize: widget.child!.iconSize ?? widget.iconSize ?? _iconSizeDefault, + icon: widget.child!.icon ?? widget.icon, + onTap: () async { + debugPrint('🖱️ FloatingIcon onTap triggered! (using child properties)'); + // 先执行外部传入的 onTap(如果有) + if (widget.child!.onTap != null) { + widget.child!.onTap!(); + } else { + // 默认行为:关闭弹窗并打开全屏 + final messageService = context.read(); + _removeOverlay(); + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: messageService, + child: const FullScreenPage(), + ), + ), + ); + } + }, + onLongPress: () async { + debugPrint('⏳ FloatingIcon onLongPress triggered! (using child properties)'); + // 先执行外部传入的 onLongPress(如果有) + if (widget.child!.onLongPress != null) { + widget.child!.onLongPress!(); + } + // 执行内部业务逻辑 + final messageService = MessageService.instance; + _insertOverlay(); + await messageService.startVoiceInput(); + }, + onLongPressUp: () async { + debugPrint('⏫ FloatingIcon onLongPressUp triggered! (using child properties)'); + // 先执行外部传入的 onLongPressUp(如果有) + if (widget.child!.onLongPressUp != null) { + widget.child!.onLongPressUp!(); + } + // 执行内部业务逻辑 + final messageService = MessageService.instance; + await messageService.stopAndProcessVoiceInput(); + final hasMessage = messageService.messages.isNotEmpty; + if (!hasMessage) { + _removeOverlay(); + } + }, + onPositionChanged: (position) { + debugPrint('📍 FloatingIcon position changed: $position (using child properties)'); + // 先执行外部传入的 onPositionChanged(如果有) + if (widget.child!.onPositionChanged != null) { + widget.child!.onPositionChanged!(position); + } + // 执行内部业务逻辑 + setState(() { + _iconPosition = position; + }); + }, + // 传递其他可能的属性 + onLongPressEnd: widget.child!.onLongPressEnd, + onDragStart: widget.child!.onDragStart, + onDragEnd: widget.child!.onDragEnd, + ); + } + + // 如果没有传入 child,使用原来的逻辑 return ChatFloatingIcon( iconSize: widget.iconSize ?? _iconSizeDefault, icon: widget.icon, + onTap: () async { + debugPrint('🖱️ FloatingIcon onTap triggered!'); + final messageService = context.read(); + _removeOverlay(); + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: messageService, // 传递同一个单例实例 + child: const FullScreenPage(), + ), + ), + ); + }, onLongPress: () async { + debugPrint('⏳ FloatingIcon onLongPress triggered!'); final messageService = MessageService.instance; _insertOverlay(); await messageService.startVoiceInput(); }, onLongPressUp: () async { + debugPrint('⏫ FloatingIcon onLongPressUp triggered!'); final messageService = MessageService.instance; await messageService.stopAndProcessVoiceInput(); final hasMessage = messageService.messages.isNotEmpty; @@ -145,11 +223,10 @@ class _ChatPopupState extends State with SingleTickerProviderStateMix } }, onPositionChanged: (position) { + debugPrint('📍 FloatingIcon position changed: $position'); setState(() { _iconPosition = position; }); - // 重新构建 overlay 以更新弹窗位置 - _overlayEntry?.markNeedsBuild(); }, ); } @@ -199,10 +276,4 @@ class _ChatPopupState extends State with SingleTickerProviderStateMix bottom: chatBottom, ); } - - @override - Widget build(BuildContext context) { - // 只渲染 FloatingIcon,Overlay 弹窗内容在长按时插入 - return _buildFloatingIcon(); - } }