931 lines
30 KiB
Dart
931 lines
30 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../core/providers/auth_provider.dart';
|
||
import '../../core/services/ai_actions.dart';
|
||
import '../../core/services/ai_context_builder.dart';
|
||
import '../../core/services/ai_service.dart';
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../core/utils/file_download_helper.dart';
|
||
import '../../models/job_file.dart';
|
||
import '../../models/tenant.dart';
|
||
|
||
class AiChatScreen extends ConsumerStatefulWidget {
|
||
const AiChatScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<AiChatScreen> createState() => _AiChatScreenState();
|
||
}
|
||
|
||
class _AiChatScreenState extends ConsumerState<AiChatScreen> {
|
||
final _inputController = TextEditingController();
|
||
final _scrollController = ScrollController();
|
||
final List<_Message> _messages = [];
|
||
|
||
String? _systemPrompt;
|
||
bool _loadingContext = true;
|
||
bool _streaming = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadContext();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_inputController.dispose();
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadContext() async {
|
||
try {
|
||
final membership = ref.read(authProvider).activeTenant!;
|
||
final prompt = await AiContextBuilder.instance.build(membership);
|
||
if (mounted) setState(() { _systemPrompt = prompt; _loadingContext = false; });
|
||
} catch (_) {
|
||
if (mounted) setState(() => _loadingContext = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _send([String? override]) async {
|
||
final text = (override ?? _inputController.text).trim();
|
||
if (text.isEmpty || _streaming || _systemPrompt == null) return;
|
||
_inputController.clear();
|
||
FocusScope.of(context).unfocus();
|
||
|
||
final apiMessages = [
|
||
..._messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.rawText}),
|
||
{'role': 'user', 'content': text},
|
||
];
|
||
|
||
setState(() {
|
||
_messages.add(_Message.user(text));
|
||
_messages.add(_Message.assistantStreaming());
|
||
_streaming = true;
|
||
});
|
||
_scrollToBottom();
|
||
|
||
var accumulated = '';
|
||
try {
|
||
final stream = AiService.instance.streamChat(
|
||
systemPrompt: _systemPrompt!,
|
||
messages: apiMessages,
|
||
);
|
||
await for (final chunk in stream) {
|
||
if (!mounted) break;
|
||
accumulated += chunk;
|
||
setState(() => _messages[_messages.length - 1] = _Message.assistantStreaming(accumulated));
|
||
_scrollToBottom();
|
||
}
|
||
if (mounted) {
|
||
setState(() => _messages[_messages.length - 1] = _Message.assistantDone(accumulated));
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() => _messages[_messages.length - 1] = _Message.error('Bir hata olustu, tekrar deneyin.'));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _streaming = false);
|
||
}
|
||
}
|
||
|
||
void _scrollToBottom() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
_scrollController.position.maxScrollExtent,
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: AppColors.background,
|
||
appBar: AppBar(
|
||
title: Row(
|
||
children: [
|
||
Container(
|
||
width: 28,
|
||
height: 28,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.accent,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(Icons.auto_awesome, size: 16, color: Colors.white),
|
||
),
|
||
const SizedBox(width: 10),
|
||
const Text('AI Asistan'),
|
||
],
|
||
),
|
||
actions: [
|
||
if (_messages.isNotEmpty)
|
||
IconButton(
|
||
onPressed: () => setState(() => _messages.clear()),
|
||
icon: const Icon(Icons.refresh_outlined, size: 20),
|
||
tooltip: 'Sohbeti temizle',
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
Expanded(
|
||
child: _loadingContext
|
||
? const _LoadingContext()
|
||
: _messages.isEmpty
|
||
? _WelcomeView(onSuggestion: _send)
|
||
: ListView.builder(
|
||
controller: _scrollController,
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||
itemCount: _messages.length,
|
||
itemBuilder: (_, i) => _MessageBubble(
|
||
message: _messages[i],
|
||
membership: ref.read(authProvider).activeTenant!,
|
||
),
|
||
),
|
||
),
|
||
_InputBar(
|
||
controller: _inputController,
|
||
enabled: !_loadingContext && !_streaming,
|
||
streaming: _streaming,
|
||
onSend: _send,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Message model ─────────────────────────────────────────────────────────────
|
||
|
||
enum _MsgKind { user, streaming, done, error }
|
||
|
||
class _Message {
|
||
_Message._({required this.kind, required this.rawText});
|
||
|
||
factory _Message.user(String text) =>
|
||
_Message._(kind: _MsgKind.user, rawText: text);
|
||
|
||
factory _Message.assistantStreaming([String text = '']) =>
|
||
_Message._(kind: _MsgKind.streaming, rawText: text);
|
||
|
||
factory _Message.assistantDone(String text) =>
|
||
_Message._(kind: _MsgKind.done, rawText: text);
|
||
|
||
factory _Message.error(String text) =>
|
||
_Message._(kind: _MsgKind.error, rawText: text);
|
||
|
||
final _MsgKind kind;
|
||
final String rawText;
|
||
|
||
bool get isUser => kind == _MsgKind.user;
|
||
bool get isStreaming => kind == _MsgKind.streaming;
|
||
bool get isError => kind == _MsgKind.error;
|
||
}
|
||
|
||
// ── Welcome view ──────────────────────────────────────────────────────────────
|
||
|
||
class _WelcomeView extends StatelessWidget {
|
||
const _WelcomeView({required this.onSuggestion});
|
||
final void Function(String) onSuggestion;
|
||
|
||
static const _suggestions = [
|
||
'Bekleyen islerimin ozeti nedir?',
|
||
'Gecikmiş iş var mı?',
|
||
'Bu ay kac is tamamlandi?',
|
||
'Revizyon oranım ne durumda?',
|
||
'Finans durumumu ozetle.',
|
||
'Son yuklenen dosyalari goster.',
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(20, 32, 20, 12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.accent,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: const Icon(Icons.auto_awesome, size: 28, color: Colors.white),
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'Merhaba! Size nasil yardimci olabilirim?',
|
||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||
),
|
||
const SizedBox(height: 6),
|
||
const Text(
|
||
'Isler, finans ve ekip hakkinda soru sorabilir, islem yapabilirsiniz.',
|
||
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||
),
|
||
const SizedBox(height: 28),
|
||
const Text(
|
||
'ONERILER',
|
||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.8),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: _suggestions
|
||
.map((s) => _SuggestionChip(label: s, onTap: () => onSuggestion(s)))
|
||
.toList(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SuggestionChip extends StatelessWidget {
|
||
const _SuggestionChip({required this.label, required this.onTap});
|
||
final String label;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Message bubble ────────────────────────────────────────────────────────────
|
||
|
||
class _MessageBubble extends StatelessWidget {
|
||
const _MessageBubble({required this.message, required this.membership});
|
||
final _Message message;
|
||
final TenantMembership membership;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (message.isUser) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Flexible(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.accent,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(16),
|
||
topRight: Radius.circular(16),
|
||
bottomLeft: Radius.circular(16),
|
||
bottomRight: Radius.circular(4),
|
||
),
|
||
),
|
||
child: Text(
|
||
message.rawText,
|
||
style: const TextStyle(fontSize: 14, height: 1.5, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 28,
|
||
height: 28,
|
||
margin: const EdgeInsets.only(right: 8, top: 2),
|
||
decoration: BoxDecoration(color: AppColors.accent, borderRadius: BorderRadius.circular(8)),
|
||
child: const Icon(Icons.auto_awesome, size: 14, color: Colors.white),
|
||
),
|
||
Flexible(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(4),
|
||
topRight: Radius.circular(16),
|
||
bottomLeft: Radius.circular(16),
|
||
bottomRight: Radius.circular(16),
|
||
),
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: message.isStreaming && message.rawText.isEmpty
|
||
? const _TypingDots()
|
||
: _MessageContent(message: message, membership: membership),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Message content: markdown + action buttons ────────────────────────────────
|
||
|
||
class _MessageContent extends StatelessWidget {
|
||
const _MessageContent({required this.message, required this.membership});
|
||
final _Message message;
|
||
final TenantMembership membership;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (message.isError) {
|
||
return Text(
|
||
message.rawText,
|
||
style: const TextStyle(fontSize: 14, height: 1.5, color: AppColors.cancelled),
|
||
);
|
||
}
|
||
|
||
// During streaming: show raw text without parsing actions
|
||
if (message.isStreaming) {
|
||
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
|
||
}
|
||
|
||
// Done: parse segments → text + action buttons
|
||
final segments = parseSegments(message.rawText);
|
||
if (segments.isEmpty) {
|
||
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: segments.map((seg) {
|
||
if (seg is TextSegment) {
|
||
return _MarkdownText(seg.text, color: AppColors.textPrimary);
|
||
}
|
||
if (seg is ActionSegment) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 10),
|
||
child: _ActionCard(action: seg.action, membership: membership),
|
||
);
|
||
}
|
||
return const SizedBox.shrink();
|
||
}).toList(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Action card ───────────────────────────────────────────────────────────────
|
||
|
||
enum _ActionState { idle, confirming, loading, success, error, files }
|
||
|
||
class _ActionCard extends StatefulWidget {
|
||
const _ActionCard({required this.action, required this.membership});
|
||
final AiAction action;
|
||
final TenantMembership membership;
|
||
|
||
@override
|
||
State<_ActionCard> createState() => _ActionCardState();
|
||
}
|
||
|
||
class _ActionCardState extends State<_ActionCard> {
|
||
_ActionState _state = _ActionState.idle;
|
||
String _resultMsg = '';
|
||
List<JobFile> _files = [];
|
||
|
||
AiAction get action => widget.action;
|
||
|
||
Future<void> _execute() async {
|
||
setState(() => _state = _ActionState.loading);
|
||
final outcome = await AiActionExecutor.execute(action, widget.membership);
|
||
if (!mounted) return;
|
||
switch (outcome) {
|
||
case ActionSuccess(:final message):
|
||
setState(() { _state = _ActionState.success; _resultMsg = message; });
|
||
case ActionError(:final error):
|
||
setState(() { _state = _ActionState.error; _resultMsg = error; });
|
||
case ActionFiles(:final files):
|
||
setState(() { _state = _ActionState.files; _files = files; });
|
||
}
|
||
}
|
||
|
||
void _onTap() {
|
||
if (action.isDangerous) {
|
||
setState(() => _state = _ActionState.confirming);
|
||
} else {
|
||
_execute();
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return switch (_state) {
|
||
_ActionState.idle => _idleButton(),
|
||
_ActionState.confirming => _confirmCard(),
|
||
_ActionState.loading => _loadingCard(),
|
||
_ActionState.success => _resultCard(success: true),
|
||
_ActionState.error => _resultCard(success: false),
|
||
_ActionState.files => _filesCard(),
|
||
};
|
||
}
|
||
|
||
Widget _idleButton() {
|
||
final isDanger = action.isDangerous;
|
||
final isFile = action.isFileAction;
|
||
final color = isDanger
|
||
? AppColors.cancelled
|
||
: isFile
|
||
? AppColors.inProgress
|
||
: AppColors.accent;
|
||
final bgColor = isDanger
|
||
? AppColors.cancelledBg
|
||
: isFile
|
||
? AppColors.inProgressBg
|
||
: AppColors.accent.withValues(alpha: 0.1);
|
||
final icon = isDanger
|
||
? Icons.cancel_outlined
|
||
: isFile
|
||
? Icons.folder_outlined
|
||
: action.type == 'add_member'
|
||
? Icons.person_add_outlined
|
||
: Icons.check_circle_outline;
|
||
|
||
return GestureDetector(
|
||
onTap: _onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 16, color: color),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: Text(
|
||
action.label,
|
||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _confirmCard() {
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cancelledBg,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.3)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.cancelled),
|
||
const SizedBox(width: 6),
|
||
const Expanded(
|
||
child: Text(
|
||
'Bu islem geri alinamayabilir.',
|
||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.cancelled),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(action.label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => setState(() => _state = _ActionState.idle),
|
||
style: OutlinedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
side: BorderSide(color: AppColors.border),
|
||
),
|
||
child: const Text('Vazgec', style: TextStyle(fontSize: 13)),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: _execute,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: AppColors.cancelled,
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
),
|
||
child: const Text('Onayla', style: TextStyle(fontSize: 13)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _loadingCard() {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.background,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text('Isleniyor...', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _resultCard({required bool success}) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: success ? AppColors.successBg : AppColors.cancelledBg,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
success ? Icons.check_circle_outline : Icons.error_outline,
|
||
size: 16,
|
||
color: success ? AppColors.success : AppColors.cancelled,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: Text(
|
||
_resultMsg,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
color: success ? AppColors.success : AppColors.cancelled,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _filesCard() {
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.inProgressBg,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: AppColors.inProgress.withValues(alpha: 0.2)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(Icons.folder_open_outlined, size: 15, color: AppColors.inProgress),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'${_files.length} dosya',
|
||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.inProgress),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
..._files.map((f) => _FileDownloadRow(file: f)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── File download row ─────────────────────────────────────────────────────────
|
||
|
||
class _FileDownloadRow extends StatefulWidget {
|
||
const _FileDownloadRow({required this.file});
|
||
final JobFile file;
|
||
|
||
@override
|
||
State<_FileDownloadRow> createState() => _FileDownloadRowState();
|
||
}
|
||
|
||
class _FileDownloadRowState extends State<_FileDownloadRow> {
|
||
bool _downloading = false;
|
||
|
||
IconData get _icon => switch (widget.file.kind) {
|
||
JobFileKind.scan => Icons.view_in_ar_rounded,
|
||
JobFileKind.image => Icons.image_outlined,
|
||
JobFileKind.document => Icons.description_outlined,
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Row(
|
||
children: [
|
||
Icon(_icon, size: 14, color: AppColors.inProgress),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
widget.file.name,
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
widget.file.sizeLabel,
|
||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||
),
|
||
const SizedBox(width: 6),
|
||
_downloading
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 1.5, color: AppColors.inProgress),
|
||
)
|
||
: GestureDetector(
|
||
onTap: () async {
|
||
setState(() => _downloading = true);
|
||
await FileDownloadHelper.download(context, widget.file);
|
||
if (mounted) setState(() => _downloading = false);
|
||
},
|
||
child: const Icon(Icons.download_outlined, size: 18, color: AppColors.inProgress),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Simple markdown renderer ──────────────────────────────────────────────────
|
||
|
||
class _MarkdownText extends StatelessWidget {
|
||
const _MarkdownText(this.text, {required this.color});
|
||
final String text;
|
||
final Color color;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (text.isEmpty) return const SizedBox.shrink();
|
||
final lines = text.split('\n');
|
||
final widgets = <Widget>[];
|
||
bool prevEmpty = false;
|
||
|
||
for (final raw in lines) {
|
||
final line = raw.trimRight();
|
||
if (line.isEmpty) {
|
||
if (!prevEmpty) widgets.add(const SizedBox(height: 6));
|
||
prevEmpty = true;
|
||
continue;
|
||
}
|
||
prevEmpty = false;
|
||
|
||
// Header ## or ###
|
||
if (line.startsWith('## ') || line.startsWith('### ')) {
|
||
final content = line.replaceFirst(RegExp(r'^#{2,3}\s+'), '');
|
||
widgets.add(Padding(
|
||
padding: const EdgeInsets.only(top: 4, bottom: 2),
|
||
child: Text(
|
||
content,
|
||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color),
|
||
),
|
||
));
|
||
continue;
|
||
}
|
||
|
||
// Bullet
|
||
if (line.startsWith('- ') || line.startsWith('• ')) {
|
||
final content = line.substring(2);
|
||
widgets.add(Padding(
|
||
padding: const EdgeInsets.only(left: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('• ', style: TextStyle(color: color, height: 1.5, fontSize: 14)),
|
||
Expanded(child: _inlineText(content, color)),
|
||
],
|
||
),
|
||
));
|
||
continue;
|
||
}
|
||
|
||
widgets.add(_inlineText(line, color));
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: widgets,
|
||
);
|
||
}
|
||
|
||
Widget _inlineText(String text, Color baseColor) {
|
||
final spans = <InlineSpan>[];
|
||
final boldPattern = RegExp(r'\*\*(.+?)\*\*');
|
||
int last = 0;
|
||
|
||
for (final m in boldPattern.allMatches(text)) {
|
||
if (m.start > last) {
|
||
spans.add(TextSpan(text: text.substring(last, m.start)));
|
||
}
|
||
spans.add(TextSpan(
|
||
text: m.group(1),
|
||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||
));
|
||
last = m.end;
|
||
}
|
||
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
|
||
|
||
return RichText(
|
||
text: TextSpan(
|
||
style: TextStyle(fontSize: 14, height: 1.5, color: baseColor),
|
||
children: spans,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Typing dots ───────────────────────────────────────────────────────────────
|
||
|
||
class _TypingDots extends StatefulWidget {
|
||
const _TypingDots();
|
||
@override
|
||
State<_TypingDots> createState() => _TypingDotsState();
|
||
}
|
||
|
||
class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin {
|
||
late final AnimationController _ctrl;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat();
|
||
}
|
||
|
||
@override
|
||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 18,
|
||
child: AnimatedBuilder(
|
||
animation: _ctrl,
|
||
builder: (_, __) => Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: List.generate(3, (i) {
|
||
final t = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
|
||
final opacity = (t < 0.5 ? t * 2 : (1 - t) * 2).clamp(0.3, 1.0);
|
||
return Container(
|
||
width: 6, height: 6,
|
||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.accent.withValues(alpha: opacity),
|
||
shape: BoxShape.circle,
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Input bar ─────────────────────────────────────────────────────────────────
|
||
|
||
class _InputBar extends StatelessWidget {
|
||
const _InputBar({
|
||
required this.controller,
|
||
required this.enabled,
|
||
required this.streaming,
|
||
required this.onSend,
|
||
});
|
||
|
||
final TextEditingController controller;
|
||
final bool enabled;
|
||
final bool streaming;
|
||
final VoidCallback onSend;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||
return Container(
|
||
padding: EdgeInsets.fromLTRB(12, 8, 12, 8 + bottom),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
border: Border(top: BorderSide(color: AppColors.border)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
enabled: enabled,
|
||
maxLines: 4,
|
||
minLines: 1,
|
||
textInputAction: TextInputAction.send,
|
||
onSubmitted: (_) => onSend(),
|
||
decoration: InputDecoration(
|
||
hintText: streaming ? 'Yanit bekleniyor...' : 'Bir sey sorun...',
|
||
hintStyle: const TextStyle(color: AppColors.textMuted),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(22),
|
||
borderSide: BorderSide(color: AppColors.border),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(22),
|
||
borderSide: BorderSide(color: AppColors.border),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(22),
|
||
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
isDense: true,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
onTap: enabled ? onSend : null,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: enabled ? AppColors.accent : AppColors.border,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Center(
|
||
child: streaming
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||
)
|
||
: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 18),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Loading context ───────────────────────────────────────────────────────────
|
||
|
||
class _LoadingContext extends StatelessWidget {
|
||
const _LoadingContext();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2),
|
||
SizedBox(height: 12),
|
||
Text('Veriler yukleniyor...', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|