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:
2026-04-19 22:18:25 +05:00
parent 52aea9ac87
commit f1e0f6b5ef
56 changed files with 8892 additions and 397 deletions
+290
View File
@@ -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
View File
@@ -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),
),
],
),
),
);
+8 -9
View File
@@ -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;
});
}
},
-1
View File
@@ -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});
+515
View File
@@ -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
View File
@@ -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}';
}
}
+464
View File
@@ -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)),
);
}
}
+193
View File
@@ -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);
}
}
+237
View File
@@ -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,
),
),
],
),
);
}
}
+2 -2
View File
@@ -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(),