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,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AIStatsScreen extends ConsumerStatefulWidget {
|
||||
const AIStatsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AIStatsScreen> createState() => _AIStatsScreenState();
|
||||
}
|
||||
|
||||
class _AIStatsScreenState extends ConsumerState<AIStatsScreen> {
|
||||
int _totalRequests = 0;
|
||||
int _totalTokens = 0;
|
||||
int _mockRequests = 0;
|
||||
int _realRequests = 0;
|
||||
Map<String, int> _requestsByType = {};
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
Future<void> _loadStats() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_totalRequests = prefs.getInt('ai_total_requests') ?? 0;
|
||||
_totalTokens = prefs.getInt('ai_total_tokens') ?? 0;
|
||||
_mockRequests = prefs.getInt('ai_mock_requests') ?? 0;
|
||||
_realRequests = prefs.getInt('ai_real_requests') ?? 0;
|
||||
_requestsByType = {
|
||||
'Генерация подзадач': prefs.getInt('ai_subtasks') ?? 0,
|
||||
'Приоритизация': prefs.getInt('ai_priority') ?? 0,
|
||||
'Описания': prefs.getInt('ai_descriptions') ?? 0,
|
||||
'Mind-карты': prefs.getInt('ai_mindmaps') ?? 0,
|
||||
'Планирование': prefs.getInt('ai_scheduling') ?? 0,
|
||||
};
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetStats() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Сбросить статистику?'),
|
||||
content: const Text('Все данные об использовании AI будут удалены'),
|
||||
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) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('ai_total_requests');
|
||||
await prefs.remove('ai_total_tokens');
|
||||
await prefs.remove('ai_mock_requests');
|
||||
await prefs.remove('ai_real_requests');
|
||||
await prefs.remove('ai_subtasks');
|
||||
await prefs.remove('ai_priority');
|
||||
await prefs.remove('ai_descriptions');
|
||||
await prefs.remove('ai_mindmaps');
|
||||
await prefs.remove('ai_scheduling');
|
||||
|
||||
await _loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('AI Статистика'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadStats,
|
||||
tooltip: 'Обновить',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
onPressed: _resetStats,
|
||||
tooltip: 'Сбросить',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Общая статистика
|
||||
_buildStatCard(
|
||||
'Общая статистика',
|
||||
[
|
||||
_buildBigStat('Всего запросов', _totalRequests.toString()),
|
||||
const Divider(),
|
||||
_buildStatRow('Mock запросов', _mockRequests.toString()),
|
||||
_buildStatRow('Реальных запросов', _realRequests.toString()),
|
||||
_buildStatRow('Использовано токенов', _totalTokens.toString()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Запросы по типам
|
||||
_buildStatCard(
|
||||
'Запросы по типам',
|
||||
_requestsByType.entries
|
||||
.map((e) => _buildStatRow(e.key, e.value.toString()))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// График использования (простой)
|
||||
_buildStatCard(
|
||||
'Использование',
|
||||
[
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: _requestsByType.entries.map((entry) {
|
||||
final max = _requestsByType.values.fold(
|
||||
1, (prev, element) => prev > element ? prev : element);
|
||||
final height = (entry.value / max) * 100;
|
||||
return Column(
|
||||
children: [
|
||||
Text(entry.value.toString()),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 30,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.blue, Colors.blueAccent],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
entry.key.split(' ')[0],
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Информация
|
||||
_buildInfoCard(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String title, List<Widget> children) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBigStat(String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Информация',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'• Сейчас работает MOCK режим\n'
|
||||
'• Реальные запросы к DeepSeek отключены\n'
|
||||
'• Токены не расходуются\n\n'
|
||||
'Для включения реального AI:\n'
|
||||
'1. Пополните баланс DeepSeek\n'
|
||||
'2. Установите useMockMode = false\n'
|
||||
'3. Перезапустите приложение',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
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}';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
Reference in New Issue
Block a user