Add full CRUD tasks with AI integration
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../screens/auth_screen.dart';
|
import '../screens/auth_screen.dart';
|
||||||
import '../screens/home_screen.dart';
|
import '../screens/home_screen.dart';
|
||||||
import '../screens/tasks_screen.dart';
|
import '../screens/tasks_screen.dart';
|
||||||
|
import '../screens/task_form_screen.dart';
|
||||||
import '../screens/mind_map_screen.dart';
|
import '../screens/mind_map_screen.dart';
|
||||||
import '../screens/calendar_screen.dart';
|
import '../screens/calendar_screen.dart';
|
||||||
import '../screens/alarms_screen.dart';
|
import '../screens/alarms_screen.dart';
|
||||||
@@ -25,6 +26,30 @@ class AppRouter {
|
|||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
name: 'tasks',
|
name: 'tasks',
|
||||||
builder: (context, state) => const TasksScreen(),
|
builder: (context, state) => const TasksScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'new',
|
||||||
|
name: 'new-task',
|
||||||
|
builder: (context, state) {
|
||||||
|
final args = state.extra as Map<String, dynamic>?;
|
||||||
|
return TaskFormScreen(
|
||||||
|
projectId: args?['projectId'] ?? 'default',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':taskId',
|
||||||
|
name: 'edit-task',
|
||||||
|
builder: (context, state) {
|
||||||
|
final args = state.extra as Map<String, dynamic>?;
|
||||||
|
final task = args?['task'];
|
||||||
|
return TaskFormScreen(
|
||||||
|
task: task,
|
||||||
|
projectId: args?['projectId'] ?? 'default',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'mindmap',
|
path: 'mindmap',
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/task_model.dart';
|
||||||
|
import '../models/project_model.dart';
|
||||||
|
import '../core/firestore_service.dart';
|
||||||
|
import '../core/local_storage_service.dart';
|
||||||
|
import '../core/deepseek_service.dart';
|
||||||
|
|
||||||
|
// Provider для списка всех задач
|
||||||
|
final tasksProvider = StateNotifierProvider<TasksNotifier, AsyncValue<List<TaskModel>>>((ref) {
|
||||||
|
return TasksNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provider для задач конкретного проекта
|
||||||
|
final tasksByProjectProvider = Provider.family<AsyncValue<List<TaskModel>>, String>((ref, projectId) {
|
||||||
|
final tasks = ref.watch(tasksProvider);
|
||||||
|
return tasks.when(
|
||||||
|
data: (allTasks) => AsyncValue.data(allTasks.where((t) => t.projectId == projectId).toList()),
|
||||||
|
loading: () => const AsyncValue.loading(),
|
||||||
|
error: (e, st) => AsyncValue.error(e, st),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provider для задач пользователя
|
||||||
|
final tasksByUserProvider = Provider.family<AsyncValue<List<TaskModel>>, String>((ref, userId) {
|
||||||
|
final tasks = ref.watch(tasksProvider);
|
||||||
|
return tasks.when(
|
||||||
|
data: (allTasks) => AsyncValue.data(allTasks.where((t) => t.assigneeId == userId).toList()),
|
||||||
|
loading: () => const AsyncValue.loading(),
|
||||||
|
error: (e, st) => AsyncValue.error(e, st),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
|
||||||
|
TasksNotifier() : super(const AsyncValue.loading()) {
|
||||||
|
_loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка задач
|
||||||
|
Future<void> _loadTasks() async {
|
||||||
|
try {
|
||||||
|
// Сначала загружаем из локального хранилища
|
||||||
|
final localTasks = localStorageService.getTasks();
|
||||||
|
state = AsyncValue.data(localTasks);
|
||||||
|
|
||||||
|
// Потом синхронизируем с Firestore
|
||||||
|
// TODO: Реализовать когда будет Firebase Auth
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание задачи
|
||||||
|
Future<TaskModel> createTask({
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
required String projectId,
|
||||||
|
String? parentTaskId,
|
||||||
|
TaskPriority priority = TaskPriority.medium,
|
||||||
|
DateTime? dueDate,
|
||||||
|
int estimatedHours = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final task = TaskModel(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
projectId: projectId,
|
||||||
|
parentTaskId: parentTaskId,
|
||||||
|
priority: priority,
|
||||||
|
dueDate: dueDate,
|
||||||
|
estimatedHours: estimatedHours,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сохраняем локально
|
||||||
|
await localStorageService.saveTask(task);
|
||||||
|
|
||||||
|
// Обновляем состояние
|
||||||
|
final currentTasks = state.value ?? [];
|
||||||
|
state = AsyncValue.data([...currentTasks, task]);
|
||||||
|
|
||||||
|
// TODO: Сохранить в Firestore когда будет авторизация
|
||||||
|
// await firestoreService.createTask(task);
|
||||||
|
|
||||||
|
return task;
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление задачи
|
||||||
|
Future<void> updateTask(String taskId, {
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
TaskStatus? status,
|
||||||
|
TaskPriority? priority,
|
||||||
|
DateTime? dueDate,
|
||||||
|
String? assigneeId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final currentTasks = state.value ?? [];
|
||||||
|
final taskIndex = currentTasks.indexWhere((t) => t.id == taskId);
|
||||||
|
|
||||||
|
if (taskIndex == -1) throw Exception('Task not found');
|
||||||
|
|
||||||
|
final oldTask = currentTasks[taskIndex];
|
||||||
|
final updatedTask = oldTask.copyWith(
|
||||||
|
title: title ?? oldTask.title,
|
||||||
|
description: description ?? oldTask.description,
|
||||||
|
status: status ?? oldTask.status,
|
||||||
|
priority: priority ?? oldTask.priority,
|
||||||
|
dueDate: dueDate ?? oldTask.dueDate,
|
||||||
|
assigneeId: assigneeId ?? oldTask.assigneeId,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сохраняем локально
|
||||||
|
await localStorageService.saveTask(updatedTask);
|
||||||
|
|
||||||
|
// Обновляем состояние
|
||||||
|
currentTasks[taskIndex] = updatedTask;
|
||||||
|
state = AsyncValue.data(List.from(currentTasks));
|
||||||
|
|
||||||
|
// TODO: Обновить в Firestore
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление задачи
|
||||||
|
Future<void> deleteTask(String taskId) async {
|
||||||
|
try {
|
||||||
|
// Удаляем локально
|
||||||
|
await localStorageService.deleteTask(taskId);
|
||||||
|
|
||||||
|
// Обновляем состояние
|
||||||
|
final currentTasks = state.value ?? [];
|
||||||
|
currentTasks.removeWhere((t) => t.id == taskId);
|
||||||
|
state = AsyncValue.data(List.from(currentTasks));
|
||||||
|
|
||||||
|
// TODO: Удалить из Firestore
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI: Генерация подзадач
|
||||||
|
Future<List<TaskModel>> generateSubtasksWithAI({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String projectId,
|
||||||
|
String? parentTaskId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final subtasksData = await deepSeekService.generateSubtasks(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
);
|
||||||
|
|
||||||
|
final subtasks = <TaskModel>[];
|
||||||
|
for (final subtaskData in subtasksData) {
|
||||||
|
final subtask = await createTask(
|
||||||
|
title: subtaskData['title'] ?? '',
|
||||||
|
description: subtaskData['description'],
|
||||||
|
projectId: projectId,
|
||||||
|
parentTaskId: parentTaskId,
|
||||||
|
estimatedHours: subtaskData['estimatedHours'] ?? 0,
|
||||||
|
);
|
||||||
|
subtasks.add(subtask);
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtasks;
|
||||||
|
} catch (e, st) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI: Приоритизация задачи
|
||||||
|
Future<TaskPriority> prioritizeTaskWithAI({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
DateTime? dueDate,
|
||||||
|
}) async {
|
||||||
|
return await deepSeekService.prioritizeTask(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
dueDate: dueDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация задач по статусу
|
||||||
|
List<TaskModel> filterByStatus(List<TaskModel> tasks, TaskStatus status) {
|
||||||
|
return tasks.where((t) => t.status == status).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация задач по приоритету
|
||||||
|
List<TaskModel> filterByPriority(List<TaskModel> tasks, TaskPriority priority) {
|
||||||
|
return tasks.where((t) => t.priority == priority).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Просроченные задачи
|
||||||
|
List<TaskModel> getOverdueTasks(List<TaskModel> tasks) {
|
||||||
|
return tasks.where((t) => t.isOverdue).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../providers/task_provider.dart';
|
||||||
|
import '../models/task_model.dart';
|
||||||
|
|
||||||
|
class TaskFormScreen extends ConsumerStatefulWidget {
|
||||||
|
final TaskModel? task;
|
||||||
|
final String projectId;
|
||||||
|
|
||||||
|
const TaskFormScreen({
|
||||||
|
super.key,
|
||||||
|
this.task,
|
||||||
|
required this.projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<TaskFormScreen> createState() => _TaskFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _titleController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
TaskPriority _priority = TaskPriority.medium;
|
||||||
|
DateTime? _dueDate;
|
||||||
|
int _estimatedHours = 0;
|
||||||
|
bool _isGenerating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.task != null) {
|
||||||
|
_titleController.text = widget.task!.title;
|
||||||
|
_descriptionController.text = widget.task!.description ?? '';
|
||||||
|
_priority = widget.task!.priority;
|
||||||
|
_dueDate = widget.task!.dueDate;
|
||||||
|
_estimatedHours = widget.task!.estimatedHours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDueDate() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _dueDate ?? DateTime.now(),
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_dueDate = picked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _generateSubtasksWithAI() async {
|
||||||
|
if (_titleController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Введите название задачи')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isGenerating = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(tasksProvider.notifier).generateSubtasksWithAI(
|
||||||
|
title: _titleController.text,
|
||||||
|
description: _descriptionController.text,
|
||||||
|
projectId: widget.projectId,
|
||||||
|
parentTaskId: widget.task?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Подзадачи успешно сгенерированы!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.pop(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isGenerating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prioritizeWithAI() async {
|
||||||
|
if (_titleController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Введите название задачи')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isGenerating = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final priority = await ref.read(tasksProvider.notifier).prioritizeTaskWithAI(
|
||||||
|
title: _titleController.text,
|
||||||
|
description: _descriptionController.text,
|
||||||
|
dueDate: _dueDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_priority = priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('✅ AI определил приоритет: ${priority.name}'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isGenerating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveTask() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (widget.task != null) {
|
||||||
|
// Редактирование
|
||||||
|
await ref.read(tasksProvider.notifier).updateTask(
|
||||||
|
widget.task!.id,
|
||||||
|
title: _titleController.text,
|
||||||
|
description: _descriptionController.text,
|
||||||
|
priority: _priority,
|
||||||
|
dueDate: _dueDate,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Создание
|
||||||
|
await ref.read(tasksProvider.notifier).createTask(
|
||||||
|
title: _titleController.text,
|
||||||
|
description: _descriptionController.text,
|
||||||
|
projectId: widget.projectId,
|
||||||
|
priority: _priority,
|
||||||
|
dueDate: _dueDate,
|
||||||
|
estimatedHours: _estimatedHours,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
context.pop(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isEdit = widget.task != null;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEdit ? 'Редактировать задачу' : 'Новая задача'),
|
||||||
|
actions: [
|
||||||
|
if (isEdit)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => _confirmDelete(),
|
||||||
|
tooltip: 'Удалить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Название
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Название *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.title),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Введите название';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Описание
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.description),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: 5,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// AI Кнопки
|
||||||
|
if (!isEdit) ...[
|
||||||
|
_buildAIButton(
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
label: 'AI: Создать подзадачи',
|
||||||
|
onPressed: _isGenerating ? null : _generateSubtasksWithAI,
|
||||||
|
color: Colors.purple,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildAIButton(
|
||||||
|
icon: Icons.priority_high,
|
||||||
|
label: 'AI: Определить приоритет',
|
||||||
|
onPressed: _isGenerating ? null : _prioritizeWithAI,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Приоритет
|
||||||
|
DropdownButtonFormField<TaskPriority>(
|
||||||
|
value: _priority,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Приоритет',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.flag),
|
||||||
|
),
|
||||||
|
items: TaskPriority.values.map((priority) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: priority,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_getPriorityIcon(priority),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_getPriorityLabel(priority)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_priority = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Дедлайн
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.calendar_today),
|
||||||
|
title: const Text('Дедлайн'),
|
||||||
|
subtitle: Text(_dueDate != null
|
||||||
|
? '📅 ${_dueDate!.day}.${_dueDate!.month}.${_dueDate!.year}'
|
||||||
|
: 'Не выбран'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _selectDueDate,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Оценка времени
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _estimatedHours.toString(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Оценка (часы)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.timer),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (value) {
|
||||||
|
_estimatedHours = int.tryParse(value) ?? 0;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Кнопка сохранения
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _saveTask,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: _isGenerating
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(isEdit ? 'Сохранить' : 'Создать задачу'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAIButton({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback? onPressed,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon, color: color),
|
||||||
|
label: Text(label),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
side: BorderSide(color: color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _getPriorityIcon(TaskPriority priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case TaskPriority.urgent:
|
||||||
|
return const Icon(Icons.fire_engine, color: Colors.red);
|
||||||
|
case TaskPriority.high:
|
||||||
|
return const Icon(Icons.arrow_upward, color: Colors.orange);
|
||||||
|
case TaskPriority.medium:
|
||||||
|
return const Icon(Icons.remove, color: Colors.blue);
|
||||||
|
case TaskPriority.low:
|
||||||
|
return const Icon(Icons.arrow_downward, color: Colors.green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPriorityLabel(TaskPriority priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case TaskPriority.urgent:
|
||||||
|
return 'Срочно';
|
||||||
|
case TaskPriority.high:
|
||||||
|
return 'Высокий';
|
||||||
|
case TaskPriority.medium:
|
||||||
|
return 'Средний';
|
||||||
|
case TaskPriority.low:
|
||||||
|
return 'Низкий';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDelete() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить задачу?'),
|
||||||
|
content: const Text('Это действие нельзя отменить'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(tasksProvider.notifier).deleteTask(widget.task!.id);
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.pop(true);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+317
-53
@@ -1,17 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../providers/task_provider.dart';
|
||||||
|
import '../models/task_model.dart';
|
||||||
|
|
||||||
class TasksScreen extends StatelessWidget {
|
class TasksScreen extends ConsumerWidget {
|
||||||
const TasksScreen({super.key});
|
const TasksScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tasksState = ref.watch(tasksProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Задачи'),
|
title: const Text('Задачи'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.filter_list),
|
icon: const Icon(Icons.filter_list),
|
||||||
onPressed: () {},
|
onPressed: () => _showFilterDialog(context),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
@@ -19,68 +25,326 @@ class TasksScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView.builder(
|
body: tasksState.when(
|
||||||
padding: const EdgeInsets.all(16),
|
data: (tasks) {
|
||||||
itemCount: 10,
|
if (tasks.isEmpty) {
|
||||||
itemBuilder: (context, index) {
|
return _buildEmptyState(context);
|
||||||
return _TaskCard(index: index);
|
}
|
||||||
|
|
||||||
|
// Группируем по статусам
|
||||||
|
final todoTasks =
|
||||||
|
tasks.where((t) => t.status == TaskStatus.todo).toList();
|
||||||
|
final inProgressTasks =
|
||||||
|
tasks.where((t) => t.status == TaskStatus.inProgress).toList();
|
||||||
|
final doneTasks =
|
||||||
|
tasks.where((t) => t.status == TaskStatus.done).toList();
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (todoTasks.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('В планах', todoTasks.length, Colors.blue),
|
||||||
|
...todoTasks.map((task) => _TaskCard(task: task)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (inProgressTasks.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader(
|
||||||
|
'В работе', inProgressTasks.length, Colors.orange),
|
||||||
|
...inProgressTasks.map((task) => _TaskCard(task: task)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (doneTasks.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('Готово', doneTasks.length, Colors.green),
|
||||||
|
...doneTasks.map((task) => _TaskCard(task: task)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Ошибка: $error'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => ref.refresh(tasksProvider),
|
||||||
|
child: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () {},
|
onPressed: () async {
|
||||||
|
final result = await context
|
||||||
|
.push('/home/tasks/new', extra: {'projectId': 'default'});
|
||||||
|
if (result == true) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('✅ Задача создана')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Новая задача'),
|
label: const Text('Новая задача'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _TaskCard extends StatelessWidget {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
final int index;
|
return Center(
|
||||||
|
child: Column(
|
||||||
const _TaskCard({required this.index});
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
@override
|
const Icon(
|
||||||
Widget build(BuildContext context) {
|
Icons.task_alt,
|
||||||
return Card(
|
size: 100,
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
color: Colors.grey,
|
||||||
child: ListTile(
|
),
|
||||||
leading: CircleAvatar(
|
const SizedBox(height: 24),
|
||||||
backgroundColor: _getPriorityColor(index),
|
const Text(
|
||||||
child: Text('${index + 1}'),
|
'Нет задач',
|
||||||
),
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
title: Text('Задача #${index + 1}'),
|
),
|
||||||
subtitle: Column(
|
const SizedBox(height: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const Text(
|
||||||
children: [
|
'Создайте первую задачу',
|
||||||
const SizedBox(height: 4),
|
style: TextStyle(color: Colors.grey),
|
||||||
Text('Описание задачи...'),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 32),
|
||||||
Row(
|
ElevatedButton.icon(
|
||||||
children: [
|
onPressed: () => context
|
||||||
Icon(Icons.calendar_today, size: 12, color: Colors.grey),
|
.push('/home/tasks/new', extra: {'projectId': 'default'}),
|
||||||
const SizedBox(width: 4),
|
icon: const Icon(Icons.add),
|
||||||
Text(
|
label: const Text('Создать задачу'),
|
||||||
'До: ${DateTime.now().add(Duration(days: index)).toString().split(' ')[0]}',
|
),
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: PopupMenuButton(
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
const PopupMenuItem(value: 'edit', child: Text('Редактировать')),
|
|
||||||
const PopupMenuItem(value: 'delete', child: Text('Удалить')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getPriorityColor(int index) {
|
Widget _buildSectionHeader(String title, int count, Color color) {
|
||||||
final colors = [Colors.red, Colors.orange, Colors.blue, Colors.green];
|
return Padding(
|
||||||
return colors[index % colors.length];
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: TextStyle(color: color, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFilterDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Фильтр задач'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.all_inclusive),
|
||||||
|
title: const Text('Все задачи'),
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.pending, color: Colors.blue),
|
||||||
|
title: const Text('В планах'),
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.hourglass_empty, color: Colors.orange),
|
||||||
|
title: const Text('В работе'),
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.check_circle, color: Colors.green),
|
||||||
|
title: const Text('Готово'),
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskCard extends ConsumerWidget {
|
||||||
|
final TaskModel task;
|
||||||
|
|
||||||
|
const _TaskCard({required this.task});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: _getPriorityColor(task.priority),
|
||||||
|
child: Icon(
|
||||||
|
_getPriorityIcon(task.priority),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
task.title,
|
||||||
|
style: TextStyle(
|
||||||
|
decoration: task.status == TaskStatus.done
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
color: task.status == TaskStatus.done ? Colors.grey : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (task.description != null && task.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
task.description!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (task.dueDate != null) ...[
|
||||||
|
Icon(
|
||||||
|
task.isOverdue ? Icons.warning : Icons.calendar_today,
|
||||||
|
size: 12,
|
||||||
|
color: task.isOverdue ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${task.dueDate!.day}.${task.dueDate!.month}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: task.isOverdue ? Colors.red : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
if (task.estimatedHours > 0) ...[
|
||||||
|
const Icon(Icons.timer, size: 12, color: Colors.grey),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${task.estimatedHours}ч',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
onSelected: (value) => _handleMenuAction(context, ref, value),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(value: 'edit', child: Text('Редактировать')),
|
||||||
|
const PopupMenuItem(value: 'duplicate', child: Text('Дублировать')),
|
||||||
|
const PopupMenuItem(value: 'delete', child: Text('Удалить')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
onTap: () => context.push('/home/tasks/${task.id}', extra: {
|
||||||
|
'task': task,
|
||||||
|
'projectId': task.projectId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getPriorityColor(TaskPriority priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case TaskPriority.urgent:
|
||||||
|
return Colors.red;
|
||||||
|
case TaskPriority.high:
|
||||||
|
return Colors.orange;
|
||||||
|
case TaskPriority.medium:
|
||||||
|
return Colors.blue;
|
||||||
|
case TaskPriority.low:
|
||||||
|
return Colors.green;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getPriorityIcon(TaskPriority priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case TaskPriority.urgent:
|
||||||
|
return Icons.fire_engine;
|
||||||
|
case TaskPriority.high:
|
||||||
|
return Icons.arrow_upward;
|
||||||
|
case TaskPriority.medium:
|
||||||
|
return Icons.remove;
|
||||||
|
case TaskPriority.low:
|
||||||
|
return Icons.arrow_downward;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMenuAction(BuildContext context, WidgetRef ref, String action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'edit':
|
||||||
|
context.push('/home/tasks/${task.id}', extra: {
|
||||||
|
'task': task,
|
||||||
|
'projectId': task.projectId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
_confirmDelete(context, ref);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDelete(BuildContext context, WidgetRef ref) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить задачу?'),
|
||||||
|
content: const Text('Это действие нельзя отменить'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(tasksProvider.notifier).deleteTask(task.id);
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('✅ Задача удалена')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user