新增一个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

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();
}
}