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 createState() => _AiChatScreenState(); } class _AiChatScreenState extends ConsumerState { 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 _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 _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 _files = []; AiAction get action => widget.action; Future _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 = []; 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 = []; 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)), ], ), ); } }