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",
|
||||
"current_task_id": "WEB-001",
|
||||
"iteration_count": 6,
|
||||
"current_task_id": "WEB-002",
|
||||
"iteration_count": 7,
|
||||
"max_iterations": 10,
|
||||
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
||||
"last_commit": null,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
"id": "WEB-002",
|
||||
"title": "Create Task Creation Form UI",
|
||||
"priority": "high",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"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.",
|
||||
"acceptance_criteria": [
|
||||
|
|
|
|||
|
|
@ -313,6 +313,11 @@ class ApiConfigV2 {
|
|||
/// Mark task as in progress
|
||||
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 user's task progress and statistics
|
||||
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
|
||||
Future<TaskCategories> getTaskCategories() async {
|
||||
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
|
||||
Future<Task> updateTaskStatus(String taskId, TaskStatus status, {
|
||||
String? proofUrl,
|
||||
|
|
|
|||
|
|
@ -233,6 +233,51 @@ class TasksStateManager extends StateManager<TasksState> {
|
|||
/// Refresh data
|
||||
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
|
||||
Future<void> clearError() => handle((emit) async {
|
||||
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:go_router/go_router.dart';
|
||||
import 'package:yx_scope_flutter/yx_scope_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
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _refreshTasks,
|
||||
tooltip: 'Обновить задания',
|
||||
child: const Icon(Icons.refresh),
|
||||
floatingActionButton: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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/purchase/purchase_page.dart';
|
||||
import '../pages/statistics/statistics_page.dart';
|
||||
import '../pages/tasks/create_task_page.dart';
|
||||
import '../pages/tasks/tasks_page.dart';
|
||||
import '../pages/test/test_page.dart';
|
||||
import '../widgets/main_shell.dart';
|
||||
|
|
@ -46,6 +47,13 @@ GoRouter createAppRouter({
|
|||
child: TasksPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks/create',
|
||||
name: 'create-task',
|
||||
pageBuilder: (context, state) => const MaterialPage(
|
||||
child: CreateTaskPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/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