223 lines
6.6 KiB
Dart
223 lines
6.6 KiB
Dart
|
|
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();
|
|||
|
|
}
|
|||
|
|
}
|