656 lines
19 KiB
Dart
656 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
|
||
import '../../../shared/widgets/voice_input_button.dart';
|
||
import '../providers/dialogue_provider.dart';
|
||
import '../widgets/dialogue_background.dart';
|
||
import '../widgets/character_avatar.dart';
|
||
import '../widgets/dialogue_bubble.dart';
|
||
import '../widgets/task_display_panel.dart';
|
||
import '../widgets/vocabulary_panel.dart';
|
||
import '../widgets/reply_assistance_panel.dart';
|
||
|
||
/// 情境對話主界面
|
||
///
|
||
/// 實現完整的AI對話功能,包括:
|
||
/// - 沉浸式場景背景
|
||
/// - 角色對話展示
|
||
/// - 語音和文字輸入
|
||
/// - 任務進度追蹤
|
||
/// - 指定詞彙提示
|
||
/// - 回覆輔助功能
|
||
/// - 限時挑戰模式
|
||
class DialogueMainScreen extends ConsumerStatefulWidget {
|
||
/// 場景ID
|
||
final String scenarioId;
|
||
|
||
/// 關卡ID
|
||
final String levelId;
|
||
|
||
/// 是否為限時挑戰模式
|
||
final bool isTimeChallenge;
|
||
|
||
const DialogueMainScreen({
|
||
super.key,
|
||
required this.scenarioId,
|
||
required this.levelId,
|
||
this.isTimeChallenge = false,
|
||
});
|
||
|
||
@override
|
||
ConsumerState<DialogueMainScreen> createState() => _DialogueMainScreenState();
|
||
}
|
||
|
||
class _DialogueMainScreenState extends ConsumerState<DialogueMainScreen>
|
||
with TickerProviderStateMixin {
|
||
final TextEditingController _textController = TextEditingController();
|
||
final FocusNode _textFocusNode = FocusNode();
|
||
late AnimationController _timerController;
|
||
late Animation<double> _timerAnimation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
// 限時挑戰計時器動畫
|
||
_timerController = AnimationController(
|
||
duration: const Duration(seconds: 300), // 300秒 = 5分鐘
|
||
vsync: this,
|
||
);
|
||
_timerAnimation = Tween<double>(
|
||
begin: 1.0,
|
||
end: 0.0,
|
||
).animate(CurvedAnimation(
|
||
parent: _timerController,
|
||
curve: Curves.linear,
|
||
));
|
||
|
||
// 如果是限時挑戰,開始計時
|
||
if (widget.isTimeChallenge) {
|
||
_timerController.forward();
|
||
_timerController.addStatusListener(_onTimerComplete);
|
||
}
|
||
|
||
// 初始化對話
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
ref.read(dialogueProvider.notifier).initializeDialogue(
|
||
scenarioId: widget.scenarioId,
|
||
levelId: widget.levelId,
|
||
isTimeChallenge: widget.isTimeChallenge,
|
||
);
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_textController.dispose();
|
||
_textFocusNode.dispose();
|
||
_timerController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 計時器完成處理
|
||
void _onTimerComplete(AnimationStatus status) {
|
||
if (status == AnimationStatus.completed) {
|
||
_showTimeUpDialog();
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dialogueState = ref.watch(dialogueProvider);
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: SafeArea(
|
||
child: Stack(
|
||
children: [
|
||
// 背景場景
|
||
DialogueBackground(
|
||
scenarioId: widget.scenarioId,
|
||
backgroundUrl: dialogueState.currentScene?.backgroundImageUrl,
|
||
),
|
||
|
||
// 主要內容
|
||
Column(
|
||
children: [
|
||
// 頂部工具列
|
||
_buildTopBar(dialogueState),
|
||
|
||
// 對話內容區域
|
||
Expanded(
|
||
child: _buildDialogueContent(dialogueState),
|
||
),
|
||
|
||
// 底部輸入區域
|
||
_buildInputArea(dialogueState),
|
||
],
|
||
),
|
||
|
||
// 任務顯示面板
|
||
if (dialogueState.currentTask != null)
|
||
Positioned(
|
||
top: 80.h,
|
||
right: 16.w,
|
||
child: TaskDisplayPanel(
|
||
task: dialogueState.currentTask!,
|
||
),
|
||
),
|
||
|
||
// 指定詞彙面板
|
||
if (dialogueState.requiredVocabulary.isNotEmpty)
|
||
Positioned(
|
||
top: 80.h,
|
||
left: 16.w,
|
||
child: VocabularyPanel(
|
||
vocabularies: dialogueState.requiredVocabulary,
|
||
usedVocabularies: dialogueState.usedVocabulary,
|
||
),
|
||
),
|
||
|
||
// 回覆輔助面板
|
||
if (dialogueState.showReplyAssistance)
|
||
Positioned.fill(
|
||
child: ReplyAssistancePanel(
|
||
suggestions: dialogueState.replySuggestions,
|
||
onSelectSuggestion: _onSelectSuggestion,
|
||
onClose: _closeReplyAssistance,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 頂部工具列
|
||
Widget _buildTopBar(DialogueState state) {
|
||
return Container(
|
||
height: 60.h,
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Colors.black.withOpacity(0.8),
|
||
Colors.transparent,
|
||
],
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 返回按鈕
|
||
IconButton(
|
||
onPressed: _showExitConfirmation,
|
||
icon: Icon(
|
||
Icons.arrow_back_ios,
|
||
color: Colors.white,
|
||
size: 20.sp,
|
||
),
|
||
),
|
||
|
||
Expanded(
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
// 限時挑戰計時器
|
||
if (widget.isTimeChallenge)
|
||
AnimatedBuilder(
|
||
animation: _timerAnimation,
|
||
builder: (context, child) {
|
||
final remainingSeconds = (_timerAnimation.value * 300).round();
|
||
final minutes = remainingSeconds ~/ 60;
|
||
final seconds = remainingSeconds % 60;
|
||
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 12.w,
|
||
vertical: 6.h,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: remainingSeconds < 60
|
||
? Colors.red.withOpacity(0.8)
|
||
: Colors.black.withOpacity(0.6),
|
||
borderRadius: BorderRadius.circular(16.r),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.timer,
|
||
color: Colors.white,
|
||
size: 16.sp,
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 資源顯示
|
||
Row(
|
||
children: [
|
||
// 命條
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.favorite,
|
||
color: Colors.red,
|
||
size: 16.sp,
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
'${state.lifePoints}',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
SizedBox(width: 16.w),
|
||
|
||
// 鑽石
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.diamond,
|
||
color: Colors.blue,
|
||
size: 16.sp,
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
'${state.diamonds}',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 對話內容區域
|
||
Widget _buildDialogueContent(DialogueState state) {
|
||
return Column(
|
||
children: [
|
||
// 角色頭像和名稱
|
||
if (state.currentCharacter != null)
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||
child: CharacterAvatar(
|
||
character: state.currentCharacter!,
|
||
showDetails: true,
|
||
),
|
||
),
|
||
|
||
// 對話氣泡
|
||
Expanded(
|
||
child: Container(
|
||
margin: EdgeInsets.symmetric(horizontal: 20.w),
|
||
child: state.currentDialogue != null
|
||
? DialogueBubble(
|
||
dialogue: state.currentDialogue!,
|
||
isUserReply: false,
|
||
)
|
||
: Center(
|
||
child: CircularProgressIndicator(
|
||
color: Theme.of(context).primaryColor,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 用戶回覆氣泡(如果有的話)
|
||
if (state.lastUserReply != null)
|
||
Container(
|
||
margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h),
|
||
child: DialogueBubble(
|
||
dialogue: state.lastUserReply!,
|
||
isUserReply: true,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 底部輸入區域
|
||
Widget _buildInputArea(DialogueState state) {
|
||
return Container(
|
||
padding: EdgeInsets.all(16.w),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.bottomCenter,
|
||
end: Alignment.topCenter,
|
||
colors: [
|
||
Colors.black.withOpacity(0.9),
|
||
Colors.transparent,
|
||
],
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// 功能按鈕行
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
// 角色詳情
|
||
_buildFunctionButton(
|
||
icon: Icons.person,
|
||
label: '角色',
|
||
onTap: _showCharacterDetails,
|
||
),
|
||
|
||
// 關鍵詞
|
||
_buildFunctionButton(
|
||
icon: Icons.key,
|
||
label: '關鍵詞',
|
||
onTap: _showKeywords,
|
||
),
|
||
|
||
// 任務提示
|
||
_buildFunctionButton(
|
||
icon: Icons.lightbulb,
|
||
label: '任務',
|
||
onTap: _showTaskHint,
|
||
disabled: state.currentTask?.isCompleted ?? true,
|
||
),
|
||
|
||
// 中翻英
|
||
_buildFunctionButton(
|
||
icon: Icons.translate,
|
||
label: '翻譯',
|
||
onTap: _showTranslation,
|
||
),
|
||
|
||
// 回覆輔助
|
||
_buildFunctionButton(
|
||
icon: Icons.help,
|
||
label: '輔助',
|
||
onTap: _showReplyAssistance,
|
||
cost: 30,
|
||
disabled: state.diamonds < 30,
|
||
),
|
||
],
|
||
),
|
||
|
||
SizedBox(height: 16.h),
|
||
|
||
// 輸入區域
|
||
Row(
|
||
children: [
|
||
// 文字輸入框
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _textController,
|
||
focusNode: _textFocusNode,
|
||
maxLines: 3,
|
||
minLines: 1,
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16.sp,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: '請輸入你的回覆...',
|
||
hintStyle: TextStyle(
|
||
color: Colors.grey,
|
||
fontSize: 16.sp,
|
||
),
|
||
filled: true,
|
||
fillColor: Colors.black.withOpacity(0.6),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(24.r),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
contentPadding: EdgeInsets.symmetric(
|
||
horizontal: 20.w,
|
||
vertical: 12.h,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
SizedBox(width: 8.w),
|
||
|
||
// 語音輸入按鈕
|
||
VoiceInputButton(
|
||
size: 48,
|
||
languageId: state.currentLanguage,
|
||
onResult: _onVoiceResult,
|
||
onError: _onVoiceError,
|
||
),
|
||
|
||
SizedBox(width: 8.w),
|
||
|
||
// 發送按鈕
|
||
Container(
|
||
width: 48.w,
|
||
height: 48.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: _textController.text.trim().isNotEmpty
|
||
? Theme.of(context).primaryColor
|
||
: Colors.grey,
|
||
),
|
||
child: IconButton(
|
||
onPressed: _textController.text.trim().isNotEmpty
|
||
? _sendReply
|
||
: null,
|
||
icon: Icon(
|
||
Icons.send,
|
||
color: Colors.white,
|
||
size: 20.sp,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 功能按鈕
|
||
Widget _buildFunctionButton({
|
||
required IconData icon,
|
||
required String label,
|
||
required VoidCallback onTap,
|
||
int? cost,
|
||
bool disabled = false,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: disabled ? null : onTap,
|
||
child: Container(
|
||
width: 60.w,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 40.w,
|
||
height: 40.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: disabled
|
||
? Colors.grey.withOpacity(0.3)
|
||
: Colors.white.withOpacity(0.2),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Center(
|
||
child: Icon(
|
||
icon,
|
||
color: disabled ? Colors.grey : Colors.white,
|
||
size: 20.sp,
|
||
),
|
||
),
|
||
if (cost != null)
|
||
Positioned(
|
||
top: -2.h,
|
||
right: -2.w,
|
||
child: Container(
|
||
padding: EdgeInsets.all(2.w),
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.blue,
|
||
),
|
||
child: Icon(
|
||
Icons.diamond,
|
||
color: Colors.white,
|
||
size: 8.sp,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(height: 4.h),
|
||
Text(
|
||
cost != null ? '$label($cost)' : label,
|
||
style: TextStyle(
|
||
color: disabled ? Colors.grey : Colors.white,
|
||
fontSize: 12.sp,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 語音識別結果處理
|
||
void _onVoiceResult(String text) {
|
||
setState(() {
|
||
_textController.text = text;
|
||
});
|
||
}
|
||
|
||
/// 語音識別錯誤處理
|
||
void _onVoiceError(String error) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('語音識別失敗:$error'),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 發送回覆
|
||
void _sendReply() {
|
||
final text = _textController.text.trim();
|
||
if (text.isEmpty) return;
|
||
|
||
ref.read(dialogueProvider.notifier).sendReply(text);
|
||
_textController.clear();
|
||
}
|
||
|
||
/// 選擇建議回覆
|
||
void _onSelectSuggestion(String suggestion) {
|
||
setState(() {
|
||
_textController.text = suggestion;
|
||
});
|
||
_closeReplyAssistance();
|
||
}
|
||
|
||
/// 顯示角色詳情
|
||
void _showCharacterDetails() {
|
||
// TODO: 導航到角色詳情頁面
|
||
}
|
||
|
||
/// 顯示關鍵詞
|
||
void _showKeywords() {
|
||
// TODO: 導航到關鍵詞頁面
|
||
}
|
||
|
||
/// 顯示任務提示
|
||
void _showTaskHint() {
|
||
// TODO: 顯示任務提示對話框
|
||
}
|
||
|
||
/// 顯示翻譯
|
||
void _showTranslation() {
|
||
// TODO: 顯示翻譯對話框
|
||
}
|
||
|
||
/// 顯示回覆輔助
|
||
void _showReplyAssistance() {
|
||
ref.read(dialogueProvider.notifier).showReplyAssistance();
|
||
}
|
||
|
||
/// 關閉回覆輔助
|
||
void _closeReplyAssistance() {
|
||
ref.read(dialogueProvider.notifier).hideReplyAssistance();
|
||
}
|
||
|
||
/// 顯示退出確認
|
||
void _showExitConfirmation() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('確認離開'),
|
||
content: Text(
|
||
widget.isTimeChallenge
|
||
? '離開限時挑戰將無法繼續,確定要離開嗎?'
|
||
: '確定要離開對話嗎?當前進度將會保存。',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: Text('取消'),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: Text('確定'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 顯示時間結束對話框
|
||
void _showTimeUpDialog() {
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('時間到!'),
|
||
content: Text('限時挑戰時間已結束,正在計算成績...'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
// TODO: 跳轉到結果頁面
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: Text('查看結果'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} |