Files
ai_chat_assistant/lib/widgets/chat_popup.dart

292 lines
9.5 KiB
Dart
Raw Permalink 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:async';
import 'dart:ui';
import 'package:ai_chat_assistant/manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter_vosk_wakeword/flutter_vosk_wakeword.dart';
import 'package:provider/provider.dart';
import '../pages/full_screen.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;
final ChatFloatingIcon? child;
const ChatPopup({
super.key,
this.child,
this.iconSize,
this.icon,
this.chatSize = const Size(320, 450),
});
@override
State<ChatPopup> createState() => _ChatPopupState();
}
class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMixin {
Offset _iconPosition = const Offset(10, 120);
final double _iconSizeDefault = 80.0;
bool _isShowingPopup = false;
late AnimationController _partScreenAnimationController;
StreamSubscription? _wakeWordSubscription;
@override
void initState() {
super.initState();
_partScreenAnimationController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
// 订阅唤醒词事件
_wakeWordSubscription = AIChatAssistantManager.instance.onWakeWordDetected.listen((_) async {
await FlutterVoskWakeword.instance.stop();
// 在这里处理唤醒事件,例如显示聊天窗口
debugPrint("唤醒词被检测到执行UI操作");
final messageService = MessageService.instance;
_insertOverlay();
await messageService.startVoiceInput();
});
}
@override
void dispose() {
_partScreenAnimationController.dispose();
_wakeWordSubscription?.cancel();
super.dispose();
}
Future<void> _insertOverlay() async {
if (_isShowingPopup) return;
await FlutterVoskWakeword.instance.stop(); // 停止唤醒词监听,避免误触发
setState(() {
_isShowingPopup = true;
});
_partScreenAnimationController.forward();
}
void _removeOverlay() {
if (!_isShowingPopup) return;
FlutterVoskWakeword.instance.start(); // 重新开始监听唤醒词
_partScreenAnimationController.reverse().then((_) {
if (mounted) {
setState(() {
_isShowingPopup = false;
});
}
});
}
_openFullScreenPage() async {
final messageService = MessageService.instance;
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreenPage(),
),
),
);
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: MessageService.instance,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// 1. 底层:弹窗内容(只有在显示时才渲染)
if (_isShowingPopup) ...[
_buildPopupBackground(),
_buildPopupContent(constraints),
],
// 2. 顶层FloatingIcon始终在最上层事件不被遮挡
_buildFloatingIcon(),
],
);
},
),
);
}
Widget _buildPopupBackground() {
return Positioned.fill(
child: GestureDetector(
onTap: () {
final messageService = MessageService.instance;
_removeOverlay();
messageService.abortReply();
messageService.initializeEmpty();
},
child: SizedBox.expand(
child: Container(
color: Colors.black45.withValues(alpha: 0.1),
))));
}
Widget _buildPopupContent(BoxConstraints 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;
return Positioned(
left: position.left,
right: position.right,
bottom: position.bottom,
child: ChatWindowContent(
onCloseWindow: _removeOverlay,
animationController: _partScreenAnimationController,
minHeight: minHeight,
maxHeight: maxHeight,
chatWidth: chatWidth,
),
);
}
Widget _buildFloatingIcon() {
// 如果外部传入了 child优先使用它的属性
if (widget.child != null) {
return ChatFloatingIcon(
iconSize: widget.child!.iconSize ?? widget.iconSize ?? _iconSizeDefault,
icon: widget.child!.icon ?? widget.icon,
onTap: () async {
debugPrint('🖱️ FloatingIcon onTap triggered! (using child properties)');
// 先执行外部传入的 onTap如果有
if (widget.child!.onTap != null) {
widget.child!.onTap!();
}
// 默认行为:关闭弹窗并打开全屏
await _openFullScreenPage();
},
onLongPress: () async {
debugPrint('⏳ FloatingIcon onLongPress triggered! (using child properties)');
// 先执行外部传入的 onLongPress如果有
if (widget.child!.onLongPress != null) {
widget.child!.onLongPress!();
}
// 执行内部业务逻辑
final messageService = MessageService.instance;
_insertOverlay();
await messageService.startVoiceInput();
},
onLongPressUp: () async {
debugPrint('⏫ FloatingIcon onLongPressUp triggered! (using child properties)');
// 先执行外部传入的 onLongPressUp如果有
if (widget.child!.onLongPressUp != null) {
widget.child!.onLongPressUp!();
}
// 执行内部业务逻辑
final messageService = MessageService.instance;
await messageService.stopAndProcessVoiceInput();
final hasMessage = messageService.messages.isNotEmpty;
if (!hasMessage) {
_removeOverlay();
}
},
onPositionChanged: (position) {
debugPrint('📍 FloatingIcon position changed: $position (using child properties)');
// 先执行外部传入的 onPositionChanged如果有
if (widget.child!.onPositionChanged != null) {
widget.child!.onPositionChanged!(position);
}
// 执行内部业务逻辑
setState(() {
_iconPosition = position;
});
},
// 传递其他可能的属性
onLongPressEnd: widget.child!.onLongPressEnd,
onDragStart: widget.child!.onDragStart,
onDragEnd: widget.child!.onDragEnd,
);
}
// 如果没有传入 child使用原来的逻辑
return ChatFloatingIcon(
iconSize: widget.iconSize ?? _iconSizeDefault,
icon: widget.icon,
onTap: () async {
debugPrint('🖱️ FloatingIcon onTap triggered!');
await _openFullScreenPage();
},
onLongPress: () async {
debugPrint('⏳ FloatingIcon onLongPress triggered!');
final messageService = MessageService.instance;
_insertOverlay();
await messageService.startVoiceInput();
},
onLongPressUp: () async {
debugPrint('⏫ FloatingIcon onLongPressUp triggered!');
final messageService = MessageService.instance;
await messageService.stopAndProcessVoiceInput();
final hasMessage = messageService.messages.isNotEmpty;
if (!hasMessage) {
_removeOverlay();
}
},
onPositionChanged: (position) {
debugPrint('📍 FloatingIcon position changed: $position');
setState(() {
_iconPosition = position;
});
},
);
}
// 计算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,
);
}
}