新增一个chat_popup 来显示聊天窗口

This commit is contained in:
2025-09-22 17:15:44 +08:00
parent e04110c6d5
commit 049cb7b5c4
10 changed files with 912 additions and 20 deletions

127
README_ChatPopup.md Normal file
View File

@@ -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. 位置计算基于屏幕坐标,确保在正确的上下文中使用

View File

@@ -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';

View File

@@ -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<FullScreen> createState() => _FullScreenState();
State<FullScreenPage> createState() => _FullScreenState();
}
class _FullScreenState extends State<FullScreen> {
class _FullScreenState extends State<FullScreenPage> {
final ScrollController _scrollController = ScrollController();
@override

View File

@@ -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<MainScreen> {
),
),
),
FloatingIcon(),
// FloatingIcon(),
ChatPopup()
],
),
);

View File

@@ -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<PartScreen> {
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreen(),
child: const FullScreenPage(),
),
),
);
@@ -76,13 +76,21 @@ class _PartScreenState extends State<PartScreen> {
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<PartScreen> {
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,
);
}

View File

@@ -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;
}
}

View File

@@ -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<ChatWindowContent> createState() => _ChatWindowContentState();
}
class _ChatWindowContentState extends State<ChatWindowContent> {
final ScrollController _scrollController = ScrollController();
bool _isInitialized = false;
int _lastMessageCount = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final messageService = Provider.of<MessageService>(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<MessageService>();
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<MessageService>(
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<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: Curves.easeOutQuart,
)),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(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;
}
}

View File

@@ -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<LongPressEndDetails>? onLongPressEnd;
final VoidCallback? onDragStart;
final VoidCallback? onDragEnd;
final ValueChanged<Offset>? 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<ChatFloatingIcon> createState() => _ChatFloatingIconState();
}
class _ChatFloatingIconState extends State<ChatFloatingIcon> 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<String> _iconImages = [
'ai1_hd.png',
'ai0_hd.png',
];
late final List<String> _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<MessageService>(
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),
),
);
},
),
),
);
}
}

223
lib/widgets/chat_popup.dart Normal file
View File

@@ -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<ChatPopup> createState() => _ChatPopupState();
}
class _ChatPopupState extends State<ChatPopup> 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();
}
}

View File

@@ -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<FloatingIcon> with TickerProviderStateMix
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreen(),
child: const FullScreenPage(),
),
),
);