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,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('Добавить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user