新增一个chat_popup 来显示聊天窗口
This commit is contained in:
127
README_ChatPopup.md
Normal file
127
README_ChatPopup.md
Normal 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. 位置计算基于屏幕坐标,确保在正确的上下文中使用
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
80
lib/widgets/_hole_overlay.dart
Normal file
80
lib/widgets/_hole_overlay.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
188
lib/widgets/chat/chat_window_content.dart
Normal file
188
lib/widgets/chat/chat_window_content.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
250
lib/widgets/chat_floating_icon.dart
Normal file
250
lib/widgets/chat_floating_icon.dart
Normal 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
223
lib/widgets/chat_popup.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user