475 lines
18 KiB
Dart
475 lines
18 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||
import 'package:flutter_svg/flutter_svg.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||
|
||
import '../bloc/ai_cubit.dart';
|
||
import '../bloc/ai_state.dart';
|
||
import '../../data/models/chat_message.dart';
|
||
import '../../data/models/session.dart';
|
||
|
||
// --- MAIN WIDGET ---
|
||
|
||
class AiPage extends StatelessWidget {
|
||
const AiPage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocProvider(
|
||
create: (context) => AiCubit(
|
||
appUserCubit: context.read<AppUserCubit>(), // ✅ 这里传
|
||
),
|
||
|
||
child: const AiView(),
|
||
);
|
||
}
|
||
}
|
||
|
||
class AiView extends StatefulWidget {
|
||
const AiView({super.key});
|
||
|
||
@override
|
||
State<AiView> createState() => _AiViewState();
|
||
}
|
||
|
||
class _AiViewState extends State<AiView> {
|
||
final TextEditingController _textController = TextEditingController();
|
||
final ScrollController _scrollController = ScrollController();
|
||
|
||
@override
|
||
void dispose() {
|
||
_textController.dispose();
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(AppLocalizations.of(context).translate('ai.title')),
|
||
actions: [
|
||
PopupMenuButton<String>(
|
||
onSelected: (value) {
|
||
if (value == 'new_session') {
|
||
context.read<AiCubit>().createNewSession();
|
||
} else if (value == 'history') {
|
||
_showHistoryDialog(context);
|
||
}
|
||
},
|
||
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
|
||
PopupMenuItem<String>(value: 'new_session', child: Text(AppLocalizations.of(context).translate('ai.new_session'))),
|
||
PopupMenuItem<String>(value: 'history', child: Text(AppLocalizations.of(context).translate('ai.history'))),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
body: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: BlocBuilder<AiCubit, AiState>(
|
||
builder: (context, state) {
|
||
// Scroll to bottom when new messages are added
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||
}
|
||
});
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||
controller: _scrollController,
|
||
reverse: true,
|
||
itemCount: state.messages.length,
|
||
itemBuilder: (context, index) => _buildMessageBubble(context, state.messages[index]),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
_buildInputArea(context),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMessageBubble(BuildContext context, ChatMessage message) {
|
||
final isUserMessage = message.isUserMessage;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 12.0),
|
||
child: Row(
|
||
mainAxisAlignment: isUserMessage ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
if (!isUserMessage) const CircleAvatar(backgroundImage: AssetImage('assets/images/app_logo_black.png'), radius: 16),
|
||
const SizedBox(width: 10),
|
||
Flexible(child: isUserMessage ? _buildUserMessageContent(context, message) : _buildAiMessageContent(context, message)),
|
||
if (isUserMessage) const SizedBox(width: 10), // <-- This was the missing part
|
||
if (isUserMessage) const CircleAvatar(child: Icon(Icons.person, size: 20), radius: 16),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildUserMessageContent(BuildContext context, ChatMessage message) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 14.0),
|
||
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(18)),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (message.image != null)
|
||
GestureDetector(
|
||
onTap: () => _showImagePreview(context, imageFile: message.image!),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12.0),
|
||
child: Image.file(message.image!, width: 200, height: 200, fit: BoxFit.cover),
|
||
),
|
||
)
|
||
else if (message.imageBase64 != null)
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(12.0),
|
||
child: Image.memory(base64Decode(message.imageBase64!.split(',').last), width: 200, height: 200, fit: BoxFit.cover),
|
||
),
|
||
if (message.text != null && message.text!.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4.0),
|
||
child: Text(message.text!, style: const TextStyle(color: Colors.white, fontSize: 16)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAiMessageContent(BuildContext context, ChatMessage message) {
|
||
final state = context.watch<AiCubit>().state;
|
||
final hasThinking = message.thinkingText != null && message.thinkingText!.isNotEmpty;
|
||
final hasAnswer = message.text != null && message.text!.isNotEmpty;
|
||
final showThinkingBlock = state.status == AiStatus.loading || !hasAnswer;
|
||
|
||
if (!showThinkingBlock && !hasAnswer) return const SizedBox.shrink();
|
||
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF5F7F9), // 更柔和的背景
|
||
borderRadius: BorderRadius.circular(18),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (showThinkingBlock)
|
||
_buildSection(
|
||
context,
|
||
icon: Icons.psychology_outlined,
|
||
title: "深度思考",
|
||
content: message.thinkingText,
|
||
iconColor: Colors.blue.shade700,
|
||
showSpinner: state.status == AiStatus.loading && !hasThinking,
|
||
),
|
||
|
||
if (showThinkingBlock && hasAnswer)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||
child: Divider(height: 1, color: Colors.grey.shade300),
|
||
),
|
||
|
||
// ========== 最终结论(带美化 Markdown)==========
|
||
if (hasAnswer)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.lightbulb_outline, size: 20, color: Colors.green.shade700),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
AppLocalizations.of(context).translate('ai.final_conclusion'),
|
||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF2D7D46)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
|
||
// ========== Markdown 渲染(超好看样式)==========
|
||
Markdown(
|
||
data: message.text!,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
styleSheet: MarkdownStyleSheet(
|
||
// 正文
|
||
p: const TextStyle(fontSize: 15, height: 1.6, color: Color(0xFF262626)),
|
||
|
||
// 标题
|
||
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF111111)),
|
||
h2: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF111111)),
|
||
h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF111111)),
|
||
|
||
// 加粗
|
||
strong: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF000000)),
|
||
|
||
// 斜体
|
||
em: const TextStyle(fontStyle: FontStyle.italic, color: Color(0xFF444444)),
|
||
|
||
// 列表
|
||
listBullet: const TextStyle(fontSize: 15, height: 1.6, color: Color(0xFF262626)),
|
||
|
||
// 引用
|
||
blockquote: const TextStyle(fontSize: 15, color: Color(0xFF555555), height: 1.6),
|
||
blockquoteDecoration: BoxDecoration(
|
||
border: Border(left: BorderSide(color: Colors.green.shade300, width: 4)),
|
||
),
|
||
|
||
// 代码行
|
||
code: TextStyle(fontSize: 14, color: const Color(0xFFD32F2F), backgroundColor: Colors.grey.shade200, height: 1.6),
|
||
|
||
// 代码块
|
||
codeblockDecoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
|
||
codeblockPadding: const EdgeInsets.all(10),
|
||
|
||
// 表格
|
||
tableBorder: TableBorder.all(color: Colors.grey.shade300, width: 1),
|
||
tableHead: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||
tableBody: const TextStyle(fontSize: 14, height: 1.5),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSection(BuildContext context, {required IconData icon, required String title, String? content, Color? iconColor, bool showSpinner = false}) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(icon, size: 20, color: iconColor),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
title == "深度思考" ? AppLocalizations.of(context).translate('ai.deep_thinking') : title,
|
||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: iconColor),
|
||
),
|
||
if (showSpinner) ...[
|
||
const SizedBox(width: 8),
|
||
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2.0, color: iconColor)),
|
||
],
|
||
],
|
||
),
|
||
if (content != null && content.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
Text(content, style: const TextStyle(fontSize: 16, color: Colors.black87, height: 1.4)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInputArea(BuildContext context) {
|
||
final state = context.watch<AiCubit>().state;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (state.selectedImageFile != null) _buildImagePreviewThumbnail(context),
|
||
Container(
|
||
padding: const EdgeInsets.all(8.0),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
IconButton(
|
||
icon: SvgPicture.asset('assets/svgs/image_icon.svg', width: 24, height: 24),
|
||
onPressed: state.status == AiStatus.loading ? null : () => _handleImageSelection(context),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _textController,
|
||
enabled: state.status != AiStatus.loading,
|
||
decoration: InputDecoration(
|
||
hintText: state.status == AiStatus.loading
|
||
? AppLocalizations.of(context).translate('ai.thinking_hint')
|
||
: AppLocalizations.of(context).translate('ai.input_hint'),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(30.0),
|
||
borderSide: const BorderSide(width: 0, style: BorderStyle.none),
|
||
),
|
||
filled: true,
|
||
fillColor: Theme.of(context).colorScheme.surfaceVariant,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
),
|
||
onSubmitted: state.status == AiStatus.loading ? null : (text) => _handleSendPressed(context),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton(
|
||
icon: SvgPicture.asset('assets/svgs/send_icon.svg', width: 24, height: 24),
|
||
onPressed: state.status == AiStatus.loading ? null : () => _handleSendPressed(context),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildImagePreviewThumbnail(BuildContext context) {
|
||
final state = context.read<AiCubit>().state;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Stack(
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(8.0),
|
||
child: Image.file(state.selectedImageFile!, width: 72, height: 72, fit: BoxFit.cover),
|
||
),
|
||
Positioned(
|
||
top: -12,
|
||
right: -12,
|
||
child: IconButton(
|
||
icon: const CircleAvatar(
|
||
backgroundColor: Colors.black54,
|
||
radius: 12,
|
||
child: Icon(Icons.close, color: Colors.white, size: 16),
|
||
),
|
||
onPressed: () => context.read<AiCubit>().deselectImage(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _handleSendPressed(BuildContext context) {
|
||
final text = _textController.text;
|
||
final selectedImageFile = context.read<AiCubit>().state.selectedImageFile;
|
||
if (text.isEmpty && selectedImageFile == null) return;
|
||
|
||
context.read<AiCubit>().analyze(prompt: text, image: selectedImageFile);
|
||
|
||
_textController.clear();
|
||
}
|
||
|
||
void _handleImageSelection(BuildContext context) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (BuildContext _) {
|
||
return SafeArea(
|
||
child: Wrap(
|
||
children: <Widget>[
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library),
|
||
title: Text(AppLocalizations.of(context).translate('ai.album')),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
context.read<AiCubit>().selectImage(ImageSource.gallery);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_camera),
|
||
title: Text(AppLocalizations.of(context).translate('ai.camera')),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
context.read<AiCubit>().selectImage(ImageSource.camera);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _showImagePreview(BuildContext context, {File? imageFile, String? imageBase64}) {
|
||
if (imageFile == null && imageBase64 == null) return;
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => ImagePreviewPage(imageFile: imageFile, imageBase64: imageBase64),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showHistoryDialog(BuildContext context) {
|
||
final cubit = context.read<AiCubit>();
|
||
showDialog(
|
||
context: context,
|
||
builder: (BuildContext dialogContext) {
|
||
return AlertDialog(
|
||
title: Text(AppLocalizations.of(context).translate('ai.history')),
|
||
content: SizedBox(
|
||
width: double.maxFinite,
|
||
child: BlocBuilder<AiCubit, AiState>(
|
||
bloc: cubit,
|
||
builder: (context, state) {
|
||
return ListView.builder(
|
||
shrinkWrap: true,
|
||
itemCount: state.sessions.length,
|
||
itemBuilder: (context, index) {
|
||
return ListTile(
|
||
title: Text(state.sessions[index].title),
|
||
onTap: () {
|
||
Navigator.of(dialogContext).pop();
|
||
cubit.loadSessionHistory(state.sessions[index].sessId);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
actions: [TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: Text(AppLocalizations.of(context).translate('common.close')))],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- IMAGE PREVIEW PAGE ---
|
||
|
||
class ImagePreviewPage extends StatelessWidget {
|
||
final File? imageFile;
|
||
final String? imageBase64;
|
||
|
||
const ImagePreviewPage({super.key, this.imageFile, this.imageBase64});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
ImageProvider? imageProvider;
|
||
if (imageFile != null) {
|
||
imageProvider = FileImage(imageFile!);
|
||
} else if (imageBase64 != null) {
|
||
imageProvider = MemoryImage(base64Decode(imageBase64!.split(',').last));
|
||
}
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.black,
|
||
elevation: 0,
|
||
iconTheme: const IconThemeData(color: Colors.white),
|
||
),
|
||
body: Center(
|
||
child: InteractiveViewer(
|
||
child: imageProvider != null ? Image(image: imageProvider) : const Text('No Image', style: TextStyle(color: Colors.white)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|