Add full CRUD tasks with AI integration

This commit is contained in:
2026-03-27 18:57:56 +05:00
parent c5072a8a84
commit 2336df8e86
4 changed files with 968 additions and 53 deletions
+416
View File
@@ -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
View File
@@ -1,17 +1,23 @@
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});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final tasksState = ref.watch(tasksProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Задачи'),
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () {},
onPressed: () => _showFilterDialog(context),
),
IconButton(
icon: const Icon(Icons.search),
@@ -19,68 +25,326 @@ class TasksScreen extends StatelessWidget {
),
],
),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 10,
itemBuilder: (context, index) {
return _TaskCard(index: index);
body: tasksState.when(
data: (tasks) {
if (tasks.isEmpty) {
return _buildEmptyState(context);
}
// Группируем по статусам
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(
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),
label: const Text('Новая задача'),
),
);
}
}
class _TaskCard extends StatelessWidget {
final int index;
const _TaskCard({required this.index});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getPriorityColor(index),
child: Text('${index + 1}'),
),
title: Text('Задача #${index + 1}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('Описание задачи...'),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.calendar_today, size: 12, color: Colors.grey),
const SizedBox(width: 4),
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,
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.task_alt,
size: 100,
color: Colors.grey,
),
const SizedBox(height: 24),
const Text(
'Нет задач',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Создайте первую задачу',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () => context
.push('/home/tasks/new', extra: {'projectId': 'default'}),
icon: const Icon(Icons.add),
label: const Text('Создать задачу'),
),
],
),
);
}
Color _getPriorityColor(int index) {
final colors = [Colors.red, Colors.orange, Colors.blue, Colors.green];
return colors[index % colors.length];
Widget _buildSectionHeader(String title, int count, Color color) {
return Padding(
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('Удалить'),
),
],
),
);
}
}