Files
ShowenV2/clients/flutter/lib/screens/chat_screen.dart
XiuchengWu 6f7e24db63 feat: M2.1 客户端骨架、BLE 修复、仓库整理与现状板
- BLE: 修复 BlueZ LocalName 与 Includes 冲突,串行注册与退避
- HTTP: 语音对话补 Live2D talking 标志与 session;Web 补 X-Session-Id
- Flutter: 角色/对话/模型页 + API,Gradle/依赖升级,麦克风权限
- 文档: 新增 docs/STATUS.md,校准 CLAUDE/PROGRESS/README
- 清理: 移除根目录截图与临时模型,运维脚本迁入 scripts/device
2026-07-09 12:41:34 +08:00

233 lines
8.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/chat_models.dart';
import '../providers/character_provider.dart';
import '../providers/chat_provider.dart';
import '../theme/app_colors.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<CharacterProvider>().refresh();
});
}
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
_scrollController.position.maxScrollExtent + 80,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
});
}
Future<void> _sendText() async {
final text = _controller.text;
_controller.clear();
await context.read<ChatProvider>().sendText(text);
_scrollToEnd();
}
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
final character = context.watch<CharacterProvider>();
final activeName = character.activePack?.name ?? '当前角色';
return Scaffold(
appBar: AppBar(
title: Text('对话 · $activeName'),
actions: [
IconButton(
tooltip: '清空会话',
onPressed: chat.isBusy ? null : chat.clearHistory,
icon: const Icon(Icons.delete_outline_rounded),
),
],
),
body: Column(
children: [
if (chat.status != null)
Container(
width: double.infinity,
color: AppColors.card,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: 8,
),
child: Text(
chat.status!,
style: TextStyle(
color: chat.error != null
? AppColors.error
: AppColors.textSecondary,
),
),
),
Expanded(
child: chat.messages.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Text(
'文字发送,或按住下方麦克风说话。\n回复会在手机播放(设备本地不出声)。',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: chat.messages.length,
itemBuilder: (context, index) {
final msg = chat.messages[index];
final isUser = msg.role == ChatBubbleRole.user;
return Align(
alignment: isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82,
),
decoration: BoxDecoration(
color: isUser
? AppColors.primary.withValues(alpha: 0.25)
: AppColors.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
msg.text,
style: Theme.of(context).textTheme.bodyMedium,
),
if (msg.audioPath != null &&
msg.audioPath!.isNotEmpty &&
!msg.isPending) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () =>
chat.playReply(msg.audioPath!),
icon: const Icon(Icons.volume_up_rounded),
label: const Text('重播语音'),
),
],
if (msg.isPending)
const Padding(
padding: EdgeInsets.only(top: 6),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
],
),
),
);
},
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
enabled: !chat.isBusy && !chat.isRecording,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(
hintText: '输入文字和角色对话...',
),
onSubmitted: (_) => _sendText(),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: chat.isBusy || chat.isRecording ? null : _sendText,
icon: const Icon(Icons.send_rounded),
),
const SizedBox(width: 4),
GestureDetector(
onLongPressStart: (_) async {
final ok =
await context.read<ChatProvider>().startRecording();
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.read<ChatProvider>().error ?? '无法录音',
),
),
);
}
},
onLongPressEnd: (_) async {
await context.read<ChatProvider>().stopRecordingAndSend();
_scrollToEnd();
},
onLongPressCancel: () {
context.read<ChatProvider>().cancelRecording();
},
child: CircleAvatar(
radius: 26,
backgroundColor: chat.isRecording
? AppColors.error
: AppColors.primary.withValues(alpha: 0.2),
child: Icon(
chat.isRecording ? Icons.mic : Icons.mic_none_rounded,
color: chat.isRecording
? Colors.white
: AppColors.primary,
),
),
),
],
),
),
),
],
),
);
}
}