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
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user