Files
Avtex/lib/screens/admin/mind_map_admin_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

437 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:convert';
class MindMapAdminScreen extends ConsumerStatefulWidget {
const MindMapAdminScreen({super.key});
@override
ConsumerState<MindMapAdminScreen> createState() => _MindMapAdminScreenState();
}
class _MindMapAdminScreenState extends ConsumerState<MindMapAdminScreen> {
String _searchQuery = '';
String? _filterProject;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Управление Mind Maps'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => setState(() {}),
tooltip: 'Обновить',
),
],
),
body: Column(
children: [
_buildSearchBar(),
_buildFilterBar(),
Expanded(child: _buildMindMapsList()),
],
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: 'Поиск mind-карт...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => setState(() => _searchQuery = ''),
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (value) => setState(() => _searchQuery = value),
),
);
}
Widget _buildFilterBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.filter_list, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: const Text('Все проекты'),
selected: _filterProject == null,
onSelected: (selected) =>
setState(() => _filterProject = null),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Проект A'),
selected: _filterProject == 'project_a',
onSelected: (selected) =>
setState(() => _filterProject = 'project_a'),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Проект B'),
selected: _filterProject == 'project_b',
onSelected: (selected) =>
setState(() => _filterProject = 'project_b'),
),
],
),
),
),
],
),
);
}
Widget _buildMindMapsList() {
return StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('mind_map_nodes')
.orderBy('createdAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.hasError) {
print('Firestore error: ${snapshot.error}');
return _buildErrorState(snapshot.error);
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final allDocs = snapshot.data?.docs ?? [];
print('Total docs from Firestore: ${allDocs.length}');
// Преобразуем в Map для безопасной работы
final mindMaps = <Map<String, dynamic>>[];
for (final doc in allDocs) {
try {
final data = doc.data() as Map<String, dynamic>;
data['_id'] = doc.id;
mindMaps.add(data);
} catch (e) {
print('Error converting doc: $e');
}
}
// Фильтруем по проекту
final filtered = mindMaps.where((data) {
final projectId = data['projectId'] ?? 'unknown';
final title = data['title']?.toString().toLowerCase() ?? '';
final matchesProject =
_filterProject == null || projectId == _filterProject;
final matchesSearch = title.contains(_searchQuery.toLowerCase());
return matchesProject && matchesSearch;
}).toList();
if (filtered.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filtered.length,
itemBuilder: (context, index) {
final data = filtered[index];
final id = data['_id'] as String;
return _buildMindMapCard(id, data);
},
);
},
);
}
Widget _buildMindMapCard(String id, Map<String, dynamic> data) {
final title = data['title'] ?? 'Без названия';
final projectId = data['projectId'] ?? 'unknown';
final createdAt = data['createdAt'] != null
? (data['createdAt'] as Timestamp).toDate()
: DateTime.now();
final nodeCount = data['nodeCount'] ?? 0;
final maxLevel = data['maxLevel'] ?? 0;
final createdBy = data['createdBy'] ?? 'unknown';
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ExpansionTile(
leading: const Icon(Icons.account_tree, color: Colors.blue),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Проект: $projectId'),
Text('Создана: ${_formatDate(createdAt)}'),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) => _handleAction(id, value, data),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'view',
child: Row(
children: [
Icon(Icons.visibility, size: 20),
SizedBox(width: 12),
Text('Просмотр'),
],
),
),
const PopupMenuItem(
value: 'export',
child: Row(
children: [
Icon(Icons.download, size: 20),
SizedBox(width: 12),
Text('Экспорт'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red, size: 20),
SizedBox(width: 12),
Text('Удалить', style: TextStyle(color: Colors.red)),
],
),
),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatRow('Узлов', nodeCount.toString()),
_buildStatRow('Уровней', maxLevel.toString()),
_buildStatRow('Автор', createdBy),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _handleAction(id, 'export', data),
icon: const Icon(Icons.download, size: 18),
label: const Text('Экспорт JSON'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _handleAction(id, 'delete', data),
icon: const Icon(Icons.delete, size: 18),
label: const Text('Удалить'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
],
),
);
}
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600])),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_tree, size: 80, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Mind-карты не найдены',
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Создайте первую mind-карту',
style: TextStyle(color: Colors.grey[500]),
),
],
),
);
}
Widget _buildErrorState(dynamic error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Ошибка: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() {}),
child: const Text('Повторить'),
),
],
),
);
}
Future<void> _handleAction(
String id, String action, Map<String, dynamic> data) async {
switch (action) {
case 'view':
// TODO: Открыть mind map
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Просмотр в разработке')),
);
break;
case 'export':
await _exportMindMap(id, data);
break;
case 'delete':
await _confirmDelete(id, data['title'] ?? 'Без названия');
break;
}
}
Future<void> _exportMindMap(String id, Map<String, dynamic> data) async {
try {
final jsonData = {
'id': id,
'title': data['title'],
'projectId': data['projectId'],
'createdAt': data['createdAt']?.toString(),
'data': data,
};
final jsonStr = const JsonEncoder.withIndent(' ').convert(jsonData);
// TODO: Сохранить в файл
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('📤 Экспорт: скопируйте JSON из консоли'),
backgroundColor: Colors.green,
),
);
print('=== EXPORT JSON ===');
print(jsonStr);
print('=== END EXPORT ===');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('❌ Ошибка экспорта: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _confirmDelete(String id, String title) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить mind-карту?'),
content: Text(
'"$title" будет удалена навсегда. Это действие нельзя отменить.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Удалить'),
),
],
),
);
if (confirmed == true) {
await _deleteMindMap(id);
}
}
Future<void> _deleteMindMap(String id) async {
try {
await FirebaseFirestore.instance
.collection('mind_map_nodes')
.doc(id)
.delete();
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,
),
);
}
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) return 'только что';
if (diff.inMinutes < 60) return '${diff.inMinutes} мин. назад';
if (diff.inHours < 24) return '${diff.inHours} ч. назад';
if (diff.inDays < 7) return '${diff.inDays} дн. назад';
return '${date.day}.${date.month}.${date.year}';
}
}