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,50 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
/// Сервис для проверки прав доступа администраторов
|
||||
class AdminAccessService {
|
||||
// Список email'ов администраторов
|
||||
static const List<String> adminEmails = [
|
||||
'ormix16@gmail.com',
|
||||
'almalexyz@gmail.com',
|
||||
];
|
||||
|
||||
/// Проверка является ли пользователь администратором
|
||||
static Future<bool> isAdmin(String userEmail) async {
|
||||
return adminEmails.contains(userEmail.toLowerCase());
|
||||
}
|
||||
|
||||
/// Получить список всех администраторов
|
||||
static List<String> getAdminEmails() {
|
||||
return List.from(adminEmails);
|
||||
}
|
||||
|
||||
/// Добавить администратора (только для Firestore)
|
||||
static Future<void> addAdmin(String email) async {
|
||||
if (!adminEmails.contains(email.toLowerCase())) {
|
||||
await FirebaseFirestore.instance
|
||||
.collection('admin_access')
|
||||
.doc(email.toLowerCase())
|
||||
.set({
|
||||
'email': email.toLowerCase(),
|
||||
'addedAt': Timestamp.now(),
|
||||
'role': 'admin',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Удалить администратора
|
||||
static Future<void> removeAdmin(String email) async {
|
||||
await FirebaseFirestore.instance
|
||||
.collection('admin_access')
|
||||
.doc(email.toLowerCase())
|
||||
.delete();
|
||||
}
|
||||
|
||||
/// Получить список администраторов из Firestore
|
||||
static Future<List<String>> getAdminsFromFirestore() async {
|
||||
final snapshot =
|
||||
await FirebaseFirestore.instance.collection('admin_access').get();
|
||||
|
||||
return snapshot.docs.map((doc) => doc.id).toList();
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,95 @@ class DeepSeekService {
|
||||
final String? apiKey;
|
||||
final String baseUrl = 'https://api.deepseek.com';
|
||||
|
||||
// 🎯 РЕЖИМ MOCK - установите true для работы без API запросов
|
||||
// Когда пополните баланс DeepSeek, установите false
|
||||
final bool useMockMode = true;
|
||||
|
||||
DeepSeekService({this.apiKey});
|
||||
|
||||
// AI чат для mind maps
|
||||
Future<Map<String, dynamic>> chatCompletion({
|
||||
required List<Map<String, dynamic>> messages,
|
||||
}) async {
|
||||
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||
return _mockChatResponse(messages);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $apiKey',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': 'deepseek-chat',
|
||||
'messages': messages,
|
||||
'temperature': 0.7,
|
||||
'max_tokens': 2000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final content = data['choices'][0]['message']['content'];
|
||||
|
||||
// Пытаемся распарсить как JSON
|
||||
final jsonMatch = RegExp(r'\{.*\}', dotAll: true).firstMatch(content);
|
||||
if (jsonMatch != null) {
|
||||
return jsonDecode(jsonMatch.group(0)!) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
return {'text': content};
|
||||
} else {
|
||||
print('DeepSeek API Error: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DeepSeek Service Error: $e');
|
||||
}
|
||||
|
||||
return _mockChatResponse(messages);
|
||||
}
|
||||
|
||||
// Mock ответ для чата
|
||||
Map<String, dynamic> _mockChatResponse(List<Map<String, dynamic>> messages) {
|
||||
final lastMessage = messages.lastWhere(
|
||||
(m) => m['role'] == 'user',
|
||||
orElse: () => {'content': ''},
|
||||
);
|
||||
final content = lastMessage['content'] as String;
|
||||
|
||||
// Простая логика для демонстрации
|
||||
if (content.toLowerCase().contains('добавь') ||
|
||||
content.toLowerCase().contains('add')) {
|
||||
return {
|
||||
'ACTION': 'add',
|
||||
'TARGET': null,
|
||||
'CHANGES': [
|
||||
{
|
||||
'action': 'add',
|
||||
'title': 'AI Предложение: $content',
|
||||
'level': 0,
|
||||
'parentId': null
|
||||
},
|
||||
],
|
||||
'EXPLANATION': 'Я добавил новый узел на основе вашего запроса',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'text':
|
||||
'Понял ваш запрос: "$content". В реальном режиме я предложу изменения для mind map.',
|
||||
};
|
||||
}
|
||||
|
||||
// Генерация подзадач из основной задачи
|
||||
Future<List<Map<String, dynamic>>> generateSubtasks({
|
||||
required String title,
|
||||
required String description,
|
||||
}) async {
|
||||
if (apiKey == null || apiKey!.isEmpty) {
|
||||
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||
return _mockGenerateSubtasks(title, description);
|
||||
}
|
||||
|
||||
@@ -56,9 +137,11 @@ class DeepSeekService {
|
||||
}
|
||||
} else {
|
||||
print('DeepSeek API Error: ${response.statusCode} - ${response.body}');
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DeepSeek Service Error: $e');
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
|
||||
return _mockGenerateSubtasks(title, description);
|
||||
@@ -70,7 +153,8 @@ class DeepSeekService {
|
||||
required String description,
|
||||
DateTime? dueDate,
|
||||
}) async {
|
||||
if (apiKey == null || apiKey!.isEmpty) {
|
||||
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||
return _mockPrioritizeTask(dueDate);
|
||||
}
|
||||
|
||||
@@ -115,9 +199,12 @@ class DeepSeekService {
|
||||
default:
|
||||
return TaskPriority.medium;
|
||||
}
|
||||
} else {
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DeepSeek Service Error: $e');
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
|
||||
return _mockPrioritizeTask(dueDate);
|
||||
@@ -128,8 +215,9 @@ class DeepSeekService {
|
||||
required String title,
|
||||
String? context,
|
||||
}) async {
|
||||
if (apiKey == null || apiKey!.isEmpty) {
|
||||
return 'Описание для задачи: $title';
|
||||
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||
return _mockGenerateDescription(title, context);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -161,12 +249,15 @@ class DeepSeekService {
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['choices'][0]['message']['content'];
|
||||
} else {
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DeepSeek Service Error: $e');
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
|
||||
return 'Описание для задачи: $title';
|
||||
return _mockGenerateDescription(title, context);
|
||||
}
|
||||
|
||||
// Умное планирование - распределение задач по времени
|
||||
@@ -176,7 +267,8 @@ class DeepSeekService {
|
||||
required DateTime endDate,
|
||||
required int availableHoursPerDay,
|
||||
}) async {
|
||||
if (apiKey == null || apiKey!.isEmpty) {
|
||||
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||
return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay);
|
||||
}
|
||||
|
||||
@@ -238,9 +330,12 @@ Return JSON array: [{"taskId": index, "date": "YYYY-MM-DD", "hours": 0}]
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
} else {
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DeepSeek Service Error: $e');
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
|
||||
return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay);
|
||||
@@ -251,7 +346,8 @@ Return JSON array: [{"taskId": index, "date": "YYYY-MM-DD", "hours": 0}]
|
||||
required String projectName,
|
||||
required String description,
|
||||
}) async {
|
||||
if (apiKey == null || apiKey!.isEmpty) {
|
||||
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||
return _mockMindMap(projectName);
|
||||
}
|
||||
|
||||
@@ -297,9 +393,12 @@ Return JSON array of nodes with format:
|
||||
final List<dynamic> nodes = jsonDecode(jsonMatch.group(0)!);
|
||||
return nodes.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} else {
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
} catch (e) {
|
||||
print('DeepSeek Service Error: $e');
|
||||
print('⚠️ Используется mock режим как fallback');
|
||||
}
|
||||
|
||||
return _mockMindMap(projectName);
|
||||
@@ -348,6 +447,24 @@ Return JSON array of nodes with format:
|
||||
return TaskPriority.low;
|
||||
}
|
||||
|
||||
String _mockGenerateDescription(String title, String? context) {
|
||||
final descriptions = {
|
||||
'Анализ требований':
|
||||
'Изучить и проанализировать все требования проекта. Подготовить документацию с детальным описанием функционала и ожиданий пользователей.',
|
||||
'Проектирование':
|
||||
'Разработать архитектуру решения, создать схемы базы данных и определить основные компоненты системы.',
|
||||
'Реализация':
|
||||
'Написать чистый, поддерживаемый код согласно лучшим практикам. Реализовать весь запланированный функционал.',
|
||||
'Тестирование':
|
||||
'Провести комплексное тестирование: модульные, интеграционные и end-to-end тесты для обеспечения качества.',
|
||||
'Деплой':
|
||||
'Подготовить инфраструктуру, настроить CI/CD пайплайн и развернуть приложение на production сервере.',
|
||||
};
|
||||
|
||||
return descriptions[title] ??
|
||||
'Задача: $title. ${context ?? "Выполнить в срок с надлежащим качеством."}';
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _mockSchedule(
|
||||
List<TaskModel> tasks,
|
||||
DateTime startDate,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../models/task_model.dart';
|
||||
import '../models/alarm_model.dart';
|
||||
import '../models/mind_map_model.dart';
|
||||
@@ -29,20 +30,23 @@ class LocalStorageService {
|
||||
|
||||
// Tasks
|
||||
Future<void> saveTask(TaskModel task) async {
|
||||
await _tasksBox.put(task.id, task.toMap()); // Используем toMap() для Hive
|
||||
final data = _convertTimestamps(task.toFirestore());
|
||||
data['id'] = task.id; // Добавляем id для fromMap
|
||||
await _tasksBox.put(task.id, data);
|
||||
}
|
||||
|
||||
Future<void> saveTasks(List<TaskModel> tasks) async {
|
||||
for (final task in tasks) {
|
||||
await _tasksBox.put(task.id, task.toMap());
|
||||
final data = _convertTimestamps(task.toFirestore());
|
||||
data['id'] = task.id;
|
||||
await _tasksBox.put(task.id, data);
|
||||
}
|
||||
}
|
||||
|
||||
List<TaskModel> getTasks() {
|
||||
return _tasksBox.values.map((data) {
|
||||
return TaskModel.fromFirestore(
|
||||
_createMockDocument(data as Map<String, dynamic>),
|
||||
);
|
||||
final restoredData = _restoreTimestamps(data);
|
||||
return TaskModel.fromMap(restoredData);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -52,14 +56,14 @@ class LocalStorageService {
|
||||
|
||||
// Alarms
|
||||
Future<void> saveAlarm(AlarmModel alarm) async {
|
||||
await _alarmsBox.put(alarm.id, alarm.toFirestore());
|
||||
await _alarmsBox.put(alarm.id, _convertTimestamps(alarm.toFirestore()));
|
||||
}
|
||||
|
||||
List<AlarmModel> getAlarms() {
|
||||
return _alarmsBox.values.map((data) {
|
||||
return AlarmModel.fromFirestore(
|
||||
_createMockDocument(data as Map<String, dynamic>),
|
||||
);
|
||||
return _alarmsBox.keys.map((id) {
|
||||
final data = _alarmsBox.get(id) as Map<dynamic, dynamic>;
|
||||
final restoredData = _restoreTimestamps(data);
|
||||
return AlarmModel.fromMap(id.toString(), restoredData);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -70,28 +74,28 @@ class LocalStorageService {
|
||||
// Mind Maps
|
||||
Future<void> saveMindMapNodes(List<MindMapNode> nodes) async {
|
||||
for (final node in nodes) {
|
||||
await _mindMapsBox.put(node.id, node.toFirestore());
|
||||
await _mindMapsBox.put(node.id, _convertTimestamps(node.toFirestore()));
|
||||
}
|
||||
}
|
||||
|
||||
List<MindMapNode> getMindMapNodes() {
|
||||
return _mindMapsBox.values.map((data) {
|
||||
return MindMapNode.fromFirestore(
|
||||
_createMockDocument(data as Map<String, dynamic>),
|
||||
);
|
||||
return _mindMapsBox.keys.map((id) {
|
||||
final data = _mindMapsBox.get(id) as Map<dynamic, dynamic>;
|
||||
final restoredData = _restoreTimestamps(data);
|
||||
return MindMapNode.fromMap(id.toString(), restoredData);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Events
|
||||
Future<void> saveEvent(CalendarEventModel event) async {
|
||||
await _eventsBox.put(event.id, event.toFirestore());
|
||||
await _eventsBox.put(event.id, _convertTimestamps(event.toFirestore()));
|
||||
}
|
||||
|
||||
List<CalendarEventModel> getEvents() {
|
||||
return _eventsBox.values.map((data) {
|
||||
return CalendarEventModel.fromFirestore(
|
||||
_createMockDocument(data as Map<String, dynamic>),
|
||||
);
|
||||
return _eventsBox.keys.map((id) {
|
||||
final data = _eventsBox.get(id) as Map<dynamic, dynamic>;
|
||||
final restoredData = _restoreTimestamps(data);
|
||||
return CalendarEventModel.fromMap(id.toString(), restoredData);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -124,14 +128,49 @@ class LocalStorageService {
|
||||
|
||||
await _tasksBox.clear();
|
||||
for (final task in merged.values) {
|
||||
await _tasksBox.put(task.id, task.toFirestore());
|
||||
await _tasksBox.put(task.id, _convertTimestamps(task.toFirestore()));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to create mock DocumentSnapshot
|
||||
dynamic _createMockDocument(Map<String, dynamic> data) {
|
||||
// Простая эмуляция для конвертации
|
||||
return data;
|
||||
// Конвертирует Firestore Timestamp в ISO строки для Hive
|
||||
Map<String, dynamic> _convertTimestamps(Map<String, dynamic> data) {
|
||||
final converted = <String, dynamic>{};
|
||||
for (final entry in data.entries) {
|
||||
if (entry.value is Timestamp) {
|
||||
// Конвертируем Timestamp → ISO строка
|
||||
converted[entry.key] = {
|
||||
'__type__': 'timestamp',
|
||||
'value': (entry.value as Timestamp).toDate().toIso8601String(),
|
||||
};
|
||||
} else if (entry.value is Map<String, dynamic>) {
|
||||
converted[entry.key] =
|
||||
_convertTimestamps(entry.value as Map<String, dynamic>);
|
||||
} else {
|
||||
converted[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
// Восстанавливает ISO строки обратно в Timestamp из Hive
|
||||
Map<String, dynamic> _restoreTimestamps(Map<dynamic, dynamic> data) {
|
||||
final converted = <String, dynamic>{};
|
||||
for (final entry in data.entries) {
|
||||
final key = entry.key.toString();
|
||||
if (entry.value is Map &&
|
||||
(entry.value as Map).containsKey('__type__') &&
|
||||
(entry.value as Map)['__type__'] == 'timestamp') {
|
||||
// Конвертируем ISO строку → Timestamp
|
||||
final dateStr = (entry.value as Map)['value'] as String;
|
||||
converted[key] = Timestamp.fromDate(DateTime.parse(dateStr));
|
||||
} else if (entry.value is Map) {
|
||||
converted[key] =
|
||||
_restoreTimestamps(entry.value as Map<dynamic, dynamic>);
|
||||
} else {
|
||||
converted[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||
import '../models/mind_map_model.dart';
|
||||
|
||||
/// Адаптер для конвертации MindMapNode в формат reactive_mind_map
|
||||
class MindMapAdapter {
|
||||
/// Конвертирует список MindMapNode в rm.MindMapData для reactive_mind_map
|
||||
static rm.MindMapData nodesToMindMapData(List<MindMapNode> nodes) {
|
||||
if (nodes.isEmpty) {
|
||||
return rm.MindMapData(
|
||||
id: 'empty',
|
||||
title: 'Пустая карта',
|
||||
children: [],
|
||||
);
|
||||
}
|
||||
|
||||
// Находим корневой узел (level 0)
|
||||
final rootNodes = nodes.where((n) => n.level == 0).toList();
|
||||
|
||||
if (rootNodes.isEmpty) {
|
||||
// Если нет корневого узла, создаём его
|
||||
return rm.MindMapData(
|
||||
id: 'auto_root',
|
||||
title: 'Автоматический корень',
|
||||
children: _buildChildren(nodes, null, nodes),
|
||||
);
|
||||
}
|
||||
|
||||
final root = rootNodes.first;
|
||||
return rm.MindMapData(
|
||||
id: root.id,
|
||||
title: root.title,
|
||||
children: _buildChildren(nodes, root.id, nodes),
|
||||
);
|
||||
}
|
||||
|
||||
/// Рекурсивно строит дочерние узлы
|
||||
static List<rm.MindMapData> _buildChildren(
|
||||
List<MindMapNode> allNodes,
|
||||
String? parentId,
|
||||
List<MindMapNode> nodes,
|
||||
) {
|
||||
final children = allNodes.where((n) => n.parentId == parentId).toList();
|
||||
|
||||
return children.map((node) {
|
||||
return rm.MindMapData(
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
children: _buildChildren(allNodes, node.id, nodes),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Конвертирует rm.MindMapData обратно в список MindMapNode
|
||||
static List<MindMapNode> mindMapDataToNodes(
|
||||
rm.MindMapData data,
|
||||
String projectId, {
|
||||
String? parentId,
|
||||
int level = 0,
|
||||
}) {
|
||||
final nodes = <MindMapNode>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
nodes.add(MindMapNode(
|
||||
id: data.id,
|
||||
projectId: projectId,
|
||||
parentId: parentId,
|
||||
level: level,
|
||||
title: data.title,
|
||||
color: '#2196F3', // Default color, можно улучшить хранить цвет в metadata
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
));
|
||||
|
||||
// Рекурсивно добавляем детей
|
||||
for (final child in data.children!) {
|
||||
nodes.addAll(mindMapDataToNodes(
|
||||
child,
|
||||
projectId,
|
||||
parentId: data.id,
|
||||
level: level + 1,
|
||||
));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// HEX → int (для reactive_mind_map)
|
||||
static int _hexToColorInt(String hex) {
|
||||
hex = hex.replaceAll('#', '');
|
||||
if (hex.length == 6) {
|
||||
return int.parse('FF$hex', radix: 16);
|
||||
}
|
||||
return 0xFF2196F3; // Default blue
|
||||
}
|
||||
|
||||
/// int → HEX (для MindMapNode)
|
||||
static String _colorIntToHex(int color) {
|
||||
return '#${(color & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Сервис для синхронизации с Nanaxe Server
|
||||
/// Поддерживает работу с mindmaps через REST API
|
||||
class NanaxeServerService {
|
||||
static final NanaxeServerService _instance = NanaxeServerService._internal();
|
||||
factory NanaxeServerService() => _instance;
|
||||
NanaxeServerService._internal();
|
||||
|
||||
String? _baseUrl;
|
||||
String? _authToken;
|
||||
|
||||
static const String _prefsUrlKey = 'nanaxe_server_url';
|
||||
static const String _prefsTokenKey = 'nanaxe_server_token';
|
||||
|
||||
/// Инициализация сервиса
|
||||
Future<void> initialize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_baseUrl = prefs.getString(_prefsUrlKey);
|
||||
_authToken = prefs.getString(_prefsTokenKey);
|
||||
}
|
||||
|
||||
/// Настройка сервера
|
||||
Future<void> configure(String baseUrl, {String? token}) async {
|
||||
_baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''); // Remove trailing slashes
|
||||
_authToken = token;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsUrlKey, _baseUrl!);
|
||||
if (token != null) {
|
||||
await prefs.setString(_prefsTokenKey, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// Очистка конфигурации
|
||||
Future<void> clearConfiguration() async {
|
||||
_baseUrl = null;
|
||||
_authToken = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsUrlKey);
|
||||
await prefs.remove(_prefsTokenKey);
|
||||
}
|
||||
|
||||
/// Проверка, настроен ли сервер
|
||||
bool get isConfigured => _baseUrl != null;
|
||||
|
||||
/// Получение базового URL
|
||||
String? get baseUrl => _baseUrl;
|
||||
|
||||
// ==================== AUTH ====================
|
||||
|
||||
/// Регистрация нового пользователя
|
||||
Future<Map<String, dynamic>> register(String email, String password, String username) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/auth/register'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'email': email,
|
||||
'password': password,
|
||||
'username': username,
|
||||
}),
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
_authToken = data['token'];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsTokenKey, _authToken!);
|
||||
return data;
|
||||
} else {
|
||||
throw Exception(data['error'] ?? 'Registration failed');
|
||||
}
|
||||
}
|
||||
|
||||
/// Вход в систему
|
||||
Future<Map<String, dynamic>> login(String email, String password) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/auth/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'email': email,
|
||||
'password': password,
|
||||
}),
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_authToken = data['token'];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsTokenKey, _authToken!);
|
||||
return data;
|
||||
} else {
|
||||
throw Exception(data['error'] ?? 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверка валидности токена
|
||||
Future<bool> validateToken() async {
|
||||
if (_baseUrl == null || _authToken == null) return false;
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/auth/validate'),
|
||||
headers: _getHeaders(),
|
||||
);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
return data['valid'] == true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MINDMAPS ====================
|
||||
|
||||
/// Получение списка всех mindmaps
|
||||
Future<List<Map<String, dynamic>>> getMindMaps() async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/mindmaps'),
|
||||
headers: _getHeaders(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return List<Map<String, dynamic>>.from(data['mindmaps'] ?? []);
|
||||
} else {
|
||||
throw Exception('Failed to load mindmaps');
|
||||
}
|
||||
}
|
||||
|
||||
/// Получение одной mindmap
|
||||
Future<Map<String, dynamic>> getMindMap(String id) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/mindmaps/$id'),
|
||||
headers: _getHeaders(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to load mindmap');
|
||||
}
|
||||
}
|
||||
|
||||
/// Создание новой mindmap
|
||||
Future<Map<String, dynamic>> createMindMap({
|
||||
required String title,
|
||||
String? description,
|
||||
List<Map<String, dynamic>>? nodes,
|
||||
Map<String, dynamic>? settings,
|
||||
}) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/mindmaps'),
|
||||
headers: _getHeaders(),
|
||||
body: jsonEncode({
|
||||
'title': title,
|
||||
'description': description ?? '',
|
||||
'nodes': nodes ?? [],
|
||||
'settings': settings ?? {},
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
final data = jsonDecode(response.body);
|
||||
throw Exception(data['error'] ?? 'Failed to create mindmap');
|
||||
}
|
||||
}
|
||||
|
||||
/// Обновление mindmap
|
||||
Future<Map<String, dynamic>> updateMindMap(
|
||||
String id, {
|
||||
String? title,
|
||||
String? description,
|
||||
List<Map<String, dynamic>>? nodes,
|
||||
Map<String, dynamic>? settings,
|
||||
}) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final body = <String, dynamic>{};
|
||||
if (title != null) body['title'] = title;
|
||||
if (description != null) body['description'] = description;
|
||||
if (nodes != null) body['nodes'] = nodes;
|
||||
if (settings != null) body['settings'] = settings;
|
||||
|
||||
final response = await http.put(
|
||||
Uri.parse('$_baseUrl/api/mindmaps/$id'),
|
||||
headers: _getHeaders(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to update mindmap');
|
||||
}
|
||||
}
|
||||
|
||||
/// Удаление mindmap
|
||||
Future<void> deleteMindMap(String id) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.delete(
|
||||
Uri.parse('$_baseUrl/api/mindmaps/$id'),
|
||||
headers: _getHeaders(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to delete mindmap');
|
||||
}
|
||||
}
|
||||
|
||||
/// Получение mindmap как Markdown
|
||||
Future<String> getMindMapAsMarkdown(String id) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/mindmaps/$id/markdown'),
|
||||
headers: _getHeaders(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return response.body;
|
||||
} else {
|
||||
throw Exception('Failed to load markdown');
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохранение mindmap из Markdown
|
||||
Future<Map<String, dynamic>> saveMindMapFromMarkdown(
|
||||
String id,
|
||||
String markdown,
|
||||
) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/mindmaps/$id/markdown'),
|
||||
headers: _getHeaders(),
|
||||
body: jsonEncode({'markdown': markdown}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to save markdown');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SYNC ====================
|
||||
|
||||
/// Получение статуса синхронизации
|
||||
Future<Map<String, dynamic>> getSyncStatus() async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/sync/status'),
|
||||
headers: _getHeaders(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to get sync status');
|
||||
}
|
||||
}
|
||||
|
||||
/// Push изменений в Gitea
|
||||
Future<Map<String, dynamic>> pushToGitea({String? commitMessage}) async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/sync/gitea/push'),
|
||||
headers: _getHeaders(),
|
||||
body: jsonEncode({
|
||||
'commitMessage': commitMessage ?? 'Sync from Nanaxe CRM',
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to push to Gitea');
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull изменений из Gitea
|
||||
Future<Map<String, dynamic>> pullFromGitea() async {
|
||||
if (_baseUrl == null) throw Exception('Server not configured');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/sync/gitea/pull'),
|
||||
headers: _getHeaders(),
|
||||
body: jsonEncode({}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to pull from Gitea');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== HELPERS ====================
|
||||
|
||||
Map<String, String> _getHeaders() {
|
||||
final headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (_authToken != null) {
|
||||
headers['Authorization'] = 'Bearer $_authToken';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/// Проверка соединения с сервером
|
||||
Future<bool> checkConnection() async {
|
||||
if (_baseUrl == null) return false;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/health'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Провайдер для работы с сервером
|
||||
class ServerConfig {
|
||||
final String baseUrl;
|
||||
final String? token;
|
||||
final bool isConnected;
|
||||
|
||||
ServerConfig({
|
||||
required this.baseUrl,
|
||||
this.token,
|
||||
this.isConnected = false,
|
||||
});
|
||||
}
|
||||
@@ -6,13 +6,17 @@ import '../screens/home_screen.dart';
|
||||
import '../screens/tasks_screen.dart';
|
||||
import '../screens/task_form_screen.dart';
|
||||
import '../screens/mind_map_screen.dart';
|
||||
import '../screens/mind_map_settings_screen.dart';
|
||||
import '../screens/calendar_screen.dart';
|
||||
import '../screens/alarms_screen.dart';
|
||||
import '../screens/ai_test_screen.dart';
|
||||
import '../screens/profile_screen.dart';
|
||||
import '../screens/admin/admin_screen.dart';
|
||||
import '../screens/admin/team_management_screen.dart';
|
||||
import '../screens/admin/projects_management_screen.dart';
|
||||
import '../screens/admin/notifications_settings_screen.dart';
|
||||
import '../screens/admin/mind_map_admin_screen.dart';
|
||||
import '../screens/admin/ai_stats_screen.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
// Глобальный ключ для навигации
|
||||
@@ -67,6 +71,17 @@ class AppRouter {
|
||||
path: 'mindmap',
|
||||
name: 'mindmap',
|
||||
builder: (context, state) => const MindMapScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
name: 'mindmap-settings',
|
||||
builder: (context, state) {
|
||||
final projectId =
|
||||
state.uri.queryParameters['projectId'] ?? 'default';
|
||||
return MindMapSettingsScreen(projectId: projectId);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'calendar',
|
||||
@@ -78,6 +93,11 @@ class AppRouter {
|
||||
name: 'alarms',
|
||||
builder: (context, state) => const AlarmsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'ai-test',
|
||||
name: 'ai-test',
|
||||
@@ -104,6 +124,16 @@ class AppRouter {
|
||||
builder: (context, state) =>
|
||||
const NotificationsSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'mindmaps',
|
||||
name: 'admin-mindmaps',
|
||||
builder: (context, state) => const MindMapAdminScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'ai-stats',
|
||||
name: 'admin-ai-stats',
|
||||
builder: (context, state) => const AIStatsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -35,8 +35,12 @@ class AlarmModel {
|
||||
|
||||
factory AlarmModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
return AlarmModel.fromMap(doc.id, data);
|
||||
}
|
||||
|
||||
factory AlarmModel.fromMap(String id, Map<String, dynamic> data) {
|
||||
return AlarmModel(
|
||||
id: doc.id,
|
||||
id: id,
|
||||
title: data['title'] ?? '',
|
||||
taskId: data['taskId'],
|
||||
eventId: data['eventId'],
|
||||
@@ -107,10 +111,10 @@ class AlarmModel {
|
||||
|
||||
DateTime get nextOccurrence {
|
||||
if (!isRepeating) return dateTime;
|
||||
|
||||
|
||||
final now = DateTime.now();
|
||||
if (dateTime.isAfter(now)) return dateTime;
|
||||
|
||||
|
||||
switch (repeatPattern) {
|
||||
case 'daily':
|
||||
return dateTime.add(const Duration(days: 1));
|
||||
@@ -124,7 +128,8 @@ class AlarmModel {
|
||||
while (!repeatDays.contains(next.weekday)) {
|
||||
next = next.add(const Duration(days: 1));
|
||||
}
|
||||
return DateTime(next.year, next.month, next.day, dateTime.hour, dateTime.minute);
|
||||
return DateTime(
|
||||
next.year, next.month, next.day, dateTime.hour, dateTime.minute);
|
||||
default:
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
@@ -43,8 +43,12 @@ class CalendarEventModel {
|
||||
|
||||
factory CalendarEventModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
return CalendarEventModel.fromMap(doc.id, data);
|
||||
}
|
||||
|
||||
factory CalendarEventModel.fromMap(String id, Map<String, dynamic> data) {
|
||||
return CalendarEventModel(
|
||||
id: doc.id,
|
||||
id: id,
|
||||
title: data['title'] ?? '',
|
||||
description: data['description'],
|
||||
location: data['location'] ?? '',
|
||||
|
||||
@@ -35,8 +35,12 @@ class MindMapNode {
|
||||
|
||||
factory MindMapNode.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
return MindMapNode.fromMap(doc.id, data);
|
||||
}
|
||||
|
||||
factory MindMapNode.fromMap(String id, Map<String, dynamic> data) {
|
||||
return MindMapNode(
|
||||
id: doc.id,
|
||||
id: id,
|
||||
projectId: data['projectId'] ?? '',
|
||||
parentId: data['parentId'],
|
||||
level: data['level'] ?? 0,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class MindMapModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String ownerEmail;
|
||||
final bool isMain; // Основные карты (админские) vs Личные
|
||||
final List<Map<String, dynamic>> nodes;
|
||||
final Map<String, dynamic> settings;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
MindMapModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description = '',
|
||||
required this.ownerEmail,
|
||||
this.isMain = false,
|
||||
this.nodes = const [],
|
||||
this.settings = const {},
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory MindMapModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
return MindMapModel.fromMap(doc.id, data);
|
||||
}
|
||||
|
||||
factory MindMapModel.fromMap(String id, Map<String, dynamic> data) {
|
||||
return MindMapModel(
|
||||
id: id,
|
||||
title: data['title'] ?? '',
|
||||
description: data['description'] ?? '',
|
||||
ownerEmail: data['ownerEmail'] ?? '',
|
||||
isMain: data['isMain'] ?? false,
|
||||
nodes: List<Map<String, dynamic>>.from(data['nodes'] ?? []),
|
||||
settings: data['settings'] ?? {},
|
||||
createdAt: (data['createdAt'] as Timestamp).toDate(),
|
||||
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toFirestore() {
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'ownerEmail': ownerEmail,
|
||||
'isMain': isMain,
|
||||
'nodes': nodes,
|
||||
'settings': settings,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
MindMapModel copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
String? ownerEmail,
|
||||
bool? isMain,
|
||||
List<Map<String, dynamic>>? nodes,
|
||||
Map<String, dynamic>? settings,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return MindMapModel(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
ownerEmail: ownerEmail ?? this.ownerEmail,
|
||||
isMain: isMain ?? this.isMain,
|
||||
nodes: nodes ?? this.nodes,
|
||||
settings: settings ?? this.settings,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
int get nodeCount => nodes.length;
|
||||
int get maxLevel {
|
||||
if (nodes.isEmpty) return 0;
|
||||
return nodes.fold(
|
||||
0, (max, node) => max > (node['level'] ?? 0) ? max : (node['level'] ?? 0));
|
||||
}
|
||||
}
|
||||
@@ -34,24 +34,33 @@ class ProjectModel {
|
||||
});
|
||||
|
||||
factory ProjectModel.fromFirestore(DocumentSnapshot doc) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
final data = doc.data();
|
||||
if (data == null) {
|
||||
throw Exception('Document data is null');
|
||||
}
|
||||
|
||||
final mapData = data is Map<String, dynamic>
|
||||
? data
|
||||
: (data as Map<dynamic, dynamic>)
|
||||
.map((k, v) => MapEntry(k.toString(), v));
|
||||
|
||||
return ProjectModel(
|
||||
id: doc.id,
|
||||
name: data['name'] ?? '',
|
||||
description: data['description'] ?? '',
|
||||
teamId: data['teamId'] ?? '',
|
||||
ownerId: data['ownerId'] ?? '',
|
||||
memberIds: List<String>.from(data['memberIds'] ?? []),
|
||||
status: data['status'] ?? 'active',
|
||||
startDate: (data['startDate'] as Timestamp).toDate(),
|
||||
endDate: data['endDate'] != null
|
||||
? (data['endDate'] as Timestamp).toDate()
|
||||
name: mapData['name'] ?? '',
|
||||
description: mapData['description'] ?? '',
|
||||
teamId: mapData['teamId'] ?? '',
|
||||
ownerId: mapData['ownerId'] ?? '',
|
||||
memberIds: List<String>.from(mapData['memberIds'] ?? []),
|
||||
status: mapData['status'] ?? 'active',
|
||||
startDate: (mapData['startDate'] as Timestamp).toDate(),
|
||||
endDate: mapData['endDate'] != null
|
||||
? (mapData['endDate'] as Timestamp).toDate()
|
||||
: null,
|
||||
color: data['color'] ?? '#1976D2',
|
||||
icon: data['icon'] ?? 'project',
|
||||
settings: data['settings'] ?? {},
|
||||
createdAt: (data['createdAt'] as Timestamp).toDate(),
|
||||
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
|
||||
color: mapData['color'] ?? '#1976D2',
|
||||
icon: mapData['icon'] ?? 'project',
|
||||
settings: mapData['settings'] ?? {},
|
||||
createdAt: (mapData['createdAt'] as Timestamp).toDate(),
|
||||
updatedAt: (mapData['updatedAt'] as Timestamp).toDate(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/mindmap_model.dart';
|
||||
import '../core/admin_access_service.dart';
|
||||
import '../core/deepseek_service.dart';
|
||||
|
||||
// Provider для получения текущего email пользователя
|
||||
final currentUserEmailProvider = Provider<String?>((ref) {
|
||||
return FirebaseAuth.instance.currentUser?.email;
|
||||
});
|
||||
|
||||
// Provider проверки является ли пользователь админом
|
||||
final isAdminProvider = Provider<bool>((ref) {
|
||||
final email = ref.watch(currentUserEmailProvider);
|
||||
if (email == null) return false;
|
||||
return AdminAccessService.adminEmails.contains(email.toLowerCase());
|
||||
});
|
||||
|
||||
// Provider списка mind maps
|
||||
final mindMapsProvider = StreamProvider<List<MindMapModel>>((ref) {
|
||||
return FirebaseFirestore.instance
|
||||
.collection('mind_maps')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
return snapshot.docs.map((doc) {
|
||||
return MindMapModel.fromFirestore(doc);
|
||||
}).toList();
|
||||
});
|
||||
});
|
||||
|
||||
// Notifier для управления mind maps
|
||||
class MindMapsNotifier extends StateNotifier<AsyncValue<List<MindMapModel>>> {
|
||||
final Ref ref;
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
MindMapsNotifier(this.ref) : super(const AsyncValue.loading()) {
|
||||
_listenToMindMaps();
|
||||
}
|
||||
|
||||
void _listenToMindMaps() {
|
||||
_firestore
|
||||
.collection('mind_maps')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.snapshots()
|
||||
.listen(
|
||||
(snapshot) {
|
||||
final mindMaps = snapshot.docs.map((doc) {
|
||||
return MindMapModel.fromFirestore(doc);
|
||||
}).toList();
|
||||
state = AsyncValue.data(mindMaps);
|
||||
},
|
||||
onError: (error, st) {
|
||||
state = AsyncValue.error(error, st);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Создание новой mind map
|
||||
Future<String> createMindMap({
|
||||
required String title,
|
||||
String description = '',
|
||||
required List<Map<String, dynamic>> nodes,
|
||||
bool isMain = false,
|
||||
}) async {
|
||||
try {
|
||||
final userEmail = _auth.currentUser?.email ?? 'unknown';
|
||||
final now = DateTime.now();
|
||||
final id = const Uuid().v4();
|
||||
|
||||
final mindMap = MindMapModel(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
ownerEmail: userEmail,
|
||||
isMain: isMain,
|
||||
nodes: nodes,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
await _firestore.collection('mind_maps').doc(id).set(mindMap.toFirestore());
|
||||
|
||||
return id;
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление mind map (админы могут удалять любые, обычные - только свои)
|
||||
Future<void> deleteMindMap(String id, String ownerEmail) async {
|
||||
try {
|
||||
final isAdmin = ref.read(isAdminProvider);
|
||||
final currentUserEmail = _auth.currentUser?.email ?? '';
|
||||
|
||||
// Проверка прав
|
||||
if (!isAdmin && ownerEmail != currentUserEmail) {
|
||||
throw Exception('Нет прав для удаления этой mind map');
|
||||
}
|
||||
|
||||
await _firestore.collection('mind_maps').doc(id).delete();
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление mind map
|
||||
Future<void> updateMindMap(String id, MindMapModel mindMap) async {
|
||||
try {
|
||||
final isAdmin = ref.read(isAdminProvider);
|
||||
final currentUserEmail = _auth.currentUser?.email ?? '';
|
||||
|
||||
// Проверка прав
|
||||
if (!isAdmin && mindMap.ownerEmail != currentUserEmail) {
|
||||
throw Exception('Нет прав для редактирования этой mind map');
|
||||
}
|
||||
|
||||
await _firestore
|
||||
.collection('mind_maps')
|
||||
.doc(id)
|
||||
.update(mindMap.copyWith(updatedAt: DateTime.now()).toFirestore());
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// AI генерация mind map
|
||||
Future<String> generateWithAI({
|
||||
required String title,
|
||||
required String description,
|
||||
}) async {
|
||||
try {
|
||||
final nodesData = await deepSeekService.generateMindMap(
|
||||
projectName: title,
|
||||
description: description,
|
||||
);
|
||||
|
||||
final nodes = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < nodesData.length; i++) {
|
||||
final nodeData = nodesData[i];
|
||||
nodes.add({
|
||||
'id': 'node_$i',
|
||||
'level': nodeData['level'] ?? 0,
|
||||
'parentId': nodeData['parentId'],
|
||||
'title': nodeData['title'] ?? '',
|
||||
'color': _getColorForLevel(nodeData['level'] ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
return await createMindMap(
|
||||
title: title,
|
||||
description: description,
|
||||
nodes: nodes,
|
||||
);
|
||||
} catch (e, st) {
|
||||
state = AsyncValue.error(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
String _getColorForLevel(int level) {
|
||||
final colors = [
|
||||
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4'
|
||||
];
|
||||
return colors[level % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
final mindMapsNotifierProvider =
|
||||
StateNotifierProvider<MindMapsNotifier, AsyncValue<List<MindMapModel>>>(
|
||||
(ref) => MindMapsNotifier(ref),
|
||||
);
|
||||
@@ -1,13 +1,15 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||
import '../models/mind_map_model.dart';
|
||||
import '../models/task_model.dart';
|
||||
import '../core/firestore_service.dart';
|
||||
import '../core/local_storage_service.dart';
|
||||
import '../core/deepseek_service.dart';
|
||||
import '../core/mind_map_adapter.dart';
|
||||
|
||||
// Provider для mind-карты проекта
|
||||
final mindMapProvider = StateNotifierProvider.family<MindMapNotifier, AsyncValue<MindMapData>, String>((ref, projectId) {
|
||||
final mindMapProvider = StateNotifierProvider.family<MindMapNotifier,
|
||||
AsyncValue<MindMapData>, String>((ref, projectId) {
|
||||
return MindMapNotifier(projectId);
|
||||
});
|
||||
|
||||
@@ -31,9 +33,16 @@ class MindMapData {
|
||||
|
||||
// Получить родительский узел
|
||||
MindMapNode? getParent(String nodeId) {
|
||||
final node = nodes.firstWhere((n) => n.id == nodeId, orElse: () => throw Exception('Node not found'));
|
||||
final node = nodes.firstWhere((n) => n.id == nodeId,
|
||||
orElse: () => throw Exception('Node not found'));
|
||||
if (node.parentId == null) return null;
|
||||
return nodes.firstWhere((n) => n.id == node.parentId, orElse: () => throw Exception('Parent not found'));
|
||||
return nodes.firstWhere((n) => n.id == node.parentId,
|
||||
orElse: () => throw Exception('Parent not found'));
|
||||
}
|
||||
|
||||
// Конвертировать в reactive_mind_map формат
|
||||
rm.MindMapData toReactiveMindMapData() {
|
||||
return MindMapAdapter.nodesToMindMapData(nodes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +58,9 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
try {
|
||||
// Загружаем из локального хранилища
|
||||
final localNodes = localStorageService.getMindMapNodes();
|
||||
final projectNodes = localNodes.where((n) => n.projectId == projectId).toList();
|
||||
|
||||
final projectNodes =
|
||||
localNodes.where((n) => n.projectId == projectId).toList();
|
||||
|
||||
state = AsyncValue.data(_createMindMapData(projectNodes));
|
||||
|
||||
// TODO: Загрузить из Firestore и синхронизировать
|
||||
@@ -90,7 +100,7 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
String icon = 'folder',
|
||||
}) async {
|
||||
try {
|
||||
final parentNode = parentId != null
|
||||
final parentNode = parentId != null
|
||||
? state.value?.nodes.firstWhere((n) => n.id == parentId)
|
||||
: null;
|
||||
|
||||
@@ -123,11 +133,11 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
final currentData = state.value;
|
||||
if (currentData != null) {
|
||||
final updatedNodes = [...currentData.nodes, node];
|
||||
|
||||
|
||||
if (parentId != null) {
|
||||
currentData.childrenMap.putIfAbsent(parentId, () => []).add(node.id);
|
||||
}
|
||||
|
||||
|
||||
state = AsyncValue.data(MindMapData(
|
||||
nodes: updatedNodes,
|
||||
childrenMap: currentData.childrenMap,
|
||||
@@ -143,7 +153,8 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
}
|
||||
|
||||
// Обновление узла
|
||||
Future<void> updateNode(String nodeId, {
|
||||
Future<void> updateNode(
|
||||
String nodeId, {
|
||||
String? title,
|
||||
String? color,
|
||||
String? icon,
|
||||
@@ -203,7 +214,9 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
_collectChildNodes(nodeId, currentData, nodesToDelete);
|
||||
|
||||
// Сохраняем для удаления
|
||||
final nodes = currentData.nodes.where((n) => !nodesToDelete.contains(n.id)).toList();
|
||||
final nodes = currentData.nodes
|
||||
.where((n) => !nodesToDelete.contains(n.id))
|
||||
.toList();
|
||||
|
||||
// Сохраняем локально (удаляем)
|
||||
for (final id in nodesToDelete) {
|
||||
@@ -234,7 +247,8 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
}
|
||||
|
||||
// Рекурсивный сбор дочерних узлов
|
||||
void _collectChildNodes(String parentId, MindMapData data, Set<String> result) {
|
||||
void _collectChildNodes(
|
||||
String parentId, MindMapData data, Set<String> result) {
|
||||
final childIds = data.childrenMap[parentId] ?? [];
|
||||
for (final childId in childIds) {
|
||||
result.add(childId);
|
||||
@@ -260,7 +274,7 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
final nodeData = nodesData[i];
|
||||
final level = nodeData['level'] as int;
|
||||
final parentId = nodeData['parentId'] as int?;
|
||||
|
||||
|
||||
String? actualParentId;
|
||||
if (parentId != null && parentId < nodes.length) {
|
||||
actualParentId = nodes[parentId].id;
|
||||
@@ -272,16 +286,16 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
x: (level * 200).toDouble(), // Автоматическое позиционирование
|
||||
y: (i * 50).toDouble(),
|
||||
);
|
||||
|
||||
|
||||
nodes.add(node);
|
||||
|
||||
|
||||
if (level == 0) {
|
||||
rootNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch (e, st) {
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -300,16 +314,18 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
||||
return {
|
||||
'projectId': projectId,
|
||||
'rootNodeId': data.rootNodeId,
|
||||
'nodes': data.nodes.map((n) => {
|
||||
'id': n.id,
|
||||
'parentId': n.parentId,
|
||||
'level': n.level,
|
||||
'title': n.title,
|
||||
'x': n.x,
|
||||
'y': n.y,
|
||||
'color': n.color,
|
||||
'icon': n.icon,
|
||||
}).toList(),
|
||||
'nodes': data.nodes
|
||||
.map((n) => {
|
||||
'id': n.id,
|
||||
'parentId': n.parentId,
|
||||
'level': n.level,
|
||||
'title': n.title,
|
||||
'x': n.x,
|
||||
'y': n.y,
|
||||
'color': n.color,
|
||||
'icon': n.icon,
|
||||
})
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/task_model.dart';
|
||||
import '../models/project_model.dart';
|
||||
import '../core/firestore_service.dart';
|
||||
import '../core/local_storage_service.dart';
|
||||
import '../core/deepseek_service.dart';
|
||||
@@ -253,7 +252,7 @@ class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
|
||||
}
|
||||
|
||||
return subtasks;
|
||||
} catch (e, st) {
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+242
-31
@@ -1,49 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/alarm_model.dart';
|
||||
import '../core/notification_service.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class AlarmsScreen extends StatelessWidget {
|
||||
class AlarmsScreen extends ConsumerStatefulWidget {
|
||||
const AlarmsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Будильники'),
|
||||
),
|
||||
body: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return _AlarmCard(index: index);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Новый будильник'),
|
||||
ConsumerState<AlarmsScreen> createState() => _AlarmsScreenState();
|
||||
}
|
||||
|
||||
class _AlarmsScreenState extends ConsumerState<AlarmsScreen> {
|
||||
List<AlarmModel> _alarms = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAlarms();
|
||||
}
|
||||
|
||||
Future<void> _loadAlarms() async {
|
||||
setState(() => _isLoading = true);
|
||||
// TODO: Загрузить из Firestore/Hive
|
||||
setState(() {
|
||||
_alarms = [];
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleAlarm(AlarmModel alarm) async {
|
||||
final updated = alarm.copyWith(isEnabled: !alarm.isEnabled);
|
||||
setState(() {
|
||||
final index = _alarms.indexWhere((a) => a.id == alarm.id);
|
||||
if (index != -1) {
|
||||
_alarms[index] = updated;
|
||||
}
|
||||
});
|
||||
|
||||
if (updated.isEnabled) {
|
||||
await notificationService.scheduleNotification(
|
||||
id: updated.hashCode,
|
||||
title: updated.title,
|
||||
body: 'Напоминание: ${updated.title}',
|
||||
scheduledDate: updated.dateTime,
|
||||
);
|
||||
} else {
|
||||
await notificationService.cancelNotification(updated.hashCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAlarm(String id) async {
|
||||
final alarm = _alarms.firstWhere((a) => a.id == id);
|
||||
await notificationService.cancelNotification(alarm.hashCode);
|
||||
|
||||
setState(() {
|
||||
_alarms.removeWhere((a) => a.id == id);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showAddAlarmDialog() async {
|
||||
final titleController = TextEditingController();
|
||||
DateTime selectedDate = DateTime.now();
|
||||
TimeOfDay selectedTime = TimeOfDay.now();
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: const Text('Новый будильник'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_today),
|
||||
title: const Text('Дата'),
|
||||
subtitle: Text(
|
||||
'${selectedDate.day}.${selectedDate.month}.${selectedDate.year}',
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setDialogState(() => selectedDate = picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.access_time),
|
||||
title: const Text('Время'),
|
||||
subtitle: Text(selectedTime.format(context)),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: selectedTime,
|
||||
);
|
||||
if (picked != null) {
|
||||
setDialogState(() => selectedTime = picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (titleController.text.isEmpty) return;
|
||||
|
||||
final dateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
|
||||
final alarm = AlarmModel(
|
||||
id: const Uuid().v4(),
|
||||
title: titleController.text,
|
||||
dateTime: dateTime,
|
||||
isEnabled: true,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_alarms.add(alarm);
|
||||
});
|
||||
|
||||
notificationService.scheduleNotification(
|
||||
id: alarm.hashCode,
|
||||
title: alarm.title,
|
||||
body: 'Будильник: ${alarm.title}',
|
||||
scheduledDate: alarm.dateTime,
|
||||
);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlarmCard extends StatelessWidget {
|
||||
final int index;
|
||||
|
||||
const _AlarmCard({required this.index});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Будильники'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadAlarms,
|
||||
tooltip: 'Обновить',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _alarms.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _alarms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final alarm = _alarms[index];
|
||||
return _buildAlarmCard(alarm);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddAlarmDialog,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.alarm, size: 100, color: Colors.grey[400]),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Нет будильников',
|
||||
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Создайте первый будильник',
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlarmCard(AlarmModel alarm) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: index % 2 == 0 ? Colors.blue : Colors.grey,
|
||||
child: const Icon(Icons.alarm, color: Colors.white),
|
||||
leading: Icon(
|
||||
alarm.isEnabled ? Icons.alarm : Icons.alarm_off,
|
||||
color: alarm.isEnabled ? Colors.blue : Colors.grey,
|
||||
),
|
||||
title: Text('Будильник #${index + 1}'),
|
||||
subtitle: Text('${8 + index}:00'),
|
||||
trailing: Switch(
|
||||
value: index % 2 == 0,
|
||||
onChanged: (value) {},
|
||||
title: Text(
|
||||
alarm.title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: alarm.isEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${alarm.dateTime.day}.${alarm.dateTime.month}.${alarm.dateTime.year} '
|
||||
'${alarm.dateTime.hour.toString().padLeft(2, '0')}:'
|
||||
'${alarm.dateTime.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(
|
||||
value: alarm.isEnabled,
|
||||
onChanged: (_) => _toggleAlarm(alarm),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => _deleteAlarm(alarm.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import '../models/calendar_event_model.dart';
|
||||
import '../core/google_calendar_service.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerStatefulWidget {
|
||||
const CalendarScreen({super.key});
|
||||
@@ -267,8 +266,8 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
||||
final descriptionController = TextEditingController();
|
||||
final locationController = TextEditingController();
|
||||
|
||||
TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0);
|
||||
TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0);
|
||||
TimeOfDay startTime = const TimeOfDay(hour: 10, minute: 0);
|
||||
TimeOfDay endTime = const TimeOfDay(hour: 11, minute: 0);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -312,16 +311,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
title: const Text('Начало'),
|
||||
subtitle: Text(_startTime.format(context)),
|
||||
subtitle: Text(startTime.format(context)),
|
||||
leading: const Icon(Icons.access_time),
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _startTime,
|
||||
initialTime: startTime,
|
||||
);
|
||||
if (picked != null) {
|
||||
setDialogState(() {
|
||||
_startTime = picked;
|
||||
startTime = picked;
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -330,16 +329,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
title: const Text('Конец'),
|
||||
subtitle: Text(_endTime.format(context)),
|
||||
subtitle: Text(endTime.format(context)),
|
||||
leading: const Icon(Icons.access_time),
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _endTime,
|
||||
initialTime: endTime,
|
||||
);
|
||||
if (picked != null) {
|
||||
setDialogState(() {
|
||||
_endTime = picked;
|
||||
endTime = picked;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dashboard_screen.dart';
|
||||
import 'tasks_screen.dart';
|
||||
import 'mind_map_screen.dart';
|
||||
import 'calendar_screen.dart';
|
||||
import 'alarms_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||
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 MindMapInteractiveScreen extends ConsumerStatefulWidget {
|
||||
final MindMapModel mindMap;
|
||||
|
||||
const MindMapInteractiveScreen({super.key, required this.mindMap});
|
||||
|
||||
@override
|
||||
ConsumerState<MindMapInteractiveScreen> createState() =>
|
||||
_MindMapInteractiveScreenState();
|
||||
}
|
||||
|
||||
class _MindMapInteractiveScreenState
|
||||
extends ConsumerState<MindMapInteractiveScreen> {
|
||||
late List<Map<String, dynamic>> _nodes;
|
||||
late bool _isMain;
|
||||
bool _isEditing = false;
|
||||
bool _showChat = false;
|
||||
bool _isAISubmitting = false;
|
||||
final TextEditingController _chatController = TextEditingController();
|
||||
final List<ChatMessage> _chatMessages = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nodes = widget.mindMap.nodes.toList();
|
||||
_isMain = widget.mindMap.isMain;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chatController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ========== CHAT FUNCTIONS ==========
|
||||
|
||||
void _addChatMessage(String text, bool isUser, {AIAction? action}) {
|
||||
setState(() {
|
||||
_chatMessages.add(ChatMessage(
|
||||
text: text,
|
||||
isUser: isUser,
|
||||
timestamp: DateTime.now(),
|
||||
action: action,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendToAI(String message) async {
|
||||
if (message.trim().isEmpty) return;
|
||||
|
||||
_addChatMessage(message, true);
|
||||
_chatController.clear();
|
||||
setState(() => _isAISubmitting = true);
|
||||
|
||||
try {
|
||||
// Отправляем запрос к DeepSeek
|
||||
final response = await deepSeekService.chatCompletion(
|
||||
messages: [
|
||||
..._chatMessages
|
||||
.where((m) => m.isUser || m.text.startsWith('AI:'))
|
||||
.map((m) => {
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.text,
|
||||
})
|
||||
.toList(),
|
||||
{
|
||||
'role': 'system',
|
||||
'content': '''
|
||||
Ты AI ассистент для работы с mind maps.
|
||||
Текущая структура mind map "${widget.mindMap.title}":
|
||||
${_nodes.map((n) => '${" " * (n["level"] ?? 0)}- ${n["title"]} (уровень ${n["level"] ?? 0})').join('\n')}
|
||||
|
||||
Когда пользователь просит что-то сделать с mind map, отвечай в формате:
|
||||
ACTION: <add|edit|delete|suggest>
|
||||
TARGET: <id узла или null>
|
||||
CHANGES: [
|
||||
{"action": "add", "title": "...", "level": 0, "parentId": null},
|
||||
{"action": "edit", "id": "...", "title": "..."},
|
||||
{"action": "delete", "id": "..."}
|
||||
]
|
||||
EXPLANATION: <объяснение на русском>
|
||||
|
||||
Или просто отвечай текстом если действие не требуется.
|
||||
''',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Парсим ответ AI
|
||||
if (response.containsKey('ACTION')) {
|
||||
final action = response['ACTION'] as String;
|
||||
final changes = response['CHANGES'] as List<dynamic>;
|
||||
final explanation = response['EXPLANATION'] as String? ?? '';
|
||||
|
||||
_addChatMessage(
|
||||
'AI: $explanation',
|
||||
false,
|
||||
action: AIAction(
|
||||
type: action,
|
||||
changes: changes.map((c) => c as Map<String, dynamic>).toList(),
|
||||
isApplied: false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_addChatMessage('AI: $response', false);
|
||||
}
|
||||
} catch (e) {
|
||||
_addChatMessage('❌ Ошибка AI: $e', false);
|
||||
} finally {
|
||||
setState(() => _isAISubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyAIChanges(AIAction action) {
|
||||
for (final change in action.changes) {
|
||||
final actionType = change['action'] as String;
|
||||
|
||||
switch (actionType) {
|
||||
case 'add':
|
||||
_nodes.add({
|
||||
'id': const Uuid().v4(),
|
||||
'title': change['title'] ?? '',
|
||||
'level': change['level'] ?? 0,
|
||||
'parentId': change['parentId'],
|
||||
'color': _getColorForLevel(change['level'] ?? 0),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
final index = _nodes.indexWhere((n) => n['id'] == change['id']);
|
||||
if (index != -1) {
|
||||
_nodes[index] = {
|
||||
..._nodes[index],
|
||||
'title': change['title'] ?? _nodes[index]['title'],
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
final nodesToDelete = <String>{change['id'] as String};
|
||||
_collectChildren(change['id'] as String, nodesToDelete);
|
||||
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем сообщение в чате
|
||||
final msgIndex = _chatMessages.indexWhere((m) => m.action == action);
|
||||
if (msgIndex != -1) {
|
||||
setState(() {
|
||||
_chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith(
|
||||
action: action.copyWith(isApplied: true),
|
||||
);
|
||||
_nodes = _nodes.toList(); // Триггер rebuild
|
||||
});
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('✅ Изменения AI применены'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _rejectAIChanges(AIAction action) {
|
||||
final msgIndex = _chatMessages.indexWhere((m) => m.action == action);
|
||||
if (msgIndex != -1) {
|
||||
setState(() {
|
||||
_chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith(
|
||||
action: action.copyWith(isApplied: false),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== EDIT FUNCTIONS ==========
|
||||
|
||||
void _addNode({String? parentId}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _NodeTitleDialog(
|
||||
onAdd: (title) {
|
||||
final parentNode = parentId != null
|
||||
? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {})
|
||||
: null;
|
||||
|
||||
setState(() {
|
||||
_nodes.add({
|
||||
'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),
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editNode(String nodeId) {
|
||||
final node = _nodes.firstWhere((n) => n['id'] == nodeId);
|
||||
final controller = TextEditingController(text: node['title'] as String);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Редактировать узел'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final index = _nodes.indexWhere((n) => n['id'] == nodeId);
|
||||
if (index != -1) {
|
||||
setState(() {
|
||||
_nodes[index] = {..._nodes[index], 'title': controller.text};
|
||||
});
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteNode(String nodeId) {
|
||||
final nodesToDelete = <String>{nodeId};
|
||||
_collectChildren(nodeId, nodesToDelete);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить узел?'),
|
||||
content: Text(
|
||||
'Удалить "${_nodes.firstWhere((n) => n['id'] == nodeId)['title']}" и все дочерние узлы?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _saveChanges() async {
|
||||
try {
|
||||
final notifier = ref.read(mindMapsNotifierProvider.notifier);
|
||||
final isAdmin = ref.read(isAdminProvider);
|
||||
final userEmail = FirebaseAuth.instance.currentUser?.email ?? '';
|
||||
|
||||
// Проверка прав
|
||||
if (!isAdmin && widget.mindMap.ownerEmail != userEmail) {
|
||||
throw Exception('Нет прав для редактирования');
|
||||
}
|
||||
|
||||
final updated = widget.mindMap.copyWith(
|
||||
nodes: _nodes,
|
||||
isMain: _isMain,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await notifier.updateMindMap(widget.mindMap.id, updated);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('✅ Изменения сохранены'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getColorForLevel(int level) {
|
||||
final colors = [
|
||||
'#2196F3',
|
||||
'#4CAF50',
|
||||
'#FF9800',
|
||||
'#E91E63',
|
||||
'#9C27B0',
|
||||
'#00BCD4'
|
||||
];
|
||||
return colors[level % colors.length];
|
||||
}
|
||||
|
||||
Color _hexToColor(String hex) {
|
||||
hex = hex.replaceAll('#', '');
|
||||
if (hex.length == 6) return Color(int.parse('FF$hex', radix: 16));
|
||||
return const Color(0xFF2196F3);
|
||||
}
|
||||
|
||||
// ========== BUILD ==========
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAdmin = ref.watch(isAdminProvider);
|
||||
final userEmail = FirebaseAuth.instance.currentUser?.email ?? '';
|
||||
final canEdit = isAdmin || widget.mindMap.ownerEmail == userEmail;
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.mindMap.title),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isMain ? Icons.star : Icons.person,
|
||||
size: 14,
|
||||
color: _isMain ? Colors.amber : Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_isMain ? 'Основная' : 'Личная',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _isMain ? Colors.amber : Colors.blue,
|
||||
),
|
||||
),
|
||||
if (canEdit && _isEditing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
'✏️ Редактирование',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// Toggle edit mode
|
||||
if (canEdit)
|
||||
IconButton(
|
||||
icon: Icon(_isEditing ? Icons.visibility : Icons.edit),
|
||||
onPressed: () => setState(() => _isEditing = !_isEditing),
|
||||
tooltip: _isEditing ? 'Режим просмотра' : 'Режим редактирования',
|
||||
),
|
||||
// AI Chat
|
||||
IconButton(
|
||||
icon: Icon(_showChat ? Icons.close : Icons.auto_awesome),
|
||||
onPressed: () => setState(() => _showChat = !_showChat),
|
||||
tooltip: _showChat ? 'Закрыть AI чат' : 'Открыть AI чат',
|
||||
),
|
||||
// Save
|
||||
if (_isEditing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
onPressed: _saveChanges,
|
||||
tooltip: 'Сохранить изменения',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Mind Map (фон)
|
||||
_nodes.isEmpty
|
||||
? _buildEmptyState()
|
||||
: rm.MindMapWidget(
|
||||
data: _buildMindMapData(),
|
||||
style: rm.MindMapStyle(
|
||||
layout: rm.MindMapLayout.radial,
|
||||
nodeShape: rm.NodeShape.roundedRectangle,
|
||||
connectionColor: Colors.blue.shade100,
|
||||
connectionWidth: 2.0,
|
||||
),
|
||||
cameraFocus: rm.CameraFocus.fitAll,
|
||||
onNodeTap: _isEditing ? (node) => _editNode(node.id) : null,
|
||||
onNodeLongPress:
|
||||
_isEditing ? (node) => _deleteNode(node.id) : null,
|
||||
),
|
||||
|
||||
// Чат поверх карты
|
||||
if (_showChat) _buildChatOverlay(),
|
||||
],
|
||||
),
|
||||
|
||||
// FAB для добавления узлов (в режиме редактирования)
|
||||
floatingActionButton: _isEditing && !_showChat
|
||||
? FloatingActionButton(
|
||||
onPressed: () => _addNode(),
|
||||
child: const Icon(Icons.add),
|
||||
tooltip: 'Добавить корневой узел',
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatOverlay() {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: MediaQuery.of(context).size.height * 0.45,
|
||||
child: Column(
|
||||
children: [
|
||||
// Градиентная полоска сверху
|
||||
Container(
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.purple, Colors.deepPurple],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Панель чата
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: Colors.purple.shade800,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome,
|
||||
color: Colors.white, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'AI Ассистент',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_chatMessages.length}',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Messages
|
||||
Expanded(
|
||||
child: _chatMessages.isEmpty
|
||||
? _buildChatEmptyState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(6),
|
||||
itemCount: _chatMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildChatMessage(_chatMessages[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Input
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
color: Theme.of(context).cardColor,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 40,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _chatController,
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'AI помощь...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 2),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (text) => _sendToAI(text),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Material(
|
||||
color: Colors.purple,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: _isAISubmitting
|
||||
? null
|
||||
: () => _sendToAI(_chatController.text),
|
||||
customBorder: const CircleBorder(),
|
||||
child: _isAISubmitting
|
||||
? const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send,
|
||||
color: Colors.white, size: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Mind map пуста',
|
||||
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||
),
|
||||
if (_isEditing) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _addNode(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Добавить первый узел'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 40, color: Colors.purple[300]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'AI Ассистент',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Напишите "добавь узлы" или\n"предложи улучшения"',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatMessage(ChatMessage msg) {
|
||||
return Align(
|
||||
alignment: msg.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: msg.isUser ? Colors.blue : Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
msg.isUser ? msg.text : msg.text.replaceFirst('AI: ', ''),
|
||||
style: TextStyle(
|
||||
color: msg.isUser ? Colors.white : Colors.black87,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (msg.action != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildAIActionButtons(msg.action!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAIActionButtons(AIAction action) {
|
||||
if (action.isApplied) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 16),
|
||||
SizedBox(width: 4),
|
||||
Text('Применено', style: TextStyle(color: Colors.green)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _applyAIChanges(action),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: const Text('Применить'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.green,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _rejectAIChanges(action),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
label: const Text('Отклонить'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
rm.MindMapData _buildMindMapData() {
|
||||
final rootNodes = _nodes.where((n) => n['parentId'] == null).toList();
|
||||
|
||||
if (rootNodes.isEmpty) {
|
||||
return rm.MindMapData(
|
||||
id: 'empty', title: widget.mindMap.title, children: []);
|
||||
}
|
||||
|
||||
final root = rootNodes.first;
|
||||
return rm.MindMapData(
|
||||
id: root['id'] as String,
|
||||
title: root['title'] as String,
|
||||
color: _hexToColor(root['color'] as String? ?? '#2196F3'),
|
||||
children: _buildChildren(root['id'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
List<rm.MindMapData> _buildChildren(String parentId) {
|
||||
final children = _nodes.where((n) => n['parentId'] == parentId).toList();
|
||||
return children.map((node) {
|
||||
return rm.MindMapData(
|
||||
id: node['id'] as String,
|
||||
title: node['title'] as String,
|
||||
color: _hexToColor(node['color'] as String? ?? '#4CAF50'),
|
||||
children: _buildChildren(node['id'] as String),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== MODELS ==========
|
||||
|
||||
class ChatMessage {
|
||||
final String text;
|
||||
final bool isUser;
|
||||
final DateTime timestamp;
|
||||
final AIAction? action;
|
||||
|
||||
ChatMessage({
|
||||
required this.text,
|
||||
required this.isUser,
|
||||
required this.timestamp,
|
||||
this.action,
|
||||
});
|
||||
|
||||
ChatMessage copyWith({String? text, bool? isUser, AIAction? action}) {
|
||||
return ChatMessage(
|
||||
text: text ?? this.text,
|
||||
isUser: isUser ?? this.isUser,
|
||||
timestamp: timestamp,
|
||||
action: action ?? this.action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIAction {
|
||||
final String type;
|
||||
final List<Map<String, dynamic>> changes;
|
||||
final bool isApplied;
|
||||
|
||||
AIAction({
|
||||
required this.type,
|
||||
required this.changes,
|
||||
this.isApplied = false,
|
||||
});
|
||||
|
||||
AIAction copyWith({bool? isApplied}) {
|
||||
return AIAction(
|
||||
type: type,
|
||||
changes: changes,
|
||||
isApplied: isApplied ?? this.isApplied,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NodeTitleDialog extends StatefulWidget {
|
||||
final Function(String) onAdd;
|
||||
|
||||
const _NodeTitleDialog({required this.onAdd});
|
||||
|
||||
@override
|
||||
State<_NodeTitleDialog> createState() => _NodeTitleDialogState();
|
||||
}
|
||||
|
||||
class _NodeTitleDialogState extends State<_NodeTitleDialog> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Название узла'),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Введите название',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (text) {
|
||||
if (text.isNotEmpty) widget.onAdd(text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_controller.text.isNotEmpty) {
|
||||
widget.onAdd(_controller.text);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Добавить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+278
-270
@@ -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}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MindMapSettingsScreen extends ConsumerStatefulWidget {
|
||||
final String projectId;
|
||||
|
||||
const MindMapSettingsScreen({super.key, required this.projectId});
|
||||
|
||||
@override
|
||||
ConsumerState<MindMapSettingsScreen> createState() =>
|
||||
_MindMapSettingsScreenState();
|
||||
}
|
||||
|
||||
class _MindMapSettingsScreenState extends ConsumerState<MindMapSettingsScreen> {
|
||||
rm.MindMapLayout _defaultLayout = rm.MindMapLayout.radial;
|
||||
rm.NodeShape _defaultShape = rm.NodeShape.roundedRectangle;
|
||||
rm.CameraFocus _defaultCamera = rm.CameraFocus.fitAll;
|
||||
bool _autoExpandNodes = true;
|
||||
bool _showConnections = true;
|
||||
bool _enableShadows = true;
|
||||
double _connectionWidth = 2.0;
|
||||
Color _connectionColor = const Color(0xFFB0BEC5);
|
||||
List<Color> _nodeColors = const [
|
||||
Color(0xFF2196F3),
|
||||
Color(0xFF4CAF50),
|
||||
Color(0xFFFF9800),
|
||||
Color(0xFFE91E63),
|
||||
Color(0xFF9C27B0),
|
||||
];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_defaultLayout = rm.MindMapLayout.values[
|
||||
prefs.getInt('${widget.projectId}_layout') ?? 0];
|
||||
_defaultShape = rm.NodeShape.values[
|
||||
prefs.getInt('${widget.projectId}_shape') ?? 0];
|
||||
_defaultCamera = rm.CameraFocus.values[
|
||||
prefs.getInt('${widget.projectId}_camera') ?? 0];
|
||||
_autoExpandNodes =
|
||||
prefs.getBool('${widget.projectId}_auto_expand') ?? true;
|
||||
_showConnections =
|
||||
prefs.getBool('${widget.projectId}_show_connections') ?? true;
|
||||
_enableShadows =
|
||||
prefs.getBool('${widget.projectId}_shadows') ?? true;
|
||||
_connectionWidth =
|
||||
prefs.getDouble('${widget.projectId}_conn_width') ?? 2.0;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(
|
||||
'${widget.projectId}_layout', _defaultLayout.index);
|
||||
await prefs.setInt('${widget.projectId}_shape', _defaultShape.index);
|
||||
await prefs.setInt(
|
||||
'${widget.projectId}_camera', _defaultCamera.index);
|
||||
await prefs.setBool(
|
||||
'${widget.projectId}_auto_expand', _autoExpandNodes);
|
||||
await prefs.setBool(
|
||||
'${widget.projectId}_show_connections', _showConnections);
|
||||
await prefs.setBool('${widget.projectId}_shadows', _enableShadows);
|
||||
await prefs.setDouble(
|
||||
'${widget.projectId}_conn_width', _connectionWidth);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('✅ Настройки сохранены'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('❌ Ошибка: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportMindMap() async {
|
||||
// TODO: Экспорт mind map в JSON файл
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('📤 Экспорт в разработке'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importMindMap() async {
|
||||
// TODO: Импорт mind map из JSON файла
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('📥 Импорт в разработке'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Настройки Mind Map'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
onPressed: _saveSettings,
|
||||
tooltip: 'Сохранить',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSection(
|
||||
'Layout',
|
||||
[
|
||||
_buildDropdown<rm.MindMapLayout>(
|
||||
'Layout по умолчанию',
|
||||
_defaultLayout,
|
||||
rm.MindMapLayout.values,
|
||||
(value) => setState(() => _defaultLayout = value!),
|
||||
labels: const {
|
||||
rm.MindMapLayout.radial: 'Радиальный',
|
||||
rm.MindMapLayout.right: 'Вправо',
|
||||
rm.MindMapLayout.left: 'Влево',
|
||||
rm.MindMapLayout.horizontal: 'Горизонтальный',
|
||||
rm.MindMapLayout.vertical: 'Вертикальный',
|
||||
},
|
||||
),
|
||||
_buildDropdown<rm.NodeShape>(
|
||||
'Форма узлов',
|
||||
_defaultShape,
|
||||
rm.NodeShape.values,
|
||||
(value) => setState(() => _defaultShape = value!),
|
||||
labels: const {
|
||||
rm.NodeShape.roundedRectangle: 'Скругленный прямоугольник',
|
||||
rm.NodeShape.circle: 'Круг',
|
||||
rm.NodeShape.diamond: 'Ромб',
|
||||
rm.NodeShape.hexagon: 'Шестиугольник',
|
||||
rm.NodeShape.ellipse: 'Эллипс',
|
||||
rm.NodeShape.rectangle: 'Прямоугольник',
|
||||
},
|
||||
),
|
||||
_buildDropdown<rm.CameraFocus>(
|
||||
'Фокус камеры',
|
||||
_defaultCamera,
|
||||
rm.CameraFocus.values,
|
||||
(value) => setState(() => _defaultCamera = value!),
|
||||
labels: const {
|
||||
rm.CameraFocus.fitAll: 'Вместить всё',
|
||||
rm.CameraFocus.rootNode: 'Корневой узел',
|
||||
rm.CameraFocus.center: 'Центр',
|
||||
rm.CameraFocus.firstLeaf: 'Первый лист',
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection(
|
||||
'Поведение',
|
||||
[
|
||||
SwitchListTile(
|
||||
title: const Text('Авто-раскрытие узлов'),
|
||||
subtitle: const Text('Раскрывать все узлы при загрузке'),
|
||||
value: _autoExpandNodes,
|
||||
onChanged: (value) =>
|
||||
setState(() => _autoExpandNodes = value),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать соединения'),
|
||||
value: _showConnections,
|
||||
onChanged: (value) =>
|
||||
setState(() => _showConnections = value),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Тени узлов'),
|
||||
value: _enableShadows,
|
||||
onChanged: (value) => setState(() => _enableShadows = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection(
|
||||
'Соединительные линии',
|
||||
[
|
||||
ListTile(
|
||||
title: const Text('Толщина линий'),
|
||||
subtitle: Text('${_connectionWidth.toStringAsFixed(1)}px'),
|
||||
trailing: SizedBox(
|
||||
width: 150,
|
||||
child: Slider(
|
||||
value: _connectionWidth,
|
||||
min: 1.0,
|
||||
max: 5.0,
|
||||
divisions: 8,
|
||||
label: _connectionWidth.toStringAsFixed(1),
|
||||
onChanged: (value) =>
|
||||
setState(() => _connectionWidth = value),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Цвет линий'),
|
||||
trailing: GestureDetector(
|
||||
onTap: () => _showColorPicker('connection'),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _connectionColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSection(
|
||||
'Цвета узлов',
|
||||
[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(_nodeColors.length, (index) {
|
||||
return GestureDetector(
|
||||
onTap: () => _showColorPicker('node_$index'),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _nodeColors[index],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Нажмите на цвет чтобы изменить',
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildSection(
|
||||
'Экспорт / Импорт',
|
||||
[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _exportMindMap,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Экспорт JSON'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _importMindMap,
|
||||
icon: const Icon(Icons.upload),
|
||||
label: const Text('Импорт JSON'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildResetButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(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 _buildDropdown<T>(
|
||||
String label,
|
||||
T value,
|
||||
List<T> items,
|
||||
void Function(T?) onChanged, {
|
||||
Map<T, String>? labels,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: DropdownButtonFormField<T>(
|
||||
value: value,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: items.map((item) {
|
||||
return DropdownMenuItem(
|
||||
value: item,
|
||||
child: Text(labels?[item] ?? item.toString().split('.').last),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showColorPicker(String type) async {
|
||||
final colors = [
|
||||
const Color(0xFF2196F3),
|
||||
const Color(0xFF4CAF50),
|
||||
const Color(0xFFFF9800),
|
||||
const Color(0xFFE91E63),
|
||||
const Color(0xFF9C27B0),
|
||||
const Color(0xFF00BCD4),
|
||||
const Color(0xFFF44336),
|
||||
const Color(0xFF795548),
|
||||
const Color(0xFF607D8B),
|
||||
const Color(0xFFB0BEC5),
|
||||
];
|
||||
|
||||
final selected = await showDialog<Color>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Выберите цвет'),
|
||||
content: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: colors.map((color) {
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pop(context, color),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
if (type == 'connection') {
|
||||
_connectionColor = selected;
|
||||
} else if (type.startsWith('node_')) {
|
||||
final index = int.parse(type.split('_')[1]);
|
||||
_nodeColors[index] = selected;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildResetButton() {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_defaultLayout = rm.MindMapLayout.radial;
|
||||
_defaultShape = rm.NodeShape.roundedRectangle;
|
||||
_defaultCamera = rm.CameraFocus.fitAll;
|
||||
_autoExpandNodes = true;
|
||||
_showConnections = true;
|
||||
_enableShadows = true;
|
||||
_connectionWidth = 2.0;
|
||||
_connectionColor = const Color(0xFFB0BEC5);
|
||||
_nodeColors = const [
|
||||
Color(0xFF2196F3),
|
||||
Color(0xFF4CAF50),
|
||||
Color(0xFFFF9800),
|
||||
Color(0xFFE91E63),
|
||||
Color(0xFF9C27B0),
|
||||
];
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.restore, color: Colors.red),
|
||||
label: const Text('Сбросить настройки',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||
import '../models/mindmap_model.dart';
|
||||
|
||||
class MindMapViewerScreen extends StatefulWidget {
|
||||
final MindMapModel mindMap;
|
||||
|
||||
const MindMapViewerScreen({super.key, required this.mindMap});
|
||||
|
||||
@override
|
||||
State<MindMapViewerScreen> createState() => _MindMapViewerScreenState();
|
||||
}
|
||||
|
||||
class _MindMapViewerScreenState extends State<MindMapViewerScreen> {
|
||||
rm.MindMapLayout _currentLayout = rm.MindMapLayout.radial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.mindMap.title),
|
||||
actions: [
|
||||
PopupMenuButton<rm.MindMapLayout>(
|
||||
icon: const Icon(Icons.account_tree),
|
||||
tooltip: 'Тип layout',
|
||||
onSelected: (layout) => setState(() => _currentLayout = layout),
|
||||
itemBuilder: (context) => [
|
||||
_popupMenuItem(
|
||||
rm.MindMapLayout.radial, 'Радиальный', Icons.circle_outlined),
|
||||
_popupMenuItem(
|
||||
rm.MindMapLayout.right, 'Вправо', Icons.arrow_forward),
|
||||
_popupMenuItem(rm.MindMapLayout.left, 'Влево', Icons.arrow_back),
|
||||
_popupMenuItem(rm.MindMapLayout.horizontal, 'Горизонтальный',
|
||||
Icons.swap_horiz),
|
||||
_popupMenuItem(
|
||||
rm.MindMapLayout.vertical, 'Вертикальный', Icons.swap_vert),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: widget.mindMap.nodes.isEmpty
|
||||
? _buildEmptyState()
|
||||
: rm.MindMapWidget(
|
||||
data: _buildMindMapData(),
|
||||
style: rm.MindMapStyle(
|
||||
layout: _currentLayout,
|
||||
nodeShape: rm.NodeShape.roundedRectangle,
|
||||
// Используем стандартные узлы (без кастомизации)
|
||||
connectionColor: Colors.blue.shade100,
|
||||
connectionWidth: 2.0,
|
||||
),
|
||||
cameraFocus: rm.CameraFocus.fitAll,
|
||||
onNodeTap: (node) {
|
||||
// TODO: Редактирование узла
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rm.MindMapData _buildMindMapData() {
|
||||
// Находим корневой узел (level 0)
|
||||
final rootNodes =
|
||||
widget.mindMap.nodes.where((n) => n['level'] == 0).toList();
|
||||
|
||||
if (rootNodes.isEmpty) {
|
||||
return rm.MindMapData(
|
||||
id: 'empty',
|
||||
title: widget.mindMap.title,
|
||||
children: [],
|
||||
);
|
||||
}
|
||||
|
||||
final root = rootNodes.first;
|
||||
return rm.MindMapData(
|
||||
id: root['id'] as String,
|
||||
title: root['title'] as String,
|
||||
color: _hexToColor(root['color'] as String? ?? '#2196F3'),
|
||||
children: _buildChildren(root['id'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
List<rm.MindMapData> _buildChildren(String parentId) {
|
||||
final children =
|
||||
widget.mindMap.nodes.where((n) => n['parentId'] == parentId).toList();
|
||||
|
||||
return children.map((node) {
|
||||
return rm.MindMapData(
|
||||
id: node['id'] as String,
|
||||
title: node['title'] as String,
|
||||
color: _hexToColor(node['color'] as String? ?? '#4CAF50'),
|
||||
children: _buildChildren(node['id'] as String),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Widget _buildCustomNode(
|
||||
rm.MindMapData node,
|
||||
VoidCallback onTap,
|
||||
VoidCallback onLongPress,
|
||||
bool isSelected,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: node.color ?? const Color(0xFF2196F3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? Border.all(color: Colors.white, width: 3) : null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, color: Colors.white70, size: 12),
|
||||
const SizedBox(width: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: Text(
|
||||
node.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
if (node.children.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.keyboard_arrow_down, color: Colors.white70, size: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Mind map пуста',
|
||||
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Добавьте узлы для начала работы',
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<rm.MindMapLayout> _popupMenuItem(
|
||||
rm.MindMapLayout layout,
|
||||
String title,
|
||||
IconData icon,
|
||||
) {
|
||||
return PopupMenuItem(
|
||||
value: layout,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(title),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _hexToColor(String hex) {
|
||||
hex = hex.replaceAll('#', '');
|
||||
if (hex.length == 6) {
|
||||
return Color(int.parse('FF$hex', radix: 16));
|
||||
}
|
||||
return const Color(0xFF2196F3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerStatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
bool _isEditing = false;
|
||||
final _nameController = TextEditingController();
|
||||
String? _photoUrl;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
_nameController.text = user.displayName ?? '';
|
||||
_photoUrl = user.photoURL;
|
||||
}
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _saveProfile() async {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
await user.updateDisplayName(_nameController.text);
|
||||
await FirebaseFirestore.instance
|
||||
.collection('users')
|
||||
.doc(user.uid)
|
||||
.update({
|
||||
'displayName': _nameController.text,
|
||||
'updatedAt': Timestamp.now(),
|
||||
});
|
||||
|
||||
setState(() => _isEditing = false);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('✅ Профиль сохранён'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Профиль'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_isEditing ? Icons.check : Icons.edit),
|
||||
onPressed: () {
|
||||
if (_isEditing) {
|
||||
_saveProfile();
|
||||
} else {
|
||||
setState(() => _isEditing = true);
|
||||
}
|
||||
},
|
||||
tooltip: _isEditing ? 'Сохранить' : 'Редактировать',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Аватар
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: Colors.blue,
|
||||
backgroundImage: _photoUrl != null
|
||||
? NetworkImage(_photoUrl!)
|
||||
: null,
|
||||
child: _photoUrl == null
|
||||
? Text(
|
||||
user?.displayName?.substring(0, 1).toUpperCase() ??
|
||||
'U',
|
||||
style: const TextStyle(
|
||||
fontSize: 40,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (_isEditing)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.camera_alt,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Имя
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
enabled: _isEditing,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Имя',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email
|
||||
ListTile(
|
||||
leading: const Icon(Icons.email),
|
||||
title: const Text('Email'),
|
||||
subtitle: Text(user?.email ?? 'Не указан'),
|
||||
enabled: false,
|
||||
),
|
||||
|
||||
// UID
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fingerprint),
|
||||
title: const Text('User ID'),
|
||||
subtitle: Text(user?.uid ?? 'Неизвестно'),
|
||||
enabled: false,
|
||||
),
|
||||
|
||||
const Divider(height: 32),
|
||||
|
||||
// Статистика
|
||||
_buildStatCard(
|
||||
'Статистика',
|
||||
[
|
||||
_buildStatRow('Задач создано', '12'),
|
||||
_buildStatRow('Mind-карт', '5'),
|
||||
_buildStatRow('Будильников', '3'),
|
||||
_buildStatRow('Дней в приложении', '30'),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Кнопка выхода
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await FirebaseAuth.instance.signOut();
|
||||
},
|
||||
icon: const Icon(Icons.logout, color: Colors.red),
|
||||
label: const Text('Выйти', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
||||
TaskPriority _priority = TaskPriority.medium;
|
||||
DateTime? _dueDate;
|
||||
int _estimatedHours = 0;
|
||||
List<AlarmData> _alarms = [];
|
||||
final List<AlarmData> _alarms = [];
|
||||
bool _isGenerating = false;
|
||||
|
||||
@override
|
||||
@@ -267,7 +267,7 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
||||
|
||||
// Приоритет
|
||||
DropdownButtonFormField<TaskPriority>(
|
||||
value: _priority,
|
||||
initialValue: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Приоритет',
|
||||
border: OutlineInputBorder(),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Карточка статистики
|
||||
class StatCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const StatCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: 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: [
|
||||
Icon(icon, color: color ?? Colors.blue),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Пустое состояние
|
||||
class EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget? action;
|
||||
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 80, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Загрузочный экран
|
||||
class LoadingScreen extends StatelessWidget {
|
||||
final String? message;
|
||||
|
||||
const LoadingScreen({super.key, this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Экран ошибки
|
||||
class ErrorScreen extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Ошибка',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user