Files
Avtex/lib/screens/mind_map_editor_screen.dart
T
Alexyz f1e0f6b5ef feat: Add Nanaxe server with Docker, GitHub Actions CI/CD, and auto-deployment
- Add complete server infrastructure with Gitea, API, PostgreSQL
- Implement Obsidian-compatible markdown mindmap converter
- Add Flutter service for server synchronization
- Configure GitHub Actions for automated build and deploy
- Add production docker-compose with health checks
- Include VPS setup and deployment scripts
- Add comprehensive deployment documentation
2026-04-19 22:18:25 +05:00

516 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_auth/firebase_auth.dart';
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 MindMapEditorScreen extends ConsumerStatefulWidget {
final MindMapModel? mindMap; // null = создание новой
const MindMapEditorScreen({super.key, this.mindMap});
@override
ConsumerState<MindMapEditorScreen> createState() => _MindMapEditorScreenState();
}
class _MindMapEditorScreenState extends ConsumerState<MindMapEditorScreen> {
late TextEditingController _titleController;
late TextEditingController _descController;
late List<Map<String, dynamic>> _nodes;
late bool _isMain;
bool _isSaving = false;
bool _isGeneratingAI = false;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.mindMap?.title ?? '');
_descController = TextEditingController(text: widget.mindMap?.description ?? '');
_nodes = widget.mindMap?.nodes.toList() ?? [];
_isMain = widget.mindMap?.isMain ?? false;
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
super.dispose();
}
Future<void> _save() async {
if (_titleController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите название')),
);
return;
}
setState(() => _isSaving = true);
try {
final notifier = ref.read(mindMapsNotifierProvider.notifier);
if (widget.mindMap == null) {
// Создание новой
await notifier.createMindMap(
title: _titleController.text.trim(),
description: _descController.text.trim(),
nodes: _nodes,
isMain: _isMain,
);
} else {
// Обновление существующей
final updated = widget.mindMap!.copyWith(
title: _titleController.text.trim(),
description: _descController.text.trim(),
nodes: _nodes,
isMain: _isMain,
updatedAt: DateTime.now(),
);
await notifier.updateMindMap(widget.mindMap!.id, updated);
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.mindMap == null ? '✅ Mind map создана' : '✅ Mind map сохранена'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
);
}
} finally {
setState(() => _isSaving = false);
}
}
Future<void> _addNode({String? parentId}) async {
final title = await _showNodeTitleDialog();
if (title == null || title.isEmpty) return;
final parentNode = parentId != null
? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {})
: null;
final newNode = {
'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),
};
setState(() => _nodes.add(newNode));
}
Future<void> _addChildNode(String parentId) async {
final title = await _showNodeTitleDialog();
if (title == null || title.isEmpty) return;
final parentNode = _nodes.firstWhere((n) => n['id'] == parentId);
final newNode = {
'id': const Uuid().v4(),
'title': title,
'level': (parentNode['level'] ?? 0) + 1,
'parentId': parentId,
'color': _getColorForLevel((parentNode['level'] ?? 0) + 1),
};
setState(() => _nodes.add(newNode));
}
void _deleteNode(String nodeId) {
// Удаляем узел и всех его потомков
final nodesToDelete = <String>{nodeId};
_collectChildren(nodeId, nodesToDelete);
setState(() {
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
});
}
void _collectChildren(String parentId, Set<String> 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<String?> _showNodeTitleDialog() async {
final controller = TextEditingController();
return showDialog<String>(
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<void> _generateWithAI() async {
if (_titleController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите название проекта')),
);
return;
}
setState(() => _isGeneratingAI = true);
try {
final projectName = _titleController.text.trim();
final projectDesc = _descController.text.trim();
final nodesData = await deepSeekService.generateMindMap(
projectName: projectName,
description: projectDesc.isNotEmpty ? projectDesc : 'Mind map для проекта',
);
// Преобразуем и добавляем к существующим узлам
final newNodes = <Map<String, dynamic>>[];
for (var i = 0; i < nodesData.length; i++) {
final nodeData = nodesData[i];
final level = nodeData['level'] ?? 0;
final parentIdIndex = nodeData['parentId'] as int?;
// Находим реальный parentId по индексу
String? actualParentId;
if (parentIdIndex != null && parentIdIndex < newNodes.length) {
actualParentId = newNodes[parentIdIndex]['id'] as String;
}
newNodes.add({
'id': const Uuid().v4(),
'title': nodeData['title'] ?? '',
'level': level,
'parentId': actualParentId,
'color': _getColorForLevel(level),
});
}
// Добавляем новые узлы к существующим
setState(() {
_nodes.addAll(newNodes);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('✅ Добавлено ${newNodes.length} узлов'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
);
}
} finally {
setState(() => _isGeneratingAI = false);
}
}
String _getColorForLevel(int level) {
final colors = [
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4'
];
return colors[level % colors.length];
}
@override
Widget build(BuildContext context) {
final isAdmin = ref.watch(isAdminProvider);
return Scaffold(
appBar: AppBar(
title: Text(widget.mindMap == null ? 'Новая Mind Map' : 'Редактировать'),
actions: [
// Переключатель Общая/Личная (только для админов)
if (isAdmin)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person, color: _isMain ? Colors.grey : Colors.white),
Switch(
value: _isMain,
onChanged: (value) => setState(() => _isMain = value),
),
Icon(Icons.star, color: _isMain ? Colors.amber : Colors.grey),
],
),
),
// AI Генерация
IconButton(
icon: _isGeneratingAI
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
onPressed: _isGeneratingAI ? null : _generateWithAI,
tooltip: 'AI Дополнение',
),
// Сохранить
IconButton(
icon: _isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
onPressed: _isSaving ? null : _save,
tooltip: 'Сохранить',
),
],
),
body: Column(
children: [
// Заголовок и описание
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Название',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.title),
),
),
const SizedBox(height: 16),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Описание',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 2,
),
if (isAdmin)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(
_isMain ? Icons.star : Icons.person,
color: _isMain ? Colors.amber : Colors.blue,
),
const SizedBox(width: 8),
Text(
_isMain ? 'Основная (видна всем)' : 'Личная (только вам)',
style: TextStyle(
color: _isMain ? Colors.amber : Colors.blue,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
const Divider(),
// Список узлов
Expanded(
child: _nodes.isEmpty
? _buildEmptyNodesState()
: _buildNodesTree(),
),
// Кнопки добавления
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _addNode(),
icon: const Icon(Icons.add),
label: const Text('Корневой узел'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton.icon(
onPressed: _isGeneratingAI ? null : _generateWithAI,
icon: const Icon(Icons.auto_awesome),
label: const Text('AI Дополнить'),
),
),
],
),
),
],
),
);
}
Widget _buildEmptyNodesState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_tree, size: 80, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Нет узлов',
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Добавьте узлы вручную или с помощью AI',
style: TextStyle(color: Colors.grey[500]),
),
],
),
);
}
Widget _buildNodesTree() {
// Находим корневые узлы (без parentId)
final rootNodes = _nodes.where((n) => n['parentId'] == null).toList();
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: rootNodes.length,
itemBuilder: (context, index) {
return _buildNodeWidget(rootNodes[index]);
},
);
}
Widget _buildNodeWidget(Map<String, dynamic> node) {
final nodeId = node['id'] as String;
final title = node['title'] as String;
final level = node['level'] as int;
final color = _hexToColor(node['color'] as String? ?? '#2196F3');
final children = _nodes.where((n) => n['parentId'] == nodeId).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: EdgeInsets.only(left: level * 24.0),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border(left: BorderSide(color: color, width: 4)),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: color,
child: Text(
'${level + 1}',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('Уровень $level'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add_circle, color: Colors.green),
onPressed: () => _addChildNode(nodeId),
tooltip: 'Добавить дочерний',
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _confirmDeleteNode(nodeId, title),
tooltip: 'Удалить',
),
],
),
),
),
// Рекурсивно строим дочерние узлы
...children.map((child) => _buildNodeWidget(child)),
],
);
}
void _confirmDeleteNode(String nodeId, String title) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить узел?'),
content: Text('"$title" и все дочерние узлы будут удалены'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
_deleteNode(nodeId);
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('FF$hex', radix: 16));
}
return const Color(0xFF2196F3);
}
}