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:
AI Agent 2025-11-22 04:20:48 +00:00
parent dbcc7a1ab1
commit 5e8aeaad39
12 changed files with 2008 additions and 7 deletions

View file

@ -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,

View file

@ -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": [

View file

@ -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';

View file

@ -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 {

View file

@ -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,

View file

@ -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) {

View file

@ -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),
),
);
}
}
}
}

View file

@ -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),
),
],
),
);
}

View file

@ -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',

View 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 'Достижение';
}
}
}

View file

@ -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
});
});
}

View 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);
});
});
}