f1e0f6b5ef
- 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
516 lines
16 KiB
Dart
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);
|
|
}
|
|
}
|