didvan-app/lib/views/ai/ai_chat_page.dart

1514 lines
76 KiB
Dart
Raw 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.

// ignore_for_file: library_private_types_in_public_api, deprecated_member_use, depend_on_referenced_packages, unnecessary_import
import 'package:didvan/views/ai/ai_state.dart';
import 'package:didvan/views/widgets/hoshan_app_bar.dart';
import 'package:didvan/views/widgets/state_handlers/state_handler.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:didvan/config/design_config.dart';
import 'package:didvan/config/theme_data.dart';
import 'package:didvan/constants/app_icons.dart';
import 'package:didvan/main.dart';
import 'package:didvan/models/ai/ai_chat_args.dart';
import 'package:didvan/models/ai/bots_model.dart';
import 'package:didvan/models/ai/chats_model.dart';
import 'package:didvan/models/ai/files_model.dart';
import 'package:didvan/models/ai/messages_model.dart';
import 'package:didvan/models/ai/ai_model_enum.dart';
import 'package:didvan/models/enums.dart';
import 'package:didvan/models/view/alert_data.dart';
import 'package:didvan/providers/user.dart';
import 'package:didvan/routes/routes.dart';
import 'package:didvan/services/media/media.dart';
import 'package:didvan/services/network/request.dart';
import 'package:didvan/services/network/request_helper.dart';
import 'package:didvan/utils/action_sheet.dart';
import 'package:didvan/utils/date_time.dart';
import 'package:didvan/utils/extension.dart';
import 'package:didvan/views/ai/ai_chat_state.dart';
import 'package:didvan/views/ai/history_ai_chat_state.dart';
import 'package:didvan/views/ai/widgets/ai_message_bar.dart';
import 'package:didvan/views/ai/widgets/audio_wave.dart';
import 'package:didvan/views/ai/widgets/hoshan_drawer.dart';
import 'package:didvan/views/widgets/didvan/didvan_markdown.dart';
import 'package:didvan/views/widgets/didvan/text.dart';
import 'package:didvan/views/widgets/marquee_text.dart';
import 'package:didvan/views/widgets/skeleton_image.dart';
import 'package:didvan/views/widgets/video/chat_video_player.dart';
import 'package:didvan/views/widgets/video/custome_controls.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:persian_number_utility/persian_number_utility.dart';
import 'package:provider/provider.dart';
class AiChatPage extends StatefulWidget {
final AiChatArgs args;
/// اگر این widget به‌عنوان child داخل AiSectionPage (تب هوشان) نمایش
/// داده شود، `inline=true` می‌دهیم — در این حالت WillPopScope فقط
/// `endChat()` می‌کند و `return false` می‌دهد تا Navigator صفحهٔ
/// والد را pop نکند. در حالت route مستقل (push از hoshan_drawer،
/// history، یا app bar) باید Navigator.pop شود تا کاربر برگردد.
final bool inline;
const AiChatPage({Key? key, required this.args, this.inline = false})
: super(key: key);
@override
_AiChatPageState createState() => _AiChatPageState();
}
class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> scaffKey = GlobalKey<ScaffoldState>();
FocusNode focusNode = FocusNode();
BotsModel? _searchBot;
BotsModel? _geminiBot;
BotsModel? _claudeBot;
BotsModel? _grokBot;
BotsModel? _qwenBot;
bool _isSearchMode = false;
AiModel _selectedModel = AiModel.chatGPT;
late BotsModel _currentBot;
late AnimationController _fadeController;
late AnimationController _messageController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void didUpdateWidget(covariant AiChatPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.args.bot.id != oldWidget.args.bot.id) {
debugPrint("--- AI CHAT PAGE: Bot Updated ---");
debugPrint(
"Bot ID: ${widget.args.bot.id}, Bot Name: ${widget.args.bot.name}");
debugPrint("---------------------------------");
setState(() {
_currentBot = widget.args.bot;
_isSearchMode = _currentBot.id == 36;
});
final state = context.read<AiChatState>();
state.clearChat();
if (widget.args.bot.id == 35) {
state.fetchSuggestedQuestions();
}
if (widget.args.chat != null) {
state.chatId = widget.args.chat!.id!;
state.chat = widget.args.chat;
state.getAllMessages(state.chatId!);
}
if (widget.args.prompts != null) {
state.messages.add(MessageModel(
dateTime: DateTime.now()
.subtract(const Duration(minutes: 210))
.toIso8601String(),
prompts: [widget.args.prompts!]));
state.message.clear();
state.update();
state.postMessage(widget.args.bot, widget.args.assistantsName != null,
webSearch: _isSearchMode);
}
}
}
@override
void initState() {
super.initState();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_messageController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _fadeController,
curve: Curves.easeIn,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _messageController,
curve: Curves.easeOutCubic,
));
_fadeController.forward();
_messageController.forward();
debugPrint("--- AI CHAT PAGE: Init ---");
debugPrint(
"Bot ID: ${widget.args.bot.id}, Bot Name: ${widget.args.bot.name}");
debugPrint("----------------------------");
_currentBot = widget.args.bot;
_isSearchMode = _currentBot.id == 36;
final state = context.read<AiChatState>();
if (widget.args.chat != null) {
state.chatId = widget.args.chat!.id!;
state.chat = widget.args.chat;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final bots = context.read<HistoryAiChatState>().bots;
if (mounted) {
setState(() {
// قبلاً _geminiBot از bot id=35 با name=gemini دنبال می‌شد
// (الگوی legacy multi-model). در ساختار جدید Gemini یک bot
// مستقل با id=36 است.
try {
_searchBot = bots.firstWhere((b) => b.id == 36);
} catch (_) {
_searchBot = null;
}
try {
_geminiBot = bots.firstWhere(
(b) => (b.name?.toLowerCase().contains('gemini') ?? false));
} catch (e) {
_geminiBot = BotsModel(
id: 36,
name: 'gemini',
responseType: 'text',
);
}
try {
_claudeBot = bots.firstWhere(
(b) => (b.name?.toLowerCase().contains('claude') ?? false));
} catch (e) {
_claudeBot = BotsModel(
id: 37,
name: 'claude',
responseType: 'text',
);
}
try {
_qwenBot = bots.firstWhere((b) =>
b.id == 35 &&
(b.name?.toLowerCase().contains('qwen') ?? false));
} catch (e) {
_qwenBot = BotsModel(
id: 35,
name: 'Qwen',
responseType: 'text',
attachmentType: [
'audio',
'image',
'pdf',
'csv',
'doc',
'docx',
'xls',
'xlsx'
],
attachment: 1,
);
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_searchBot = BotsModel(
id: 36,
name: 'GPT-4o-mini-search',
responseType: 'text',
attachmentType: [
'audio',
'image',
'pdf',
'csv',
'doc',
'docx',
'xls',
'xlsx'
],
attachment: 1,
);
_geminiBot = BotsModel(
id: 35,
name: 'Gemini',
responseType: 'text',
attachmentType: [
'audio',
'image',
'pdf',
'csv',
'doc',
'docx',
'xls',
'xlsx'
],
attachment: 1,
);
_grokBot = BotsModel(
id: 35,
name: 'Grok',
responseType: 'text',
attachmentType: [
'audio',
'image',
'pdf',
'csv',
'doc',
'docx',
'xls',
'xlsx'
],
attachment: 1,
);
_qwenBot = BotsModel(
id: 35,
name: 'Qwen',
responseType: 'text',
attachmentType: [
'audio',
'image',
'pdf',
'csv',
'doc',
'docx',
'xls',
'xlsx'
],
attachment: 1,
);
});
debugPrint(
"Search bot (ID 36) not found in History state: $e. Using fallback with audio support.");
}
}
if (widget.args.bot.id == 35) {
state.fetchSuggestedQuestions();
}
if (state.chatId != null) {
state.getAllMessages(state.chatId!).then((value) => Future.delayed(
const Duration(milliseconds: 100),
() {
focusNode.requestFocus();
if (state.messages.isNotEmpty) {
state.scrollController.animateTo(
state.scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
},
));
} else {
state.appState = AppState.idle;
Future.delayed(
const Duration(milliseconds: 100),
() => focusNode.requestFocus(),
);
}
if (widget.args.prompts != null) {
state.messages.add(MessageModel(
dateTime: DateTime.now()
.subtract(const Duration(minutes: 210))
.toIso8601String(),
prompts: [widget.args.prompts!]));
state.message.clear();
state.update();
await state.postMessage(
widget.args.bot, widget.args.assistantsName != null,
webSearch: _isSearchMode);
Future.delayed(const Duration(milliseconds: 100), () {
state.scrollController.animateTo(
state.scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
});
}
@override
void dispose() {
_fadeController.dispose();
_messageController.dispose();
super.dispose();
}
BotsModel _getSelectedBot() {
switch (_selectedModel) {
case AiModel.gemini:
return _geminiBot ?? _currentBot;
case AiModel.claude:
return _claudeBot ?? _currentBot;
case AiModel.grok:
return _grokBot ?? _currentBot;
case AiModel.qwen:
return _qwenBot ?? _currentBot;
case AiModel.chatGPT:
// ignore: unreachable_switch_default
default:
return _currentBot;
}
}
String _getBotTitle() {
final name = _currentBot.name?.toLowerCase() ?? '';
// bot 103/infoai حالا Nano Banana 2 است (نه اینفوگرافی). title و
// subtitle و icon آن هم باید "ساخت عکس" باشد، نه "اینفوگرافی". پس
// این bot مثل بقیه image-gen ها هندل می‌شود.
if (_currentBot.id == 103 ||
name.contains('dall-e') ||
name.contains('dalle') ||
name.contains('image') ||
name.contains('infoai') ||
name.contains('nano-banana') ||
name.contains('nanobanana')) {
return 'ساخت عکس';
} else if (name.contains('veo') || name.contains('video')) {
return 'ساخت ویدیو';
} else if (_currentBot.id == 104 ||
name.contains('translate') ||
name.contains('translator') ||
name.contains('ترجمه')) {
return 'ترجمه';
} else if (name.contains('summarize') ||
name.contains('aisummery') ||
name.contains('خلاصه')) {
return 'خلاصه‌ساز';
} else if (name.contains('speech') ||
name.contains('text-to-speech') ||
name.contains('aiaudio') ||
name.contains('متن به صوت')) {
return 'تبدیل متن به صوت';
} else if (name.contains('chart') ||
name.contains('graph') ||
name.contains('نمودار') ||
name.contains('تحلیل')) {
return 'تحلیل نمودار';
}
return _currentBot.short ?? _currentBot.name ?? 'هوش مصنوعی';
}
String _getBotSubtitle() {
final name = _currentBot.name?.toLowerCase() ?? '';
if (_currentBot.id == 103 ||
name.contains('dall-e') ||
name.contains('dalle') ||
name.contains('image') ||
name.contains('infoai') ||
name.contains('nano-banana') ||
name.contains('nanobanana')) {
return 'با استفاده از این بات، می‌توانید تصاویر با کیفیت ایجاد کنید. پس از توصیف ایده‌ خود در قالب چند عبارت، بات، تصویری نزدیک به درخواست شما تولید می‌کند. در صورت نیاز، می‌توانید توضیح خود را اصلاح کرده و دوباره امتحان کنید تا به نتیجه دلخواه برسید.';
} else if (name.contains('veo') || name.contains('video')) {
return 'با استفاده از این بات، می‌توانید ویدیوهای کوتاه و ساده را از ایده‌ها، متن‌ها یا سناریوی موردنظر خود بسازید. کافی است توضیح دهید چه می‌خواهید، تا بات نسخه اولیه ویدیو را تولید کند. ';
} else if (_currentBot.id == 104 ||
name.contains('translate') ||
name.contains('translator') ||
name.contains('ترجمه')) {
return 'این بات متن شما را به‌صورت خودکار بین فارسی و انگلیسی ترجمه می‌کند. هر متنی بفرستید، فقط خود ترجمه برمی‌گردد بدون توضیح اضافه.';
} else if (name.contains('summarize') ||
name.contains('aisummery') ||
name.contains('خلاصه')) {
return 'با استفاده از این بات، می‌توانید هر متن طولانی را به نسخه‌ای کوتاه، روان و دقیق تبدیل کنید. کافی است متن خود را ارسال کنید تا بات، مهم‌ترین نکات آن را استخراج و در قالب خلاصه‌ای قابل‌فهم ارائه کند. ';
} else if (name.contains('speech') ||
name.contains('text-to-speech') ||
name.contains('aiaudio') ||
name.contains('متن به صوت')) {
return 'این بات متن شما را با صدایی طبیعی و واضح به فایل صوتی تبدیل می‌کند. فقط کافی است متن موردنظر را وارد کنید تا نسخه صوتی آن را دریافت کنید. ';
} else if (name.contains('chart') ||
name.contains('graph') ||
name.contains('نمودار') ||
name.contains('تحلیل')) {
return 'این بات امکان تولید انواع نمودارها و ترسیم‌های تحلیلی را برای شما فراهم می‌کند. تنها کافی است داده‌ها و نوع نمودار موردنظر را توضیح دهید تا خروجی تمیز و قابل استفاده دریافت کنید. اگر نتیجه مطابق نیازتان نبود، می‌توانید داده‌ها یا سبک نمودار را اصلاح و دوباره تولید کنید.';
}
return _currentBot.description ?? '';
}
String? _getBotIcon() {
final name = _currentBot.name?.toLowerCase() ?? '';
if (_currentBot.id == 103 ||
name.contains('dall-e') ||
name.contains('dalle') ||
name.contains('image') ||
name.contains('infoai') ||
name.contains('nano-banana') ||
name.contains('nanobanana')) {
return 'lib/assets/icons/CrateImage.svg';
} else if (name.contains('veo') || name.contains('video')) {
return 'lib/assets/icons/VideoBot.svg';
} else if (_currentBot.id == 104 ||
name.contains('translate') ||
name.contains('translator') ||
name.contains('ترجمه')) {
return 'lib/assets/icons/TranslateBot.svg';
} else if (name.contains('summarize') ||
name.contains('aisummery') ||
name.contains('خلاصه')) {
return 'lib/assets/icons/SummeryBot.svg';
} else if (name.contains('speech') ||
name.contains('text-to-speech') ||
name.contains('aiaudio') ||
name.contains('متن به صوت')) {
return 'lib/assets/icons/TTV_Bot.svg';
} else if (name.contains('chart') ||
name.contains('graph') ||
name.contains('نمودار') ||
name.contains('تحلیل')) {
return 'lib/assets/icons/ChartBot.svg';
}
return null;
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
try {
if (context.read<HistoryAiChatState>().refresh) {
context.read<HistoryAiChatState>().getChats();
context.read<HistoryAiChatState>().refresh = false;
}
} catch (_) {/* ignore */}
try {
context.read<AiState>().endChat();
} catch (e) {
debugPrint('endChat error: $e');
}
// inline → return false: Navigator صفحه را pop نمی‌کند و
// AiSectionPage بعد از notifyListeners به grid برمی‌گردد.
// standalone → return true: Navigator صفحه را pop می‌کند تا
// کاربر به صفحهٔ قبلی برود.
return !widget.inline;
},
child: Consumer<AiChatState>(
builder: (context, state, child) => Scaffold(
appBar: HoshanAppBar(
onBack: () async {
try {
if (context.read<HistoryAiChatState>().refresh) {
context.read<HistoryAiChatState>().getChats();
context.read<HistoryAiChatState>().refresh = false;
}
} catch (_) {/* ignore */}
try {
context.read<AiState>().endChat();
} catch (e) {
debugPrint('endChat error: $e');
}
if (!widget.inline && context.mounted) {
Navigator.pop(context);
}
},
withActions: false,
),
key: scaffKey,
resizeToAvoidBottomInset: true,
drawer: HoshanDrawer(
scaffKey: scaffKey,
),
body: StateHandler<AiChatState>(
state: state,
onRetry: () {
if (state.chatId != null) {
state.getAllMessages(state.chatId!);
}
if (widget.args.bot.id == 35) {
state.fetchSuggestedQuestions();
}
},
builder: (context, state) {
final userProvider = context.watch<UserProvider>();
return SingleChildScrollView(
reverse: true,
controller: state.scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (state.messages.isEmpty)
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 90),
userProvider.isLoadingWelcome
? const SizedBox(height: 20)
: userProvider.welcomeMessage != null
? TweenAnimationBuilder<double>(
duration:
const Duration(milliseconds: 800),
tween: Tween(begin: 0.0, end: 1.0),
curve: Curves.easeOut,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.scale(
scale: 0.8 + (0.2 * value),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.only(
bottom: 12.0,
left: 24,
right: 24),
child: Center(
child: DidvanText(
userProvider.welcomeMessage!,
textAlign: TextAlign.center,
fontSize: 18,
fontWeight: FontWeight.bold,
color: const Color.fromARGB(
255, 0, 126, 167),
),
),
),
)
: const SizedBox(height: 20),
if (widget.args.bot.id == 35)
TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 1000),
tween: Tween(begin: 0.0, end: 1.0),
curve: Curves.easeOut,
builder: (context, value, child) {
return Center(
child: Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: child,
),
),
);
},
child: Center(
child: Text(
'چطور می‌تونم کمکت کنم؟',
style: TextStyle(
color: DesignConfig.isDark
? const Color.fromARGB(
255, 0, 90, 119)
: const Color.fromARGB(
255, 0, 53, 70),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
if (widget.args.bot.id != 35)
TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 1000),
tween: Tween(begin: 0.0, end: 1.0),
curve: Curves.easeOut,
builder: (context, value, child) {
return Center(
child: Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: child,
),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0),
child: Column(
children: [
if (_getBotIcon() != null)
const SizedBox(
height: 10,
),
Padding(
padding: const EdgeInsets.only(
bottom: 16.0),
child: SvgPicture.asset(
_getBotIcon()!,
height: 70,
),
),
DidvanText(
_getBotTitle(),
textAlign: TextAlign.center,
fontSize: 18,
fontWeight: FontWeight.bold,
color: const Color.fromARGB(
255, 27, 60, 89),
),
const SizedBox(height: 12),
DidvanText(
_getBotSubtitle(),
textAlign: TextAlign.justify,
fontSize: 14,
color: Theme.of(context)
.colorScheme
.caption,
),
],
),
),
),
const SizedBox(height: 70),
if (widget.args.bot.id == 35)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0),
child: Row(
children: [
const SizedBox(width: 7),
SvgPicture.asset(
'lib/assets/icons/message-question.svg',
height: 25,
color: const Color.fromARGB(
255, 0, 126, 167),
),
const SizedBox(width: 7),
const DidvanText(
'سوالات پیشنهادی:',
fontSize: 16,
fontWeight: FontWeight.normal,
color: Color.fromARGB(
255, 0, 126, 167),
),
],
),
),
const SizedBox(height: 12),
state.isLoadingQuestions
? Center(
child: SpinKitThreeBounce(
color: Theme.of(context)
.colorScheme
.primary,
size: 18,
),
)
: state.suggestedQuestions.isEmpty
? Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 24.0),
child: Center(
child: DidvanText(
'سوالی برای پیشنهاد وجود ندارد.',
fontSize: 12,
color: Theme.of(context)
.colorScheme
.caption,
),
),
)
: ListView.builder(
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
itemCount: state
.suggestedQuestions.length,
itemBuilder: (context, index) {
final question =
state.suggestedQuestions[
index];
return TweenAnimationBuilder<
double>(
duration: Duration(
milliseconds: 400 +
(index * 100)),
tween: Tween(
begin: 0.0, end: 1.0),
curve: Curves.easeOut,
builder: (context, value,
child) {
return Opacity(
opacity: value,
child:
Transform.translate(
offset: Offset(
30 * (1 - value),
0),
child: child,
),
);
},
child: InkWell(
onTap: () {
if (state.messages
.isNotEmpty &&
DateTime.parse(state
.messages
.last
.dateTime)
.toPersianDateStr(
monthString:
'')
.contains(DateTime.parse(DateTime
.now()
.subtract(const Duration(
minutes:
210))
.toIso8601String())
.toPersianDateStr(
monthString:
''))) {
state.messages.last
.prompts
.add(Prompts(
error: false,
text: question,
fileLocal: null,
finished: true,
role: 'user',
createdAt: DateTime
.now()
.subtract(
const Duration(
minutes:
210))
.toIso8601String(),
));
} else {
state.messages.add(MessageModel(
dateTime: DateTime
.now()
.subtract(
const Duration(
minutes:
210))
.toIso8601String(),
prompts: [
Prompts(
error: false,
text:
question,
finished:
true,
fileLocal:
null,
role: 'user',
createdAt: DateTime
.now()
.subtract(const Duration(
minutes:
210))
.toIso8601String(),
)
]));
}
state.message.clear();
state.update();
state.postMessage(
_currentBot,
widget.args
.assistantsName !=
null,
webSearch:
_isSearchMode);
Future.delayed(
const Duration(
milliseconds:
100), () {
state.scrollController
.animateTo(
state
.scrollController
.position
.maxScrollExtent,
duration:
const Duration(
milliseconds:
300),
curve:
Curves.easeOut,
);
});
},
child: Column(
children: [
Container(
margin:
const EdgeInsets
.symmetric(
horizontal:
24,
vertical:
4),
child: DidvanText(
question,
fontSize: 12,
color: const Color
.fromARGB(
255,
102,
102,
102),
),
),
const Padding(
// قبلاً LTR
// (left=110, right=20)
// بود که در RTL
// فارسی به سمت
// راست می‌چسبید.
// الان متقارن
// برای center.
padding: EdgeInsets
.symmetric(
horizontal:
16,
vertical:
5),
child: Divider(
height: 1,
color: Color
.fromARGB(
255,
210,
210,
210),
),
),
],
),
),
);
},
),
const SizedBox(height: 10),
],
)
else
const SizedBox(height: 20),
],
),
),
),
if (state.messages.isNotEmpty)
ListView.builder(
itemCount: state.messages.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, mIndex) {
final prompts = state.messages[mIndex].prompts;
final time = state.messages[mIndex].dateTime;
return Column(
children: [
timeLabel(context, time),
ListView.builder(
itemCount: prompts.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final message = prompts[index];
return messageBubble(
message, context, state, index, mIndex);
},
),
],
);
},
),
],
),
);
},
),
bottomNavigationBar: Padding(
padding: EdgeInsets.only(
bottom: _currentBot.id == 35
? MediaQuery.of(context).viewInsets.bottom
: (MediaQuery.of(context).viewInsets.bottom - 72.0)
.clamp(0.0, double.infinity),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AiMessageBar(
// قبلاً وقتی search mode روشن می‌شد bot به Gemini سویچ
// می‌شد (legacy). الان وب‌جستجو به‌عنوان tool روی هر
// bot انتخاب‌شده اعمال می‌شود؛ پس همان bot فعلی را
// پاس می‌دهیم.
bot: _getSelectedBot(),
attch: widget.args.attach,
assistantsName: widget.args.assistantsName,
// toggle همیشه برای GPT/Gemini در دسترس باشد — نیازی
// به _searchBot (= bot 36) نیست چون tool روی هر bot
// فعال است.
showSearchToggle: _currentBot.id == 35 || _currentBot.id == 36,
isSearchMode: _isSearchMode,
onSearchModeToggled: (bool isSearchOn) {
print('[SEARCH_TOGGLE] tapped — new mode=$isSearchOn');
setState(() {
_isSearchMode = isSearchOn;
});
},
// همیشه برای bot 35/36 (GPT/Gemini) نشان داده شود تا
// اپ موقع لود اول از کوچک به بزرگ نپرد. fallback های
// PopupMenu خود را مدیریت می‌کنند اگر bot ها هنوز null
// باشند.
showModelSelector:
_currentBot.id == 35 || _currentBot.id == 36,
selectedModel: _selectedModel,
onModelChanged: (AiModel model) {
setState(() {
_selectedModel = model;
});
},
),
],
),
),
),
),
);
}
Center timeLabel(BuildContext context, String time) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.splash,
borderRadius: DesignConfig.lowBorderRadius,
),
child: DidvanText(
DateTime.parse(time).toPersianDateStr(monthString: ''),
style: Theme.of(context).textTheme.labelSmall,
color: DesignConfig.isDark
? Theme.of(context).colorScheme.white
: Theme.of(context).colorScheme.black,
),
),
);
}
Widget messageBubble(Prompts message, BuildContext context, AiChatState state,
int index, int mIndex) {
FilesModel? file = message.fileLocal ??
(message.file == null
? null
: FilesModel(message.file.toString().replaceAll(' ', ''),
duration: message.duration != null
? Duration(seconds: message.duration!)
: null,
audio: message.audio,
video: message.video,
network: true));
debugPrint(
"📬 Message bubble - file: ${message.file}, fileLocal: ${message.fileLocal?.path}, isImage: ${file?.isImage()}");
final isUser = message.role.toString().contains('user');
return TweenAnimationBuilder<double>(
duration: Duration(milliseconds: 300 + (index * 50)),
tween: Tween(begin: 0.0, end: 1.0),
curve: Curves.easeOut,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(isUser ? -20 * (1 - value) : 20 * (1 - value), 0),
child: child,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: message.role.toString().contains('user')
? CrossAxisAlignment.start
: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: message.role.toString().contains('user')
? MainAxisAlignment.start
: MainAxisAlignment.end,
children: [
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width / 1.3),
decoration: BoxDecoration(
borderRadius: DesignConfig.highBorderRadius.copyWith(
bottomLeft: !message.role.toString().contains('user')
? Radius.zero
: null,
bottomRight: message.role.toString().contains('user')
? Radius.zero
: null,
),
color: message.error != null && message.error!
? Theme.of(context).colorScheme.error.withOpacity(0.4)
: (message.role.toString().contains('user')
? Theme.of(context).colorScheme.focused
: Theme.of(context).colorScheme.surface)
.withOpacity(0.9),
border: Border.all(
color: message.role.toString().contains('user')
? const Color.fromARGB(0, 0, 0, 0)
: Theme.of(context).colorScheme.border,
width: 0.5,
),
),
child: Container(
child: message.finished != null && !message.finished!
? Column(
children: [
ValueListenableBuilder<Stream<String>>(
valueListenable: state.messageOnstream,
builder: (context, value, child) {
return StreamBuilder<String>(
stream: value,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16),
child: Directionality(
textDirection: snapshot.data
.toString()
.startsWithEnglish()
? TextDirection.ltr
: TextDirection.rtl,
child: DidvanMarkdownText(
text: "${snapshot.data}...",
),
),
);
},
);
},
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8.0),
child: SpinKitThreeBounce(
color: Theme.of(context).colorScheme.primary,
size: 18,
),
),
],
)
: Column(
children: [
if (file != null)
(file.isAudio())
? Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: AudioWave(
file: file.isNetwork() && !file.path.startsWith('http') && !file.path.startsWith('blob:')
? RequestHelper.baseUrl + file.path
: file.path,
totalDuration: file.duration,
),
)
: file.isVideo()
? Padding(
padding: const EdgeInsets.fromLTRB(
16, 16, 16, 0),
child: ClipRRect(
borderRadius:
DesignConfig.lowBorderRadius,
child: ChatVideoPlayer(
src: RequestHelper.baseUrl +
file.path,
custome: const CustomControls(),
),
),
)
: file.isImage()
? Builder(builder: (context) {
debugPrint(
"🖼️ Rendering image with path: ${file.path}");
return Padding(
padding:
const EdgeInsets.all(8.0),
child: messageImage(file),
);
})
: Padding(
padding:
const EdgeInsets.all(8.0),
child: messageFile(
context, message, state),
),
if (message.text != null &&
message.text!.isNotEmpty &&
((message.audio == null ||
(message.audio != null &&
!message.audio!))) &&
(file == null || !file.isImage()))
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16),
child: Directionality(
textDirection: message.text
.toString()
.startsWithEnglish()
? TextDirection.ltr
: TextDirection.rtl,
child: DidvanMarkdownText(
text: message.text.toString(),
),
),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (message.role
.toString()
.contains('user') &&
widget.args.assistantsName == null)
PopupMenuButton(
offset: const Offset(0, 46),
onSelected: (value) async {
navigatorKey.currentState!.pushNamed(
Routes.aiChat,
arguments: AiChatArgs(
bot: value,
prompts: message,
isTool: widget.args.isTool ??
context
.read<
HistoryAiChatState>()
.bots,
),
);
},
itemBuilder: (BuildContext context) {
final bots = widget.args.isTool ??
context
.read<HistoryAiChatState>()
.bots;
return <PopupMenuEntry>[
...List.generate(
bots.length,
(index) => PopupMenuItem(
value: bots[index],
height: 72,
child: Container(
constraints:
const BoxConstraints(
maxWidth: 200),
child: Row(
children: [
SkeletonImage(
imageUrl: bots[index]
.image
.toString(),
width: 42,
height: 42,
borderRadius:
BorderRadius
.circular(360),
),
const SizedBox(width: 12),
Expanded(
child: Directionality(
textDirection:
TextDirection.ltr,
child: DidvanText(
bots[index]
.name
.toString(),
maxLines: 1,
overflow:
TextOverflow
.ellipsis,
),
),
),
],
),
),
),
),
];
},
child: const SizedBox(),
),
if (message.role
.toString()
.contains('user') &&
index ==
state.messages[mIndex].prompts
.length -
2 &&
(_currentBot.editable != null &&
_currentBot.editable!))
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
state.isEdite = true;
state.message.text =
message.text.toString();
state.update();
},
child: SvgPicture.asset(
'lib/assets/icons/edit-2.svg',
height: 20,
)),
),
if ((message.file != null ||
(file != null && (file.isAudio() || file.isVideo() || file.isImage()))) &&
kIsWeb)
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
// اگر URL کامل (با http) ذخیره شده — مثل عکس
// Nano Banana 2 از kie.ai — مستقیم استفاده کنیم.
// در غیر این صورت روال قدیمی (baseUrl + token).
final raw = message.file ??
(file != null && file.isNetwork()
? file.path
: (file?.path ?? ''));
final url = raw.startsWith('http')
? raw
: '${RequestHelper.baseUrl + raw}?accessToken=${RequestService.token}';
if (url.isNotEmpty) {
MediaService.downloadFileFromWeb(url);
}
},
child: Icon(
DidvanIcons.download_solid,
size: 18,
color: Theme.of(context)
.colorScheme
.focusedBorder,
),
),
),
if ((message.file != null ||
(file != null && (file.isAudio() || file.isVideo() || file.isImage()))) &&
!kIsWeb)
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
debugPrint("Download button tapped");
final raw = message.file ??
(file != null && file.isNetwork()
? file.path
: (file?.path ?? ''));
final url = raw.startsWith('http')
? raw
: '${RequestHelper.baseUrl + raw}?accessToken=${RequestService.token}';
// fileName ممکن است null باشد — از URL یا
// timestamp استخراج کنیم تا دکمه دانلود کار کند.
String fileName = message.fileName ??
(file != null ? (file.name ?? '') : '');
if (fileName.isEmpty && url.isNotEmpty) {
final seg = Uri.parse(url).pathSegments;
fileName = seg.isNotEmpty
? seg.last
: 'image_${DateTime.now().millisecondsSinceEpoch}.png';
}
if (url.isNotEmpty && fileName.isNotEmpty) {
await MediaService.downloadFile(url, name: fileName);
}
},
child: SvgPicture.asset(
'lib/assets/icons/material-symbols_download-rounded.svg',
height: 20,
)),
),
if (message.error != null && message.error!)
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
state.messages.last.prompts
.remove(message);
state.messages.last.prompts.add(
message.copyWith(
error: false));
state.file = file;
state.update();
final botToUse = _isSearchMode &&
_searchBot != null
? _searchBot!
: _getSelectedBot();
await state.postMessage(
botToUse,
widget.args.assistantsName !=
null);
Future.delayed(
const Duration(
milliseconds: 100), () {
state.scrollController
.animateTo(
state.scrollController
.position.maxScrollExtent,
duration: const Duration(
milliseconds: 300),
curve: Curves.easeOut,
);
});
},
child: SvgPicture.asset(
'lib/assets/icons/refresh-2.svg',
height: 20,
)),
),
if (message.text != null &&
message.text!.isNotEmpty &&
(file == null || !file.isImage()))
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
await Clipboard.setData(
ClipboardData(
text: state
.messages[mIndex]
.prompts[index]
.text
.toString()));
Future.delayed(
Duration.zero,
() => ActionSheetUtils(context)
.showAlert(
AlertData(
message:
"متن با موفقیت کپی شد",
aLertType:
ALertType.success,
),
),
);
},
child: SvgPicture.asset(
'lib/assets/icons/copy.svg',
height: 20,
)),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
if (message.id != null) {
state.deleteMessage(
message.id!, mIndex, index);
} else {
state.messages[mIndex].prompts
.removeAt(index);
state.update();
}
},
child: SvgPicture.asset(
'lib/assets/icons/trash.svg',
height: 20,
)),
),
],
),
),
const SizedBox(height: 8),
],
),
),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
DidvanText(
DateTimeUtils.timeWithAmPm(message.createdAt.toString())
.toPersianDigit(),
style: Theme.of(context).textTheme.labelSmall,
color: Theme.of(context).colorScheme.caption,
),
],
),
],
),
),
);
}
Container messageFile(
BuildContext context, Prompts message, AiChatState state) {
final String fileName = message.fileName ?? message.fileLocal?.name ?? '';
return Container(
decoration: BoxDecoration(
borderRadius: DesignConfig.mediumBorderRadius,
color: Theme.of(context).colorScheme.border,
),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
margin: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
SvgPicture.asset(
'lib/assets/icons/copy.svg',
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: MediaQuery.sizeOf(context).width,
child: MarqueeText(
text: fileName,
style: const TextStyle(fontSize: 14),
stop: const Duration(seconds: 3),
textDirection: fileName.startsWithEnglish()
? TextDirection.ltr
: TextDirection.rtl,
),
),
],
),
),
],
),
);
}
Widget messageImage(FilesModel file) {
final imagePath = file.isNetwork() && !file.path.startsWith('http') && !file.path.startsWith('blob:')
? RequestHelper.baseUrl + file.path
: file.path;
debugPrint(
"🎨 messageImage called with: $imagePath, isNetwork: ${file.isNetwork()}");
return GestureDetector(
onTap: () => ActionSheetUtils(context)
.openInteractiveViewer(context, imagePath, !file.isNetwork()),
child: file.isNetwork()
? file.path.startsWith('blob:')
? ClipRRect(
borderRadius: DesignConfig.lowBorderRadius,
child: Image.network(file.path),
)
: SkeletonImage(
pWidth: MediaQuery.sizeOf(context).width / 1,
pHeight: MediaQuery.sizeOf(context).height / 6,
imageUrl: imagePath,
)
: ClipRRect(
borderRadius: DesignConfig.lowBorderRadius,
child: Image.file(file.main),
),
);
}
}