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:
2026-04-19 22:18:25 +05:00
parent 52aea9ac87
commit f1e0f6b5ef
56 changed files with 8892 additions and 397 deletions
+278 -270
View File
@@ -1,57 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/mindmap_provider.dart';
import '../models/mind_map_model.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../providers/mind_maps_provider.dart';
import '../models/mindmap_model.dart';
import '../core/admin_access_service.dart';
import 'mind_map_viewer_screen.dart';
import 'mind_map_editor_screen.dart';
import 'mind_map_interactive_screen.dart';
class MindMapScreen extends ConsumerStatefulWidget {
class MindMapScreen extends ConsumerWidget {
const MindMapScreen({super.key});
@override
ConsumerState<MindMapScreen> createState() => _MindMapScreenState();
}
class _MindMapScreenState extends ConsumerState<MindMapScreen> {
bool _isEditing = false;
bool _isGenerating = false;
@override
Widget build(BuildContext context) {
final projectId = 'default'; // TODO: Получить текущий проект
final mindMapState = ref.watch(mindMapProvider(projectId));
Widget build(BuildContext context, WidgetRef ref) {
final mindMapsAsync = ref.watch(mindMapsProvider);
final isAdmin = ref.watch(isAdminProvider);
final currentUserEmail = FirebaseAuth.instance.currentUser?.email ?? '';
return Scaffold(
appBar: AppBar(
title: const Text('Mind-карта'),
title: const Text('Mind Maps'),
actions: [
IconButton(
icon: Icon(_isEditing ? Icons.check : Icons.edit),
onPressed: () {
setState(() {
_isEditing = !_isEditing;
});
},
tooltip: _isEditing ? 'Готово' : 'Редактировать',
),
IconButton(
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: 'Добавить узел',
),
if (isAdmin)
IconButton(
icon: const Icon(Icons.admin_panel_settings),
onPressed: () => _showAdminOptions(context),
tooltip: 'Админ панель',
),
],
),
body: mindMapState.when(
data: (mindMapData) {
if (mindMapData.nodes.isEmpty) {
return _buildEmptyState(context, projectId);
}
return _buildSimpleMindMap(mindMapData);
},
body: mindMapsAsync.when(
data: (mindMaps) =>
_buildContent(context, mindMaps, isAdmin, currentUserEmail, ref),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
@@ -62,121 +42,185 @@ class _MindMapScreenState extends ConsumerState<MindMapScreen> {
Text('Ошибка: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(mindMapProvider(projectId)),
onPressed: () => ref.invalidate(mindMapsProvider),
child: const Text('Повторить'),
),
],
),
),
),
floatingActionButton: _isEditing
? FloatingActionButton.extended(
onPressed: () => _showAddNodeDialog(context, projectId, null),
icon: const Icon(Icons.add),
label: const Text('Добавить узел'),
)
: null,
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showCreateOptions(context, ref),
icon: const Icon(Icons.add),
label: const Text('Создать'),
),
);
}
// Простое отображение дерева (замена GraphView)
Widget _buildSimpleMindMap(MindMapData mindMapData) {
return ListView.builder(
Widget _buildContent(
BuildContext context,
List<MindMapModel> mindMaps,
bool isAdmin,
String currentUserEmail,
WidgetRef ref,
) {
// Разделяем на основные и личные
final mainMaps = mindMaps.where((m) => m.isMain).toList();
final personalMaps = mindMaps.where((m) => !m.isMain).toList();
if (mindMaps.isEmpty) {
return _buildEmptyState(context, ref);
}
return ListView(
padding: const EdgeInsets.all(16),
itemCount: mindMapData.nodes.length,
itemBuilder: (context, index) {
final node = mindMapData.nodes[index];
return _buildTreeNodeWidget(node, mindMapData);
},
);
}
Widget _buildTreeNodeWidget(MindMapNode node, MindMapData mindMapData) {
final children = mindMapData.getChildren(node.id);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
margin: EdgeInsets.only(left: node.level * 20.0),
decoration: BoxDecoration(
color: _hexToColor(node.color),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
_getIconData(node.icon),
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
node.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
if (_isEditing)
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
onPressed: () => _confirmDeleteNode(node),
),
],
),
),
if (children.isNotEmpty) ...[
const SizedBox(height: 8),
...children.map((child) => _buildTreeNodeWidget(child, mindMapData)),
// Основные карты
if (mainMaps.isNotEmpty) ...[
_buildSectionHeader('Основные', Icons.star, Colors.amber),
...mainMaps.map((map) =>
_buildMindMapCard(context, map, isAdmin, currentUserEmail, ref)),
const SizedBox(height: 16),
],
// Личные карты
if (personalMaps.isNotEmpty) ...[
_buildSectionHeader('Личные', Icons.person, Colors.blue),
...personalMaps.map((map) =>
_buildMindMapCard(context, map, isAdmin, currentUserEmail, ref)),
],
],
);
}
Widget _buildEmptyState(BuildContext context, String projectId) {
Widget _buildSectionHeader(String title, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildMindMapCard(
BuildContext context,
MindMapModel mindMap,
bool isAdmin,
String currentUserEmail,
WidgetRef ref,
) {
final isOwner = mindMap.ownerEmail == currentUserEmail;
final canDelete = isAdmin || isOwner;
final canEdit = isAdmin || isOwner;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: mindMap.isMain ? Colors.amber : Colors.blue,
child: Icon(
mindMap.isMain ? Icons.star : Icons.account_tree,
color: Colors.white,
),
),
title: Text(
mindMap.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (mindMap.description.isNotEmpty)
Text(mindMap.description,
maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text('Владелец: ${mindMap.ownerEmail}'),
Text(
'${mindMap.nodeCount} узлов • ${_formatDate(mindMap.createdAt)}'),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) => _handleAction(context, value, mindMap, ref),
itemBuilder: (context) => [
PopupMenuItem(
value: 'view',
child: Row(
children: [
const Icon(Icons.visibility, size: 20),
const SizedBox(width: 12),
const Text('Открыть'),
],
),
),
if (canEdit)
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 20),
const SizedBox(width: 12),
const Text('Редактировать'),
],
),
),
if (canDelete)
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red, size: 20),
const SizedBox(width: 12),
const Text('Удалить', style: TextStyle(color: Colors.red)),
],
),
),
],
),
onTap: () => _openMindMap(context, mindMap),
),
);
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.account_tree,
size: 100,
color: Colors.grey,
),
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
const SizedBox(height: 24),
const Text(
'Mind-карта пуста',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
Text(
'Нет mind карт',
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
),
const SizedBox(height: 8),
const Text(
'Создайте структуру проекта',
style: TextStyle(color: Colors.grey),
Text(
'Создайте первую карту вручную или с помощью AI',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[500]),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () => _showAddNodeDialog(context, projectId, null),
onPressed: () => _showCreateManualDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('Добавить вручную'),
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),
FilledButton.icon(
onPressed: () => _showCreateAIDialog(context, ref),
icon: const Icon(Icons.auto_awesome),
label: const Text('AI Генерация'),
),
],
@@ -186,159 +230,133 @@ class _MindMapScreenState extends ConsumerState<MindMapScreen> {
);
}
Future<void> _generateWithAI(String projectId) async {
setState(() {
_isGenerating = true;
});
void _showCreateOptions(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add, color: Colors.blue),
title: const Text('Создать вручную'),
subtitle: const Text('Пустая mind map'),
onTap: () {
Navigator.pop(context);
_showCreateManualDialog(context, ref);
},
),
ListTile(
leading: const Icon(Icons.auto_awesome, color: Colors.purple),
title: const Text('AI Генерация'),
subtitle: const Text('С помощью DeepSeek'),
onTap: () {
Navigator.pop(context);
_showCreateAIDialog(context, ref);
},
),
],
),
),
);
}
try {
final projectName = await _showProjectNameDialog();
if (projectName == null || projectName.isEmpty) return;
void _showCreateManualDialog(BuildContext context, WidgetRef ref) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const MindMapEditorScreen(),
),
);
}
final projectDescription = await _showProjectDescriptionDialog();
void _showCreateAIDialog(BuildContext context, WidgetRef ref) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const MindMapEditorScreen(),
),
);
}
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;
});
void _handleAction(
BuildContext context,
String action,
MindMapModel mindMap,
WidgetRef ref,
) {
switch (action) {
case 'view':
_openMindMap(context, mindMap);
break;
case 'edit':
_editMindMap(context, mindMap, ref);
break;
case 'delete':
_confirmDelete(context, mindMap, ref);
break;
}
}
Future<String?> _showProjectNameDialog() {
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('Генерировать'),
),
],
void _openMindMap(BuildContext context, MindMapModel mindMap) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MindMapInteractiveScreen(mindMap: mindMap),
),
);
}
Future<String?> _showProjectDescriptionDialog() {
final controller = TextEditingController();
return showDialog<String>(
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 _editMindMap(BuildContext context, MindMapModel mindMap, WidgetRef ref) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MindMapEditorScreen(mindMap: mindMap),
),
);
}
void _showAddNodeDialog(
BuildContext context, String projectId, String? parentId) {
final titleController = TextEditingController();
void _confirmDelete(
BuildContext context, MindMapModel mindMap, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Добавить узел'),
content: TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Название',
border: OutlineInputBorder(),
),
autofocus: true,
),
title: const Text('Удалить mind map?'),
content: Text(
'"${mindMap.title}" будет удалена навсегда. Это действие нельзя отменить.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
ElevatedButton(
TextButton(
onPressed: () async {
if (titleController.text.isEmpty) return;
try {
await ref
.read(mindMapsNotifierProvider.notifier)
.deleteMindMap(mindMap.id, mindMap.ownerEmail);
await ref.read(mindMapProvider(projectId).notifier).addNode(
title: titleController.text,
parentId: parentId,
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✅ Mind map удалена'),
backgroundColor: Colors.green,
),
);
if (context.mounted) {
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('❌ Ошибка: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Добавить'),
),
],
),
);
}
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('Удалить'),
),
@@ -347,26 +365,16 @@ class _MindMapScreenState extends ConsumerState<MindMapScreen> {
);
}
Color _hexToColor(String hex) {
hex = hex.replaceAll('#', '');
if (hex.length == 6) {
return Color(int.parse('0xFF$hex'));
}
return Colors.blue;
void _showAdminOptions(BuildContext context) {
// TODO: Открыть админ панель
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Админ панель в разработке')),
);
}
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;
}
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}.'
'${date.month.toString().padLeft(2, '0')}.'
'${date.year}';
}
}