f1e0f6b5ef
- 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
836 lines
26 KiB
Dart
836 lines
26 KiB
Dart
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('Добавить'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|