import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:reactive_mind_map/reactive_mind_map.dart' as rm; import 'package:uuid/uuid.dart'; import '../models/mindmap_model.dart'; import '../providers/mind_maps_provider.dart'; import '../core/admin_access_service.dart'; import '../core/deepseek_service.dart'; class MindMapInteractiveScreen extends ConsumerStatefulWidget { final MindMapModel mindMap; const MindMapInteractiveScreen({super.key, required this.mindMap}); @override ConsumerState createState() => _MindMapInteractiveScreenState(); } class _MindMapInteractiveScreenState extends ConsumerState { late List> _nodes; late bool _isMain; bool _isEditing = false; bool _showChat = false; bool _isAISubmitting = false; final TextEditingController _chatController = TextEditingController(); final List _chatMessages = []; @override void initState() { super.initState(); _nodes = widget.mindMap.nodes.toList(); _isMain = widget.mindMap.isMain; } @override void dispose() { _chatController.dispose(); super.dispose(); } // ========== CHAT FUNCTIONS ========== void _addChatMessage(String text, bool isUser, {AIAction? action}) { setState(() { _chatMessages.add(ChatMessage( text: text, isUser: isUser, timestamp: DateTime.now(), action: action, )); }); } Future _sendToAI(String message) async { if (message.trim().isEmpty) return; _addChatMessage(message, true); _chatController.clear(); setState(() => _isAISubmitting = true); try { // Отправляем запрос к DeepSeek final response = await deepSeekService.chatCompletion( messages: [ ..._chatMessages .where((m) => m.isUser || m.text.startsWith('AI:')) .map((m) => { 'role': m.isUser ? 'user' : 'assistant', 'content': m.text, }) .toList(), { 'role': 'system', 'content': ''' Ты AI ассистент для работы с mind maps. Текущая структура mind map "${widget.mindMap.title}": ${_nodes.map((n) => '${" " * (n["level"] ?? 0)}- ${n["title"]} (уровень ${n["level"] ?? 0})').join('\n')} Когда пользователь просит что-то сделать с mind map, отвечай в формате: ACTION: TARGET: CHANGES: [ {"action": "add", "title": "...", "level": 0, "parentId": null}, {"action": "edit", "id": "...", "title": "..."}, {"action": "delete", "id": "..."} ] EXPLANATION: <объяснение на русском> Или просто отвечай текстом если действие не требуется. ''', }, ], ); // Парсим ответ AI if (response.containsKey('ACTION')) { final action = response['ACTION'] as String; final changes = response['CHANGES'] as List; final explanation = response['EXPLANATION'] as String? ?? ''; _addChatMessage( 'AI: $explanation', false, action: AIAction( type: action, changes: changes.map((c) => c as Map).toList(), isApplied: false, ), ); } else { _addChatMessage('AI: $response', false); } } catch (e) { _addChatMessage('❌ Ошибка AI: $e', false); } finally { setState(() => _isAISubmitting = false); } } void _applyAIChanges(AIAction action) { for (final change in action.changes) { final actionType = change['action'] as String; switch (actionType) { case 'add': _nodes.add({ 'id': const Uuid().v4(), 'title': change['title'] ?? '', 'level': change['level'] ?? 0, 'parentId': change['parentId'], 'color': _getColorForLevel(change['level'] ?? 0), }); break; case 'edit': final index = _nodes.indexWhere((n) => n['id'] == change['id']); if (index != -1) { _nodes[index] = { ..._nodes[index], 'title': change['title'] ?? _nodes[index]['title'], }; } break; case 'delete': final nodesToDelete = {change['id'] as String}; _collectChildren(change['id'] as String, nodesToDelete); _nodes.removeWhere((n) => nodesToDelete.contains(n['id'])); break; } } // Обновляем сообщение в чате final msgIndex = _chatMessages.indexWhere((m) => m.action == action); if (msgIndex != -1) { setState(() { _chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith( action: action.copyWith(isApplied: true), ); _nodes = _nodes.toList(); // Триггер rebuild }); } ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('✅ Изменения AI применены'), backgroundColor: Colors.green, ), ); } void _rejectAIChanges(AIAction action) { final msgIndex = _chatMessages.indexWhere((m) => m.action == action); if (msgIndex != -1) { setState(() { _chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith( action: action.copyWith(isApplied: false), ); }); } } // ========== EDIT FUNCTIONS ========== void _addNode({String? parentId}) { showDialog( context: context, builder: (context) => _NodeTitleDialog( onAdd: (title) { final parentNode = parentId != null ? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {}) : null; setState(() { _nodes.add({ 'id': const Uuid().v4(), 'title': title, 'level': parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0, 'parentId': parentId, 'color': _getColorForLevel( parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0), }); }); }, ), ); } void _editNode(String nodeId) { final node = _nodes.firstWhere((n) => n['id'] == nodeId); final controller = TextEditingController(text: node['title'] as String); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Редактировать узел'), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( labelText: 'Название', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), ElevatedButton( onPressed: () { final index = _nodes.indexWhere((n) => n['id'] == nodeId); if (index != -1) { setState(() { _nodes[index] = {..._nodes[index], 'title': controller.text}; }); } Navigator.pop(context); }, child: const Text('Сохранить'), ), ], ), ); } void _deleteNode(String nodeId) { final nodesToDelete = {nodeId}; _collectChildren(nodeId, nodesToDelete); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Удалить узел?'), content: Text( 'Удалить "${_nodes.firstWhere((n) => n['id'] == nodeId)['title']}" и все дочерние узлы?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), TextButton( onPressed: () { setState(() { _nodes.removeWhere((n) => nodesToDelete.contains(n['id'])); }); Navigator.pop(context); }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Удалить'), ), ], ), ); } void _collectChildren(String parentId, Set result) { final children = _nodes.where((n) => n['parentId'] == parentId).toList(); for (final child in children) { final childId = child['id'] as String; result.add(childId); _collectChildren(childId, result); } } Future _saveChanges() async { try { final notifier = ref.read(mindMapsNotifierProvider.notifier); final isAdmin = ref.read(isAdminProvider); final userEmail = FirebaseAuth.instance.currentUser?.email ?? ''; // Проверка прав if (!isAdmin && widget.mindMap.ownerEmail != userEmail) { throw Exception('Нет прав для редактирования'); } final updated = widget.mindMap.copyWith( nodes: _nodes, isMain: _isMain, updatedAt: DateTime.now(), ); await notifier.updateMindMap(widget.mindMap.id, updated); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('✅ Изменения сохранены'), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red), ); } } } String _getColorForLevel(int level) { final colors = [ '#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4' ]; return colors[level % colors.length]; } Color _hexToColor(String hex) { hex = hex.replaceAll('#', ''); if (hex.length == 6) return Color(int.parse('FF$hex', radix: 16)); return const Color(0xFF2196F3); } // ========== BUILD ========== @override Widget build(BuildContext context) { final isAdmin = ref.watch(isAdminProvider); final userEmail = FirebaseAuth.instance.currentUser?.email ?? ''; final canEdit = isAdmin || widget.mindMap.ownerEmail == userEmail; return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(widget.mindMap.title), Row( children: [ Icon( _isMain ? Icons.star : Icons.person, size: 14, color: _isMain ? Colors.amber : Colors.blue, ), const SizedBox(width: 4), Text( _isMain ? 'Основная' : 'Личная', style: TextStyle( fontSize: 12, color: _isMain ? Colors.amber : Colors.blue, ), ), if (canEdit && _isEditing) Padding( padding: const EdgeInsets.only(left: 8), child: Text( '✏️ Редактирование', style: const TextStyle( fontSize: 12, color: Colors.green, fontWeight: FontWeight.bold, ), ), ), ], ), ], ), actions: [ // Toggle edit mode if (canEdit) IconButton( icon: Icon(_isEditing ? Icons.visibility : Icons.edit), onPressed: () => setState(() => _isEditing = !_isEditing), tooltip: _isEditing ? 'Режим просмотра' : 'Режим редактирования', ), // AI Chat IconButton( icon: Icon(_showChat ? Icons.close : Icons.auto_awesome), onPressed: () => setState(() => _showChat = !_showChat), tooltip: _showChat ? 'Закрыть AI чат' : 'Открыть AI чат', ), // Save if (_isEditing) IconButton( icon: const Icon(Icons.save), onPressed: _saveChanges, tooltip: 'Сохранить изменения', ), ], ), body: Stack( children: [ // Mind Map (фон) _nodes.isEmpty ? _buildEmptyState() : rm.MindMapWidget( data: _buildMindMapData(), style: rm.MindMapStyle( layout: rm.MindMapLayout.radial, nodeShape: rm.NodeShape.roundedRectangle, connectionColor: Colors.blue.shade100, connectionWidth: 2.0, ), cameraFocus: rm.CameraFocus.fitAll, onNodeTap: _isEditing ? (node) => _editNode(node.id) : null, onNodeLongPress: _isEditing ? (node) => _deleteNode(node.id) : null, ), // Чат поверх карты if (_showChat) _buildChatOverlay(), ], ), // FAB для добавления узлов (в режиме редактирования) floatingActionButton: _isEditing && !_showChat ? FloatingActionButton( onPressed: () => _addNode(), child: const Icon(Icons.add), tooltip: 'Добавить корневой узел', ) : null, ); } Widget _buildChatOverlay() { return Positioned( left: 0, right: 0, bottom: 0, height: MediaQuery.of(context).size.height * 0.45, child: Column( children: [ // Градиентная полоска сверху Container( height: 4, decoration: const BoxDecoration( gradient: LinearGradient( colors: [Colors.purple, Colors.deepPurple], ), ), ), // Панель чата Expanded( child: Container( color: Theme.of(context).scaffoldBackgroundColor, child: Column( children: [ // Header Container( width: double.infinity, height: 44, padding: const EdgeInsets.symmetric(horizontal: 16), color: Colors.purple.shade800, child: Row( children: [ const Icon(Icons.auto_awesome, color: Colors.white, size: 18), const SizedBox(width: 8), const Text( 'AI Ассистент', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15, ), ), const Spacer(), Text( '${_chatMessages.length}', style: TextStyle( color: Colors.white.withOpacity(0.7), fontSize: 12, ), ), ], ), ), // Messages Expanded( child: _chatMessages.isEmpty ? _buildChatEmptyState() : ListView.builder( padding: const EdgeInsets.all(6), itemCount: _chatMessages.length, itemBuilder: (context, index) { return _buildChatMessage(_chatMessages[index]); }, ), ), // Input Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: Theme.of(context).cardColor, child: SafeArea( top: false, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: ConstrainedBox( constraints: const BoxConstraints( maxHeight: 40, ), child: TextField( controller: _chatController, maxLines: null, minLines: 1, decoration: InputDecoration( hintText: 'AI помощь...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(20), ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 2), isDense: true, ), onSubmitted: (text) => _sendToAI(text), ), ), ), const SizedBox(width: 4), SizedBox( width: 34, height: 34, child: Material( color: Colors.purple, shape: const CircleBorder(), child: InkWell( onTap: _isAISubmitting ? null : () => _sendToAI(_chatController.text), customBorder: const CircleBorder(), child: _isAISubmitting ? const SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.send, color: Colors.white, size: 12), ), ), ), ], ), ), ), ], ), ), ), ], ), ); } Widget _buildEmptyState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.account_tree, size: 100, color: Colors.grey[400]), const SizedBox(height: 24), Text( 'Mind map пуста', style: TextStyle(fontSize: 20, color: Colors.grey[600]), ), if (_isEditing) ...[ const SizedBox(height: 16), ElevatedButton.icon( onPressed: () => _addNode(), icon: const Icon(Icons.add), label: const Text('Добавить первый узел'), ), ], ], ), ); } Widget _buildChatEmptyState() { return Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.auto_awesome, size: 40, color: Colors.purple[300]), const SizedBox(height: 8), Text( 'AI Ассистент', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey[700], ), ), const SizedBox(height: 4), Text( 'Напишите "добавь узлы" или\n"предложи улучшения"', textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: Colors.grey[600]), ), ], ), ), ); } Widget _buildChatMessage(ChatMessage msg) { return Align( alignment: msg.isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: msg.isUser ? Colors.blue : Colors.grey[200], borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( msg.isUser ? msg.text : msg.text.replaceFirst('AI: ', ''), style: TextStyle( color: msg.isUser ? Colors.white : Colors.black87, fontSize: 13, ), ), if (msg.action != null) ...[ const SizedBox(height: 6), _buildAIActionButtons(msg.action!), ], ], ), ), ); } Widget _buildAIActionButtons(AIAction action) { if (action.isApplied) { return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( color: Colors.green.withOpacity(0.2), borderRadius: BorderRadius.circular(8), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle, color: Colors.green, size: 16), SizedBox(width: 4), Text('Применено', style: TextStyle(color: Colors.green)), ], ), ); } return Row( mainAxisSize: MainAxisSize.min, children: [ TextButton.icon( onPressed: () => _applyAIChanges(action), icon: const Icon(Icons.check, size: 18), label: const Text('Применить'), style: TextButton.styleFrom( foregroundColor: Colors.green, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), ), ), const SizedBox(width: 8), TextButton.icon( onPressed: () => _rejectAIChanges(action), icon: const Icon(Icons.close, size: 18), label: const Text('Отклонить'), style: TextButton.styleFrom( foregroundColor: Colors.red, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), ), ), ], ); } rm.MindMapData _buildMindMapData() { final rootNodes = _nodes.where((n) => n['parentId'] == null).toList(); if (rootNodes.isEmpty) { return rm.MindMapData( id: 'empty', title: widget.mindMap.title, children: []); } final root = rootNodes.first; return rm.MindMapData( id: root['id'] as String, title: root['title'] as String, color: _hexToColor(root['color'] as String? ?? '#2196F3'), children: _buildChildren(root['id'] as String), ); } List _buildChildren(String parentId) { final children = _nodes.where((n) => n['parentId'] == parentId).toList(); return children.map((node) { return rm.MindMapData( id: node['id'] as String, title: node['title'] as String, color: _hexToColor(node['color'] as String? ?? '#4CAF50'), children: _buildChildren(node['id'] as String), ); }).toList(); } } // ========== MODELS ========== class ChatMessage { final String text; final bool isUser; final DateTime timestamp; final AIAction? action; ChatMessage({ required this.text, required this.isUser, required this.timestamp, this.action, }); ChatMessage copyWith({String? text, bool? isUser, AIAction? action}) { return ChatMessage( text: text ?? this.text, isUser: isUser ?? this.isUser, timestamp: timestamp, action: action ?? this.action, ); } } class AIAction { final String type; final List> changes; final bool isApplied; AIAction({ required this.type, required this.changes, this.isApplied = false, }); AIAction copyWith({bool? isApplied}) { return AIAction( type: type, changes: changes, isApplied: isApplied ?? this.isApplied, ); } } class _NodeTitleDialog extends StatefulWidget { final Function(String) onAdd; const _NodeTitleDialog({required this.onAdd}); @override State<_NodeTitleDialog> createState() => _NodeTitleDialogState(); } class _NodeTitleDialogState extends State<_NodeTitleDialog> { final _controller = TextEditingController(); @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Название узла'), content: TextField( controller: _controller, autofocus: true, decoration: const InputDecoration( labelText: 'Введите название', border: OutlineInputBorder(), ), onSubmitted: (text) { if (text.isNotEmpty) widget.onAdd(text); Navigator.pop(context); }, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), ElevatedButton( onPressed: () { if (_controller.text.isNotEmpty) { widget.onAdd(_controller.text); } Navigator.pop(context); }, child: const Text('Добавить'), ), ], ); } }