diff --git a/lib/core/firestore_service.dart b/lib/core/firestore_service.dart index 6114f80..28f8538 100644 --- a/lib/core/firestore_service.dart +++ b/lib/core/firestore_service.dart @@ -166,6 +166,27 @@ class FirestoreService { .delete(); } + Stream> watchMindMapNodes(String projectId) { + return _db + .collection('mindmaps') + .doc(projectId) + .collection('nodes') + .orderBy('level') + .snapshots() + .map((snapshot) => snapshot.docs + .map((doc) => MindMapNode.fromFirestore(doc)) + .toList()); + } + + Future deleteMindMapNode(String projectId, String nodeId) async { + await _db + .collection('mindmaps') + .doc(projectId) + .collection('nodes') + .doc(nodeId) + .delete(); + } + // === CALENDAR EVENTS === Future createEvent(CalendarEventModel event) async { await _db.collection('events').doc(event.id).set(event.toFirestore()); diff --git a/lib/providers/mindmap_provider.dart b/lib/providers/mindmap_provider.dart new file mode 100644 index 0000000..1bcc6a1 --- /dev/null +++ b/lib/providers/mindmap_provider.dart @@ -0,0 +1,320 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import '../models/mind_map_model.dart'; +import '../models/task_model.dart'; +import '../core/firestore_service.dart'; +import '../core/local_storage_service.dart'; +import '../core/deepseek_service.dart'; + +// Provider для mind-карты проекта +final mindMapProvider = StateNotifierProvider.family, String>((ref, projectId) { + return MindMapNotifier(projectId); +}); + +// Данные mind-карты +class MindMapData { + final List nodes; + final Map> childrenMap; // parentId -> [childIds] + final String? rootNodeId; + + MindMapData({ + required this.nodes, + required this.childrenMap, + this.rootNodeId, + }); + + // Получить дочерние узлы + List getChildren(String parentId) { + final childIds = childrenMap[parentId] ?? []; + return nodes.where((n) => childIds.contains(n.id)).toList(); + } + + // Получить родительский узел + MindMapNode? getParent(String nodeId) { + final node = nodes.firstWhere((n) => n.id == nodeId, orElse: () => throw Exception('Node not found')); + if (node.parentId == null) return null; + return nodes.firstWhere((n) => n.id == node.parentId, orElse: () => throw Exception('Parent not found')); + } +} + +class MindMapNotifier extends StateNotifier> { + final String projectId; + + MindMapNotifier(this.projectId) : super(const AsyncValue.loading()) { + _loadMindMap(); + } + + // Загрузка mind-карты + Future _loadMindMap() async { + try { + // Загружаем из локального хранилища + final localNodes = localStorageService.getMindMapNodes(); + final projectNodes = localNodes.where((n) => n.projectId == projectId).toList(); + + state = AsyncValue.data(_createMindMapData(projectNodes)); + + // TODO: Загрузить из Firestore и синхронизировать + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + // Создание структуры данных + MindMapData _createMindMapData(List nodes) { + final childrenMap = >{}; + String? rootNodeId; + + for (final node in nodes) { + if (node.parentId == null) { + rootNodeId = node.id; + } else { + childrenMap.putIfAbsent(node.parentId!, () => []).add(node.id); + } + } + + return MindMapData( + nodes: nodes, + childrenMap: childrenMap, + rootNodeId: rootNodeId, + ); + } + + // Добавление узла + Future addNode({ + required String title, + String? parentId, + String? taskId, + double x = 0, + double y = 0, + String color = '#2196F3', + String icon = 'folder', + }) async { + try { + final parentNode = parentId != null + ? state.value?.nodes.firstWhere((n) => n.id == parentId) + : null; + + final node = MindMapNode( + id: const Uuid().v4(), + projectId: projectId, + parentId: parentId, + level: parentId != null ? (parentNode?.level ?? 0) + 1 : 0, + title: title, + taskId: taskId, + x: x, + y: y, + color: color, + icon: icon, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + // Сохраняем локально + await localStorageService.saveMindMapNodes([node]); + + // Сохраняем в Firestore + try { + await firestoreService.createMindMapNode(node); + } catch (e) { + print('Firestore save failed: $e'); + } + + // Обновляем состояние + final currentData = state.value; + if (currentData != null) { + final updatedNodes = [...currentData.nodes, node]; + + if (parentId != null) { + currentData.childrenMap.putIfAbsent(parentId, () => []).add(node.id); + } + + state = AsyncValue.data(MindMapData( + nodes: updatedNodes, + childrenMap: currentData.childrenMap, + rootNodeId: parentId == null ? node.id : currentData.rootNodeId, + )); + } + + return node; + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + // Обновление узла + Future updateNode(String nodeId, { + String? title, + String? color, + String? icon, + double? x, + double? y, + }) async { + try { + final currentData = state.value; + if (currentData == null) throw Exception('No data'); + + final nodeIndex = currentData.nodes.indexWhere((n) => n.id == nodeId); + if (nodeIndex == -1) throw Exception('Node not found'); + + final oldNode = currentData.nodes[nodeIndex]; + final updatedNode = oldNode.copyWith( + title: title ?? oldNode.title, + color: color ?? oldNode.color, + icon: icon ?? oldNode.icon, + x: x ?? oldNode.x, + y: y ?? oldNode.y, + updatedAt: DateTime.now(), + ); + + // Сохраняем локально + await localStorageService.saveMindMapNodes([updatedNode]); + + // Сохраняем в Firestore + try { + await firestoreService.updateMindMapNode(updatedNode); + } catch (e) { + print('Firestore update failed: $e'); + } + + // Обновляем состояние + final updatedNodes = List.from(currentData.nodes); + updatedNodes[nodeIndex] = updatedNode; + + state = AsyncValue.data(MindMapData( + nodes: updatedNodes, + childrenMap: currentData.childrenMap, + rootNodeId: currentData.rootNodeId, + )); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + // Удаление узла (и всех дочерних) + Future deleteNode(String nodeId) async { + try { + final currentData = state.value; + if (currentData == null) return; + + // Находим все дочерние узлы (рекурсивно) + final nodesToDelete = {nodeId}; + _collectChildNodes(nodeId, currentData, nodesToDelete); + + // Сохраняем для удаления + final nodes = currentData.nodes.where((n) => !nodesToDelete.contains(n.id)).toList(); + + // Сохраняем локально (удаляем) + for (final id in nodesToDelete) { + // TODO: localStorageService.deleteMindMapNode(id); + } + + // Обновляем состояние + final newChildrenMap = >{}; + String? newRootNodeId; + + for (final node in nodes) { + if (node.parentId == null) { + newRootNodeId = node.id; + } else if (!nodesToDelete.contains(node.parentId)) { + newChildrenMap.putIfAbsent(node.parentId!, () => []).add(node.id); + } + } + + state = AsyncValue.data(MindMapData( + nodes: nodes, + childrenMap: newChildrenMap, + rootNodeId: newRootNodeId, + )); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + // Рекурсивный сбор дочерних узлов + void _collectChildNodes(String parentId, MindMapData data, Set result) { + final childIds = data.childrenMap[parentId] ?? []; + for (final childId in childIds) { + result.add(childId); + _collectChildNodes(childId, data, result); + } + } + + // AI: Генерация mind-карты проекта + Future> generateMindMapWithAI({ + required String projectName, + required String description, + }) async { + try { + final nodesData = await deepSeekService.generateMindMap( + projectName: projectName, + description: description, + ); + + final nodes = []; + MindMapNode? rootNode; + + for (var i = 0; i < nodesData.length; i++) { + final nodeData = nodesData[i]; + final level = nodeData['level'] as int; + final parentId = nodeData['parentId'] as int?; + + String? actualParentId; + if (parentId != null && parentId < nodes.length) { + actualParentId = nodes[parentId].id; + } + + final node = await addNode( + title: nodeData['title'] as String, + parentId: actualParentId, + x: (level * 200).toDouble(), // Автоматическое позиционирование + y: (i * 50).toDouble(), + ); + + nodes.add(node); + + if (level == 0) { + rootNode = node; + } + } + + return nodes; + } catch (e, st) { + rethrow; + } + } + + // Связь узла с задачей + Future linkTask(String nodeId, String taskId) async { + await updateNode(nodeId); + // TODO: Обновить taskId в узле + } + + // Экспорт в JSON + Map exportToJson() { + final data = state.value; + if (data == null) return {}; + + return { + 'projectId': projectId, + 'rootNodeId': data.rootNodeId, + 'nodes': data.nodes.map((n) => { + 'id': n.id, + 'parentId': n.parentId, + 'level': n.level, + 'title': n.title, + 'x': n.x, + 'y': n.y, + 'color': n.color, + 'icon': n.icon, + }).toList(), + }; + } + + // Импорт из JSON + Future importFromJson(Map json) async { + // TODO: Реализовать импорт + } +} diff --git a/lib/screens/mind_map_screen.dart b/lib/screens/mind_map_screen.dart index 366ea0f..173c8e7 100644 --- a/lib/screens/mind_map_screen.dart +++ b/lib/screens/mind_map_screen.dart @@ -1,60 +1,494 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:graphview/graphview.dart'; +import '../providers/mindmap_provider.dart'; +import '../models/mind_map_model.dart'; -class MindMapScreen extends StatelessWidget { +class MindMapScreen extends ConsumerStatefulWidget { const MindMapScreen({super.key}); + @override + ConsumerState createState() => _MindMapScreenState(); +} + +class _MindMapScreenState extends ConsumerState { + final Graph graph = Graph(); + final SugiyamaConfiguration config = SugiyamaConfiguration(); + bool _isEditing = false; + bool _isGenerating = false; + @override Widget build(BuildContext context) { + final projectId = 'default'; // TODO: Получить текущий проект + final mindMapState = ref.watch(mindMapProvider(projectId)); + return Scaffold( appBar: AppBar( title: const Text('Mind-карта'), actions: [ IconButton( - icon: const Icon(Icons.edit), + icon: Icon(_isEditing ? Icons.check : Icons.edit), onPressed: () { - // Edit mode - только для админов + setState(() { + _isEditing = !_isEditing; + }); }, + tooltip: _isEditing ? 'Готово' : 'Редактировать', ), IconButton( - icon: const Icon(Icons.download), - onPressed: () {}, + icon: const Icon(Icons.auto_awesome), + onPressed: _isGenerating ? null : () => _generateWithAI(projectId), + tooltip: 'AI Генерация', + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showAddNodeDialog(context, projectId, null), + tooltip: 'Добавить узел', ), ], ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.account_tree, - size: 100, - color: Colors.grey, - ), - const SizedBox(height: 24), - const Text( - 'Mind-карта проекта', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text( - 'Создайте структуру задач в виде дерева', - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 32), - ElevatedButton.icon( - onPressed: () {}, + body: mindMapState.when( + data: (mindMapData) { + if (mindMapData.nodes.isEmpty) { + return _buildEmptyState(context, projectId); + } + + return _buildMindMap(mindMapData); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text('Ошибка: $error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.refresh(mindMapProvider(projectId)), + child: const Text('Повторить'), + ), + ], + ), + ), + ), + floatingActionButton: _isEditing + ? FloatingActionButton.extended( + onPressed: () => _showAddNodeDialog(context, projectId, null), icon: const Icon(Icons.add), - label: const Text('Создать mind-карту'), + label: const Text('Добавить узел'), + ) + : null, + ); + } + + Widget _buildEmptyState(BuildContext context, String projectId) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.account_tree, + size: 100, + color: Colors.grey, + ), + const SizedBox(height: 24), + const Text( + 'Mind-карта пуста', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Создайте структуру проекта', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => _showAddNodeDialog(context, projectId, null), + icon: const Icon(Icons.add), + label: const Text('Добавить вручную'), + ), + const SizedBox(width: 16), + OutlinedButton.icon( + onPressed: + _isGenerating ? null : () => _generateWithAI(projectId), + icon: _isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome), + label: const Text('AI Генерация'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildMindMap(MindMapData mindMapData) { + // Строим граф из узлов + graph.clear(); + + final nodesMap = {}; + + // Создаём узлы графа + for (final node in mindMapData.nodes) { + final graphNode = Node.Id(node.id); + nodesMap[node.id] = graphNode; + graph.addNode(graphNode); + } + + // Создаём связи + for (final node in mindMapData.nodes) { + if (node.parentId != null && nodesMap.containsKey(node.parentId)) { + graph.addEdge(nodesMap[node.parentId]!, nodesMap[node.id]!); + } + } + + // Настройка визуализации + config.orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; + config.nodeSeparation = 40; + config.levelSeparation = 60; + config.backgroundColor = Colors.transparent; + + // Настройка узлов + config.builder = (Node node) { + final mindMapNode = mindMapData.nodes.firstWhere( + (n) => n.id == node.key, + orElse: () => throw Exception('Node not found'), + ); + + return _buildNodeWidget(mindMapNode); + }; + + return InteractiveViewer( + panEnabled: true, + scaleEnabled: true, + child: SizedBox( + width: double.maxFinite, + height: double.maxFinite, + child: SugiyamaAlgorithm(graph, config), + ), + ); + } + + Widget _buildNodeWidget(MindMapNode node) { + return GestureDetector( + onTap: () => _showNodeOptionsDialog(node), + onDoubleTap: () => _isEditing ? _showEditNodeDialog(node) : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _hexToColor(node.color), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getIconData(node.icon), + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + node.title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (node.taskId != null) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '📋 Задача', + style: TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ], + ), + ), + ); + } + + Future _generateWithAI(String projectId) async { + setState(() { + _isGenerating = true; + }); + + try { + // Показываем диалог для ввода данных проекта + final projectName = await _showProjectNameDialog(); + if (projectName == null || projectName.isEmpty) return; + + final projectDescription = await _showProjectDescriptionDialog(); + + await ref.read(mindMapProvider(projectId).notifier).generateMindMapWithAI( + projectName: projectName, + description: projectDescription ?? '', + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Mind-карта сгенерирована!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ Ошибка: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() { + _isGenerating = false; + }); + } + } + + Future _showProjectNameDialog() { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Название проекта'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Введите название', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, controller.text), + child: const Text('Генерировать'), + ), + ], + ), + ); + } + + Future _showProjectDescriptionDialog() { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Описание проекта'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Описание (опционально)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, ''), + child: const Text('Пропустить'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, controller.text), + child: const Text('Генерировать'), + ), + ], + ), + ); + } + + void _showAddNodeDialog( + BuildContext context, String projectId, String? parentId) { + final titleController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Добавить узел'), + content: TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () async { + if (titleController.text.isEmpty) return; + + await ref.read(mindMapProvider(projectId).notifier).addNode( + title: titleController.text, + parentId: parentId, + ); + + if (context.mounted) { + Navigator.pop(context); + } + }, + child: const Text('Добавить'), + ), + ], + ), + ); + } + + void _showEditNodeDialog(MindMapNode node) { + final titleController = TextEditingController(text: node.title); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Редактировать узел'), + content: TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () async { + // TODO: Обновить узел + Navigator.pop(context); + }, + child: const Text('Сохранить'), + ), + ], + ), + ); + } + + void _showNodeOptionsDialog(MindMapNode node) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Редактировать'), + onTap: () { + Navigator.pop(context); + _showEditNodeDialog(node); + }, + ), + ListTile( + leading: const Icon(Icons.link), + title: const Text('Привязать задачу'), + onTap: () { + Navigator.pop(context); + // TODO: Выбрать задачу + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text('Удалить', style: TextStyle(color: Colors.red)), + onTap: () { + Navigator.pop(context); + _confirmDeleteNode(node); + }, ), ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - // AI генерация mind-карты - }, - child: const Icon(Icons.auto_awesome), + ); + } + + void _confirmDeleteNode(MindMapNode node) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить узел?'), + content: const Text('Все дочерние узлы также будут удалены'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () async { + // TODO: Удалить узел + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], ), ); } + + Color _hexToColor(String hex) { + hex = hex.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('0xFF$hex')); + } + return Colors.blue; + } + + IconData _getIconData(String iconName) { + switch (iconName) { + case 'folder': + return Icons.folder; + case 'task': + return Icons.task; + case 'star': + return Icons.star; + case 'flag': + return Icons.flag; + default: + return Icons.circle; + } + } }