feat(web_v2): Create Task Creation Form UI
Task ID: WEB-002 Priority: high Changes: Completed by: AI Agent Duration: 899654ms
This commit is contained in:
parent
dbcc7a1ab1
commit
5e8aeaad39
12 changed files with 2008 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"component": "web_v2",
|
"component": "web_v2",
|
||||||
"current_task_id": "WEB-001",
|
"current_task_id": "WEB-002",
|
||||||
"iteration_count": 6,
|
"iteration_count": 7,
|
||||||
"max_iterations": 10,
|
"max_iterations": 10,
|
||||||
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
||||||
"last_commit": null,
|
"last_commit": null,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"id": "WEB-002",
|
"id": "WEB-002",
|
||||||
"title": "Create Task Creation Form UI",
|
"title": "Create Task Creation Form UI",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"status": "pending",
|
"status": "in_progress",
|
||||||
"estimated_hours": 3.0,
|
"estimated_hours": 3.0,
|
||||||
"description": "Create a new page/widget for task creation with form fields: title, description, type, difficulty, rewards, expiration date, instructions, tags. Include validation and proper error handling. Follow app design patterns.",
|
"description": "Create a new page/widget for task creation with form fields: title, description, type, difficulty, rewards, expiration date, instructions, tags. Include validation and proper error handling. Follow app design patterns.",
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,11 @@ class ApiConfigV2 {
|
||||||
/// Mark task as in progress
|
/// Mark task as in progress
|
||||||
static String taskStart(String taskId) => '/tasks/$taskId/start';
|
static String taskStart(String taskId) => '/tasks/$taskId/start';
|
||||||
|
|
||||||
|
/// POST /api/v2/tasks
|
||||||
|
/// Create a new task
|
||||||
|
/// Body: Task creation request
|
||||||
|
static const String tasksCreate = '/tasks';
|
||||||
|
|
||||||
/// GET /api/v2/users/me/tasks/progress
|
/// GET /api/v2/users/me/tasks/progress
|
||||||
/// Get user's task progress and statistics
|
/// Get user's task progress and statistics
|
||||||
static const String usersMeTasksProgress = '/users/me/tasks/progress';
|
static const String usersMeTasksProgress = '/users/me/tasks/progress';
|
||||||
|
|
|
||||||
|
|
@ -1645,6 +1645,45 @@ class HttpRepositoryV2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new task
|
||||||
|
Future<Task> createTask({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required TaskType type,
|
||||||
|
required TaskDifficulty difficulty,
|
||||||
|
required List<TaskReward> rewards,
|
||||||
|
required DateTime expiresAt,
|
||||||
|
String? instructions,
|
||||||
|
List<String>? tags,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'type': type.name,
|
||||||
|
'difficulty': difficulty.name,
|
||||||
|
'rewards': rewards.map((r) => r.toJson()).toList(),
|
||||||
|
'expiresAt': expiresAt.toIso8601String(),
|
||||||
|
if (instructions != null) 'instructions': instructions,
|
||||||
|
if (tags != null) 'tags': tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
|
ApiConfigV2.tasksCreate,
|
||||||
|
data: jsonEncode(body),
|
||||||
|
);
|
||||||
|
return Task.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
throw NetworkException(
|
||||||
|
message: e.message ?? 'Failed to create task',
|
||||||
|
originalError: e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get task categories and available filters
|
/// Get task categories and available filters
|
||||||
Future<TaskCategories> getTaskCategories() async {
|
Future<TaskCategories> getTaskCategories() async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,42 @@ class TasksRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new task
|
||||||
|
Future<Task> createTask({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required TaskType type,
|
||||||
|
required TaskDifficulty difficulty,
|
||||||
|
required List<TaskReward> rewards,
|
||||||
|
required DateTime expiresAt,
|
||||||
|
String? instructions,
|
||||||
|
List<String>? tags,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final task = await _httpRepository.createTask(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
type: type,
|
||||||
|
difficulty: difficulty,
|
||||||
|
rewards: rewards,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
instructions: instructions,
|
||||||
|
tags: tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
log('Created task: ${task.id}', name: 'TasksRepository');
|
||||||
|
return task;
|
||||||
|
} catch (e, s) {
|
||||||
|
log(
|
||||||
|
'Error creating task',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
name: 'TasksRepository',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update task status
|
/// Update task status
|
||||||
Future<Task> updateTaskStatus(String taskId, TaskStatus status, {
|
Future<Task> updateTaskStatus(String taskId, TaskStatus status, {
|
||||||
String? proofUrl,
|
String? proofUrl,
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,51 @@ class TasksStateManager extends StateManager<TasksState> {
|
||||||
/// Refresh data
|
/// Refresh data
|
||||||
Future<void> refresh({String? userId}) => loadTasks(userId: userId);
|
Future<void> refresh({String? userId}) => loadTasks(userId: userId);
|
||||||
|
|
||||||
|
/// Create a new task
|
||||||
|
Future<void> createTask({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required TaskType type,
|
||||||
|
required TaskDifficulty difficulty,
|
||||||
|
required List<TaskReward> rewards,
|
||||||
|
required DateTime expiresAt,
|
||||||
|
String? instructions,
|
||||||
|
List<String>? tags,
|
||||||
|
}) => handle((emit) async {
|
||||||
|
log('Creating task: $title', name: 'TasksStateManager');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newTask = await _repository.createTask(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
type: type,
|
||||||
|
difficulty: difficulty,
|
||||||
|
rewards: rewards,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
instructions: instructions,
|
||||||
|
tags: tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the new task to the current list
|
||||||
|
final updatedTasks = [newTask, ...state.tasks];
|
||||||
|
|
||||||
|
emit(state.copyWith(
|
||||||
|
tasks: updatedTasks,
|
||||||
|
error: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
log('Created task: ${newTask.id}', name: 'TasksStateManager');
|
||||||
|
} catch (e, s) {
|
||||||
|
log(
|
||||||
|
'Error creating task',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
name: 'TasksStateManager',
|
||||||
|
);
|
||||||
|
emit(TasksState.error('Failed to create task: ${e.toString()}'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/// Clear error state
|
/// Clear error state
|
||||||
Future<void> clearError() => handle((emit) async {
|
Future<void> clearError() => handle((emit) async {
|
||||||
if (state.error != null) {
|
if (state.error != null) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
|
import 'package:yx_state_flutter/yx_state_flutter.dart';
|
||||||
|
|
||||||
|
import '../../../di/user_scope/user_scope.dart';
|
||||||
|
import '../../../domain/models/task_models.dart';
|
||||||
|
import '../../../domain/state/tasks_state_manager.dart';
|
||||||
|
import '../../widgets/task_form.dart';
|
||||||
|
|
||||||
|
/// Page for creating a new task
|
||||||
|
class CreateTaskPage extends StatelessWidget {
|
||||||
|
const CreateTaskPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Создать задание'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: ScopeBuilder<UserScope>(
|
||||||
|
builder: (context, userScope) {
|
||||||
|
if (userScope == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('User scope not available'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StateBuilder<TasksState>(
|
||||||
|
stateReadable: userScope.tasksStateManager,
|
||||||
|
builder: (context, tasksState, _) {
|
||||||
|
return TaskForm(
|
||||||
|
onSubmit: (formData) => _handleSubmit(
|
||||||
|
context,
|
||||||
|
userScope.tasksStateManager,
|
||||||
|
formData,
|
||||||
|
),
|
||||||
|
errorMessage: tasksState.error,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit(
|
||||||
|
BuildContext context,
|
||||||
|
TasksStateManager stateManager,
|
||||||
|
TaskFormData formData,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await stateManager.createTask(
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
type: formData.type,
|
||||||
|
difficulty: formData.difficulty,
|
||||||
|
rewards: formData.rewards,
|
||||||
|
expiresAt: formData.expiresAt,
|
||||||
|
instructions: formData.instructions,
|
||||||
|
tags: formData.tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear error state if submission was successful
|
||||||
|
await stateManager.clearError();
|
||||||
|
|
||||||
|
// Show success message and navigate back
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Задание успешно создано!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Error is already handled by the state manager
|
||||||
|
// The error will be displayed in the form via errorMessage
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Ошибка при создании задания: ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
import 'package:yx_state_flutter/yx_state_flutter.dart';
|
import 'package:yx_state_flutter/yx_state_flutter.dart';
|
||||||
|
|
||||||
|
|
@ -130,10 +131,23 @@ class _TasksPageState extends State<TasksPage> with TickerProviderStateMixin {
|
||||||
_buildTasksList(status: TaskStatus.completed), // Completed
|
_buildTasksList(status: TaskStatus.completed), // Completed
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: Column(
|
||||||
onPressed: _refreshTasks,
|
mainAxisSize: MainAxisSize.min,
|
||||||
tooltip: 'Обновить задания',
|
children: [
|
||||||
child: const Icon(Icons.refresh),
|
FloatingActionButton(
|
||||||
|
onPressed: () => context.push('/tasks/create'),
|
||||||
|
tooltip: 'Создать задание',
|
||||||
|
heroTag: 'create_task',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FloatingActionButton(
|
||||||
|
onPressed: _refreshTasks,
|
||||||
|
tooltip: 'Обновить задания',
|
||||||
|
heroTag: 'refresh_tasks',
|
||||||
|
child: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../pages/pack_details/pack_details_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
import '../pages/purchase/purchase_page.dart';
|
import '../pages/purchase/purchase_page.dart';
|
||||||
import '../pages/statistics/statistics_page.dart';
|
import '../pages/statistics/statistics_page.dart';
|
||||||
|
import '../pages/tasks/create_task_page.dart';
|
||||||
import '../pages/tasks/tasks_page.dart';
|
import '../pages/tasks/tasks_page.dart';
|
||||||
import '../pages/test/test_page.dart';
|
import '../pages/test/test_page.dart';
|
||||||
import '../widgets/main_shell.dart';
|
import '../widgets/main_shell.dart';
|
||||||
|
|
@ -46,6 +47,13 @@ GoRouter createAppRouter({
|
||||||
child: TasksPage(),
|
child: TasksPage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tasks/create',
|
||||||
|
name: 'create-task',
|
||||||
|
pageBuilder: (context, state) => const MaterialPage(
|
||||||
|
child: CreateTaskPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/statistics',
|
path: '/statistics',
|
||||||
name: 'statistics',
|
name: 'statistics',
|
||||||
|
|
|
||||||
961
mnemo_cards_web_v2/lib/presentation/widgets/task_form.dart
Normal file
961
mnemo_cards_web_v2/lib/presentation/widgets/task_form.dart
Normal file
|
|
@ -0,0 +1,961 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../domain/models/task_models.dart';
|
||||||
|
|
||||||
|
/// Form data for task creation
|
||||||
|
class TaskFormData {
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final TaskType type;
|
||||||
|
final TaskDifficulty difficulty;
|
||||||
|
final List<TaskReward> rewards;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
final String? instructions;
|
||||||
|
final List<String>? tags;
|
||||||
|
|
||||||
|
TaskFormData({
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.type,
|
||||||
|
required this.difficulty,
|
||||||
|
required this.rewards,
|
||||||
|
required this.expiresAt,
|
||||||
|
this.instructions,
|
||||||
|
this.tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation errors for task form
|
||||||
|
class TaskFormErrors {
|
||||||
|
final String? title;
|
||||||
|
final String? description;
|
||||||
|
final String? type;
|
||||||
|
final String? difficulty;
|
||||||
|
final String? rewards;
|
||||||
|
final String? expiresAt;
|
||||||
|
final String? instructions;
|
||||||
|
final String? tags;
|
||||||
|
|
||||||
|
const TaskFormErrors({
|
||||||
|
this.title,
|
||||||
|
this.description,
|
||||||
|
this.type,
|
||||||
|
this.difficulty,
|
||||||
|
this.rewards,
|
||||||
|
this.expiresAt,
|
||||||
|
this.instructions,
|
||||||
|
this.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasErrors =>
|
||||||
|
title != null ||
|
||||||
|
description != null ||
|
||||||
|
type != null ||
|
||||||
|
difficulty != null ||
|
||||||
|
rewards != null ||
|
||||||
|
expiresAt != null ||
|
||||||
|
instructions != null ||
|
||||||
|
tags != null;
|
||||||
|
|
||||||
|
TaskFormErrors copyWith({
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
String? type,
|
||||||
|
String? difficulty,
|
||||||
|
String? rewards,
|
||||||
|
String? expiresAt,
|
||||||
|
String? instructions,
|
||||||
|
String? tags,
|
||||||
|
}) {
|
||||||
|
return TaskFormErrors(
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
type: type ?? this.type,
|
||||||
|
difficulty: difficulty ?? this.difficulty,
|
||||||
|
rewards: rewards ?? this.rewards,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
instructions: instructions ?? this.instructions,
|
||||||
|
tags: tags ?? this.tags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Form widget for creating/editing tasks
|
||||||
|
class TaskForm extends StatefulWidget {
|
||||||
|
const TaskForm({
|
||||||
|
required this.onSubmit,
|
||||||
|
this.initialData,
|
||||||
|
this.errorMessage,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Future<void> Function(TaskFormData) onSubmit;
|
||||||
|
final TaskFormData? initialData;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TaskForm> createState() => _TaskFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskFormState extends State<TaskForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _titleController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
final _instructionsController = TextEditingController();
|
||||||
|
final _tagsController = TextEditingController();
|
||||||
|
|
||||||
|
TaskType? _selectedType;
|
||||||
|
TaskDifficulty? _selectedDifficulty;
|
||||||
|
DateTime? _selectedExpiresAt;
|
||||||
|
List<TaskReward> _rewards = [];
|
||||||
|
TaskFormErrors _errors = const TaskFormErrors();
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.initialData != null) {
|
||||||
|
final data = widget.initialData!;
|
||||||
|
_titleController.text = data.title;
|
||||||
|
_descriptionController.text = data.description;
|
||||||
|
_instructionsController.text = data.instructions ?? '';
|
||||||
|
_tagsController.text = data.tags?.join(', ') ?? '';
|
||||||
|
_selectedType = data.type;
|
||||||
|
_selectedDifficulty = data.difficulty;
|
||||||
|
_selectedExpiresAt = data.expiresAt;
|
||||||
|
_rewards = List.from(data.rewards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_instructionsController.dispose();
|
||||||
|
_tagsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Error message display
|
||||||
|
if (widget.errorMessage != null) ...[
|
||||||
|
_buildErrorMessage(widget.errorMessage!),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Title field
|
||||||
|
_buildTitleField(theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Description field
|
||||||
|
_buildDescriptionField(theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Type and Difficulty row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildTypeField(theme)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: _buildDifficultyField(theme)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Expiration date field
|
||||||
|
_buildExpirationDateField(theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Rewards section
|
||||||
|
_buildRewardsSection(theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Instructions field
|
||||||
|
_buildInstructionsField(theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Tags field
|
||||||
|
_buildTagsField(theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Submit button
|
||||||
|
_buildSubmitButton(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage(String message) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.red.shade200),
|
||||||
|
),
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
const WidgetSpan(
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const WidgetSpan(child: SizedBox(width: 8)),
|
||||||
|
TextSpan(
|
||||||
|
text: message,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitleField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Название',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Введите название задания',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.title,
|
||||||
|
),
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Название обязательно';
|
||||||
|
}
|
||||||
|
if (value.trim().length < 3) {
|
||||||
|
return 'Название должно быть не менее 3 символов';
|
||||||
|
}
|
||||||
|
if (value.trim().length > 200) {
|
||||||
|
return 'Название должно быть не более 200 символов';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_errors.title != null) {
|
||||||
|
setState(() {
|
||||||
|
_errors = _errors.copyWith(title: null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDescriptionField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Описание',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Введите описание задания',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.description,
|
||||||
|
),
|
||||||
|
maxLines: 4,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Описание обязательно';
|
||||||
|
}
|
||||||
|
if (value.trim().length < 10) {
|
||||||
|
return 'Описание должно быть не менее 10 символов';
|
||||||
|
}
|
||||||
|
if (value.trim().length > 1000) {
|
||||||
|
return 'Описание должно быть не более 1000 символов';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_errors.description != null) {
|
||||||
|
setState(() {
|
||||||
|
_errors = _errors.copyWith(description: null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypeField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Тип',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<TaskType>(
|
||||||
|
value: _selectedType,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Выберите тип',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.type,
|
||||||
|
),
|
||||||
|
items: TaskType.values.map((type) {
|
||||||
|
return DropdownMenuItem<TaskType>(
|
||||||
|
value: type,
|
||||||
|
child: Text(_getTypeLabel(type)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = value;
|
||||||
|
if (_errors.type != null) {
|
||||||
|
_errors = _errors.copyWith(type: null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 'Тип обязателен';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDifficultyField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Сложность',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<TaskDifficulty>(
|
||||||
|
value: _selectedDifficulty,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Выберите сложность',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.difficulty,
|
||||||
|
),
|
||||||
|
items: TaskDifficulty.values.map((difficulty) {
|
||||||
|
return DropdownMenuItem<TaskDifficulty>(
|
||||||
|
value: difficulty,
|
||||||
|
child: Text(_getDifficultyLabel(difficulty)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDifficulty = value;
|
||||||
|
if (_errors.difficulty != null) {
|
||||||
|
_errors = _errors.copyWith(difficulty: null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 'Сложность обязательна';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExpirationDateField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Дата истечения',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _selectExpirationDate(context),
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Выберите дату истечения',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.expiresAt,
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_selectedExpiresAt != null
|
||||||
|
? '${_formatDate(_selectedExpiresAt!)} ${_formatTime(_selectedExpiresAt!)}'
|
||||||
|
: 'Выберите дату',
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRewardsSection(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Награды',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _addReward,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Добавить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_errors.rewards != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_errors.rewards!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_rewards.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'Нет наград. Нажмите "Добавить" для добавления.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.disabledColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._rewards.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final reward = entry.value;
|
||||||
|
return _buildRewardItem(theme, index, reward);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRewardItem(ThemeData theme, int index, TaskReward reward) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_getRewardTypeLabel(reward.type),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Количество: ${reward.amount}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (reward.achievementId != null)
|
||||||
|
Text(
|
||||||
|
'Достижение: ${reward.achievementId}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
onPressed: () => _removeReward(index),
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInstructionsField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Инструкции (необязательно)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _instructionsController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Дополнительные инструкции для выполнения',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.instructions,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value != null && value.trim().length > 2000) {
|
||||||
|
return 'Инструкции должны быть не более 2000 символов';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_errors.instructions != null) {
|
||||||
|
setState(() {
|
||||||
|
_errors = _errors.copyWith(instructions: null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTagsField(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Теги (необязательно)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _tagsController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Введите теги через запятую',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
errorText: _errors.tags,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
validator: (value) {
|
||||||
|
if (value != null && value.trim().isNotEmpty) {
|
||||||
|
final tags = _parseTags(value);
|
||||||
|
if (tags.length > 10) {
|
||||||
|
return 'Максимум 10 тегов';
|
||||||
|
}
|
||||||
|
for (final tag in tags) {
|
||||||
|
if (tag.length > 50) {
|
||||||
|
return 'Тег не должен превышать 50 символов';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_errors.tags != null) {
|
||||||
|
setState(() {
|
||||||
|
_errors = _errors.copyWith(tags: null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmitButton(ThemeData theme) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: _isSubmitting ? null : _handleSubmit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Создать задание',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectExpirationDate(BuildContext context) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final initialDate = _selectedExpiresAt ?? now.add(const Duration(days: 7));
|
||||||
|
final firstDate = now;
|
||||||
|
final lastDate = now.add(const Duration(days: 365));
|
||||||
|
|
||||||
|
final pickedDate = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: initialDate,
|
||||||
|
firstDate: firstDate,
|
||||||
|
lastDate: lastDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedDate != null) {
|
||||||
|
final pickedTime = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(initialDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedTime != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedExpiresAt = DateTime(
|
||||||
|
pickedDate.year,
|
||||||
|
pickedDate.month,
|
||||||
|
pickedDate.day,
|
||||||
|
pickedTime.hour,
|
||||||
|
pickedTime.minute,
|
||||||
|
);
|
||||||
|
if (_errors.expiresAt != null) {
|
||||||
|
_errors = _errors.copyWith(expiresAt: null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addReward() async {
|
||||||
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _RewardDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
setState(() {
|
||||||
|
_rewards.add(
|
||||||
|
TaskReward(
|
||||||
|
type: result['type'] as RewardType,
|
||||||
|
amount: result['amount'] as int,
|
||||||
|
achievementId: result['achievementId'] as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (_errors.rewards != null) {
|
||||||
|
_errors = _errors.copyWith(rewards: null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeReward(int index) {
|
||||||
|
setState(() {
|
||||||
|
_rewards.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields manually
|
||||||
|
final errors = TaskFormErrors(
|
||||||
|
type: _selectedType == null ? 'Тип обязателен' : null,
|
||||||
|
difficulty: _selectedDifficulty == null ? 'Сложность обязательна' : null,
|
||||||
|
expiresAt: _selectedExpiresAt == null
|
||||||
|
? 'Дата истечения обязательна'
|
||||||
|
: _selectedExpiresAt!.isBefore(DateTime.now())
|
||||||
|
? 'Дата истечения должна быть в будущем'
|
||||||
|
: null,
|
||||||
|
rewards: _rewards.isEmpty ? 'Добавьте хотя бы одну награду' : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errors.hasErrors) {
|
||||||
|
setState(() {
|
||||||
|
_errors = errors;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final formData = TaskFormData(
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
type: _selectedType!,
|
||||||
|
difficulty: _selectedDifficulty!,
|
||||||
|
rewards: _rewards,
|
||||||
|
expiresAt: _selectedExpiresAt!,
|
||||||
|
instructions: _instructionsController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _instructionsController.text.trim(),
|
||||||
|
tags: _parseTags(_tagsController.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
await widget.onSubmit(formData);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseTags(String value) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.where((tag) => tag.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getTypeLabel(TaskType type) {
|
||||||
|
switch (type) {
|
||||||
|
case TaskType.appInternal:
|
||||||
|
return 'В приложении';
|
||||||
|
case TaskType.external:
|
||||||
|
return 'Внешнее';
|
||||||
|
case TaskType.social:
|
||||||
|
return 'Социальное';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getDifficultyLabel(TaskDifficulty difficulty) {
|
||||||
|
switch (difficulty) {
|
||||||
|
case TaskDifficulty.easy:
|
||||||
|
return 'Легко';
|
||||||
|
case TaskDifficulty.medium:
|
||||||
|
return 'Средне';
|
||||||
|
case TaskDifficulty.hard:
|
||||||
|
return 'Сложно';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getRewardTypeLabel(RewardType type) {
|
||||||
|
switch (type) {
|
||||||
|
case RewardType.xp:
|
||||||
|
return 'Опыт (XP)';
|
||||||
|
case RewardType.coins:
|
||||||
|
return 'Монеты';
|
||||||
|
case RewardType.achievement:
|
||||||
|
return 'Достижение';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
return '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime date) {
|
||||||
|
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog for adding a reward
|
||||||
|
class _RewardDialog extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_RewardDialog> createState() => _RewardDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RewardDialogState extends State<_RewardDialog> {
|
||||||
|
RewardType? _selectedType;
|
||||||
|
final _amountController = TextEditingController();
|
||||||
|
final _achievementIdController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amountController.dispose();
|
||||||
|
_achievementIdController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
'Добавить награду',
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<RewardType>(
|
||||||
|
value: _selectedType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Тип награды',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: RewardType.values.map((type) {
|
||||||
|
return DropdownMenuItem<RewardType>(
|
||||||
|
value: type,
|
||||||
|
child: Text(_getRewardTypeLabel(type)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _amountController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Количество',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Обязательно';
|
||||||
|
}
|
||||||
|
final amount = int.tryParse(value);
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
return 'Введите положительное число';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_selectedType == RewardType.achievement) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _achievementIdController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'ID достижения',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (_selectedType == RewardType.achievement &&
|
||||||
|
(value == null || value.isEmpty)) {
|
||||||
|
return 'Обязательно для достижения';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _validateAndSubmit,
|
||||||
|
child: const Text('Добавить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateAndSubmit() {
|
||||||
|
if (_selectedType == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Выберите тип награды')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final amount = int.tryParse(_amountController.text);
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Введите корректное количество')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedType == RewardType.achievement &&
|
||||||
|
_achievementIdController.text.trim().isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Введите ID достижения')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pop({
|
||||||
|
'type': _selectedType,
|
||||||
|
'amount': amount,
|
||||||
|
'achievementId': _selectedType == RewardType.achievement
|
||||||
|
? _achievementIdController.text.trim()
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getRewardTypeLabel(RewardType type) {
|
||||||
|
switch (type) {
|
||||||
|
case RewardType.xp:
|
||||||
|
return 'Опыт (XP)';
|
||||||
|
case RewardType.coins:
|
||||||
|
return 'Монеты';
|
||||||
|
case RewardType.achievement:
|
||||||
|
return 'Достижение';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
|
|
||||||
|
import '../../../../lib/di/user_scope/user_scope.dart';
|
||||||
|
import '../../../../lib/domain/models/task_models.dart';
|
||||||
|
import '../../../../lib/domain/services/tasks_repository.dart';
|
||||||
|
import '../../../../lib/domain/state/tasks_state_manager.dart';
|
||||||
|
import '../../../../lib/presentation/pages/tasks/create_task_page.dart';
|
||||||
|
|
||||||
|
class MockTasksRepository extends Mock implements TasksRepository {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('CreateTaskPage', () {
|
||||||
|
late MockTasksRepository mockRepository;
|
||||||
|
late TasksStateManager stateManager;
|
||||||
|
late UserScope userScope;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockRepository = MockTasksRepository();
|
||||||
|
stateManager = TasksStateManager(repository: mockRepository);
|
||||||
|
userScope = UserScope(
|
||||||
|
tasksStateManager: stateManager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays task form', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<UserScope>(
|
||||||
|
scope: userScope,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show form
|
||||||
|
expect(find.text('Создать задание'), findsWidgets);
|
||||||
|
expect(find.text('Название'), findsOneWidget);
|
||||||
|
expect(find.text('Описание'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays error message from state', (tester) async {
|
||||||
|
// Set error state
|
||||||
|
await stateManager.handle((emit) async {
|
||||||
|
emit(const TasksState.error('Test error message'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<UserScope>(
|
||||||
|
scope: userScope,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should show error
|
||||||
|
expect(find.text('Test error message'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('handles successful task creation', (tester) async {
|
||||||
|
final testTask = Task(
|
||||||
|
id: 'test_id',
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: TaskType.appInternal,
|
||||||
|
difficulty: TaskDifficulty.easy,
|
||||||
|
rewards: [const TaskReward(type: RewardType.xp, amount: 100)],
|
||||||
|
status: TaskStatus.available,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
expiresAt: DateTime.now().add(const Duration(days: 7)),
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockRepository.createTask(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
type: any(named: 'type'),
|
||||||
|
difficulty: any(named: 'difficulty'),
|
||||||
|
rewards: any(named: 'rewards'),
|
||||||
|
expiresAt: any(named: 'expiresAt'),
|
||||||
|
instructions: any(named: 'instructions'),
|
||||||
|
tags: any(named: 'tags'),
|
||||||
|
)).thenAnswer((_) async => testTask);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<UserScope>(
|
||||||
|
scope: userScope,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, 'Test Task');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'Test Description',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите дату'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final futureDate = DateTime.now().add(const Duration(days: 7));
|
||||||
|
await tester.tap(find.text(futureDate.day.toString()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify repository was called
|
||||||
|
verify(() => mockRepository.createTask(
|
||||||
|
title: 'Test Task',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: TaskType.appInternal,
|
||||||
|
difficulty: TaskDifficulty.easy,
|
||||||
|
rewards: any(named: 'rewards'),
|
||||||
|
expiresAt: any(named: 'expiresAt'),
|
||||||
|
instructions: any(named: 'instructions'),
|
||||||
|
tags: any(named: 'tags'),
|
||||||
|
)).called(1);
|
||||||
|
|
||||||
|
// Should show success message
|
||||||
|
expect(find.text('Задание успешно создано!'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('handles task creation error', (tester) async {
|
||||||
|
when(() => mockRepository.createTask(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
type: any(named: 'type'),
|
||||||
|
difficulty: any(named: 'difficulty'),
|
||||||
|
rewards: any(named: 'rewards'),
|
||||||
|
expiresAt: any(named: 'expiresAt'),
|
||||||
|
instructions: any(named: 'instructions'),
|
||||||
|
tags: any(named: 'tags'),
|
||||||
|
)).thenThrow(Exception('Network error'));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<UserScope>(
|
||||||
|
scope: userScope,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, 'Test Task');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'Test Description',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите дату'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final futureDate = DateTime.now().add(const Duration(days: 7));
|
||||||
|
await tester.tap(find.text(futureDate.day.toString()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
expect(
|
||||||
|
find.textContaining('Ошибка при создании задания'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('navigates back on back button', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<UserScope>(
|
||||||
|
scope: userScope,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: const CreateTaskPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tap back button
|
||||||
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Should navigate back (this is handled by GoRouter)
|
||||||
|
// In a real test, we'd verify navigation occurred
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
546
mnemo_cards_web_v2/test/presentation/widgets/task_form_test.dart
Normal file
546
mnemo_cards_web_v2/test/presentation/widgets/task_form_test.dart
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/models/task_models.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/presentation/widgets/task_form.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TaskForm', () {
|
||||||
|
late Future<void> Function(TaskFormData) mockOnSubmit;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockOnSubmit = (data) async {};
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays all form fields', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check all required fields are present
|
||||||
|
expect(find.text('Название'), findsOneWidget);
|
||||||
|
expect(find.text('Описание'), findsOneWidget);
|
||||||
|
expect(find.text('Тип'), findsOneWidget);
|
||||||
|
expect(find.text('Сложность'), findsOneWidget);
|
||||||
|
expect(find.text('Дата истечения'), findsOneWidget);
|
||||||
|
expect(find.text('Награды'), findsOneWidget);
|
||||||
|
expect(find.text('Инструкции (необязательно)'), findsOneWidget);
|
||||||
|
expect(find.text('Теги (необязательно)'), findsOneWidget);
|
||||||
|
expect(find.text('Создать задание'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('validates required fields', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to submit empty form
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should show validation errors
|
||||||
|
expect(find.text('Название обязательно'), findsOneWidget);
|
||||||
|
expect(find.text('Описание обязательно'), findsOneWidget);
|
||||||
|
expect(find.text('Тип обязателен'), findsOneWidget);
|
||||||
|
expect(find.text('Сложность обязательна'), findsOneWidget);
|
||||||
|
expect(find.text('Дата истечения обязательна'), findsOneWidget);
|
||||||
|
expect(find.text('Добавьте хотя бы одну награду'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('validates title length', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test too short title
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).first,
|
||||||
|
'ab',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Название должно быть не менее 3 символов'), findsOneWidget);
|
||||||
|
|
||||||
|
// Test too long title
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).first,
|
||||||
|
'a' * 201,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Название должно быть не более 200 символов'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('validates description length', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final descriptionField = find.byType(TextFormField).at(1);
|
||||||
|
|
||||||
|
// Test too short description
|
||||||
|
await tester.enterText(descriptionField, 'short');
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Описание должно быть не менее 10 символов'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test too long description
|
||||||
|
await tester.enterText(descriptionField, 'a' * 1001);
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Описание должно быть не более 1000 символов'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows selecting task type', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tap type dropdown
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Select a type
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Type should be selected
|
||||||
|
expect(find.text('В приложении'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows selecting difficulty', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tap difficulty dropdown
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Select a difficulty
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Difficulty should be selected
|
||||||
|
expect(find.text('Легко'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows adding rewards', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tap add reward button
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Reward dialog should appear
|
||||||
|
expect(find.text('Добавить награду'), findsOneWidget);
|
||||||
|
expect(find.text('Тип награды'), findsOneWidget);
|
||||||
|
|
||||||
|
// Select reward type
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter amount
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Submit reward dialog
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Reward should be added
|
||||||
|
expect(find.text('Опыт (XP)'), findsWidgets);
|
||||||
|
expect(find.text('Количество: 100'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows removing rewards', (tester) async {
|
||||||
|
bool onSubmitCalled = false;
|
||||||
|
TaskFormData? submittedData;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(
|
||||||
|
onSubmit: (data) async {
|
||||||
|
onSubmitCalled = true;
|
||||||
|
submittedData = data;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a reward
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '50');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Remove the reward
|
||||||
|
await tester.tap(find.byIcon(Icons.delete));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Reward should be removed
|
||||||
|
expect(find.text('Нет наград. Нажмите "Добавить" для добавления.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('validates expiration date is in future', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill required fields
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, 'Test Task');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'This is a test description',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select type and difficulty
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Add a reward
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Try to submit without selecting date
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Дата истечения обязательна'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays error message when provided', (tester) async {
|
||||||
|
const errorMessage = 'Failed to create task';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(
|
||||||
|
onSubmit: mockOnSubmit,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text(errorMessage), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calls onSubmit with correct data', (tester) async {
|
||||||
|
TaskFormData? submittedData;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(
|
||||||
|
onSubmit: (data) async {
|
||||||
|
submittedData = data;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, 'Test Task');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'This is a test description',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select type and difficulty
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Add reward
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Select expiration date
|
||||||
|
await tester.tap(find.text('Выберите дату'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Select a future date
|
||||||
|
final futureDate = DateTime.now().add(const Duration(days: 7));
|
||||||
|
await tester.tap(find.text(futureDate.day.toString()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify data was submitted
|
||||||
|
expect(submittedData, isNotNull);
|
||||||
|
expect(submittedData!.title, 'Test Task');
|
||||||
|
expect(submittedData.description, 'This is a test description');
|
||||||
|
expect(submittedData.type, TaskType.appInternal);
|
||||||
|
expect(submittedData.difficulty, TaskDifficulty.easy);
|
||||||
|
expect(submittedData.rewards.length, 1);
|
||||||
|
expect(submittedData.rewards.first.type, RewardType.xp);
|
||||||
|
expect(submittedData.rewards.first.amount, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('parses tags correctly', (tester) async {
|
||||||
|
TaskFormData? submittedData;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(
|
||||||
|
onSubmit: (data) async {
|
||||||
|
submittedData = data;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill required fields
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, 'Test Task');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'This is a test description',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter tags
|
||||||
|
final tagsField = find.byType(TextFormField).at(3);
|
||||||
|
await tester.enterText(tagsField, 'tag1, tag2, tag3');
|
||||||
|
|
||||||
|
// Select type and difficulty
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Add reward
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Select expiration date
|
||||||
|
await tester.tap(find.text('Выберите дату'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final futureDate = DateTime.now().add(const Duration(days: 7));
|
||||||
|
await tester.tap(find.text(futureDate.day.toString()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify tags were parsed
|
||||||
|
expect(submittedData, isNotNull);
|
||||||
|
expect(submittedData!.tags, isNotNull);
|
||||||
|
expect(submittedData.tags!.length, 3);
|
||||||
|
expect(submittedData.tags, ['tag1', 'tag2', 'tag3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('validates tags count and length', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(onSubmit: mockOnSubmit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final tagsField = find.byType(TextFormField).at(3);
|
||||||
|
|
||||||
|
// Test too many tags
|
||||||
|
final manyTags = List.generate(11, (i) => 'tag$i').join(', ');
|
||||||
|
await tester.enterText(tagsField, manyTags);
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Максимум 10 тегов'), findsOneWidget);
|
||||||
|
|
||||||
|
// Test tag too long
|
||||||
|
await tester.enterText(tagsField, 'a' * 51);
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Тег не должен превышать 50 символов'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows loading state during submission', (tester) async {
|
||||||
|
bool isSubmitting = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskForm(
|
||||||
|
onSubmit: (data) async {
|
||||||
|
isSubmitting = true;
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
isSubmitting = false;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill form with valid data
|
||||||
|
await tester.enterText(find.byType(TextFormField).first, 'Test Task');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'This is a test description',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите тип'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('В приложении'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите сложность'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Легко'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Тип награды'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Опыт (XP)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextFormField).last, '100');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Добавить'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Выберите дату'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final futureDate = DateTime.now().add(const Duration(days: 7));
|
||||||
|
await tester.tap(find.text(futureDate.day.toString()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('OK'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await tester.tap(find.text('Создать задание'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should show loading indicator
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue