Files
ai_chat_assistant/lib/widgets/chat_popup.dart

223 lines
6.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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