Refactor image handling in the exercise submission process

Now the images are kept in a single list, instead of having two for
the files themselves and the metadata.
This commit is contained in:
Roland Geider
2025-10-06 16:01:14 +02:00
parent 50b63a4749
commit 1a78011a7d
11 changed files with 623 additions and 311 deletions

View File

@@ -143,3 +143,6 @@ const String API_RESULTS_PAGE_SIZE = '100';
/// Marker used for identifying interpolated values in a list, e.g. for measurements
/// the milliseconds in the entry date are set to this value
const INTERPOLATION_MARKER = 123;
/// Creative Commons license IDs
const CC_BY_SA_4_ID = 4;

View File

@@ -0,0 +1,67 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'package:flutter/material.dart';
enum ImageType {
photo(id: 1, label: 'Photo', icon: Icons.photo_camera),
threeD(id: 2, label: '3D', icon: Icons.view_in_ar),
line(id: 3, label: 'Line', icon: Icons.show_chart),
lowPoly(id: 4, label: 'Low-Poly', icon: Icons.filter_vintage),
other(id: 5, label: 'Other', icon: Icons.more_horiz);
const ImageType({required this.id, required this.label, required this.icon});
final int id;
final String label;
final IconData icon;
}
class ExerciseSubmissionImage {
final File imageFile;
String? title;
String? author;
String? authorUrl;
String? sourceUrl;
String? derivativeSourceUrl;
ImageType type = ImageType.photo;
ExerciseSubmissionImage({
this.title,
this.author,
this.authorUrl,
this.sourceUrl,
this.derivativeSourceUrl,
this.type = ImageType.photo,
required this.imageFile,
});
Map<String, String> toJson() {
return {
'title': title ?? '',
'author': author ?? '',
'author_url': authorUrl ?? '',
'source_url': sourceUrl ?? '',
'derivative_source_url': derivativeSourceUrl ?? '',
'type': type.id.toString(),
};
}
}

View File

@@ -1,13 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/exercises/category.dart';
import 'package:wger/models/exercises/equipment.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/exercises/exercise_submission.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/models/exercises/language.dart';
import 'package:wger/models/exercises/muscle.dart';
import 'package:wger/models/exercises/variation.dart';
@@ -20,11 +18,10 @@ class AddExerciseProvider with ChangeNotifier {
AddExerciseProvider(this.baseProvider);
List<File> get exerciseImages => [..._exerciseImages];
List<File> _exerciseImages = [];
// Images and their metadata (license info, style)
final List<ExerciseSubmissionImage> _exerciseImages = [];
// Map storing image metadata (license info, style), keyed by file path
final Map<String, Map<String, String>> _imageDetails = {};
List<ExerciseSubmissionImage> get exerciseImages => [..._exerciseImages];
String? exerciseNameEn;
String? exerciseNameTrans;
@@ -37,7 +34,6 @@ class AddExerciseProvider with ChangeNotifier {
List<String> alternateNamesEn = [];
List<String> alternateNamesTrans = [];
ExerciseCategory? category;
List<Exercise> _variations = [];
List<Equipment> _equipment = [];
List<Muscle> _primaryMuscles = [];
List<Muscle> _secondaryMuscles = [];
@@ -47,8 +43,7 @@ class AddExerciseProvider with ChangeNotifier {
static const _checkLanguageUrlPath = 'check-language';
void clear() {
_exerciseImages = [];
_imageDetails.clear();
_exerciseImages.clear();
languageTranslation = null;
category = null;
exerciseNameEn = null;
@@ -57,7 +52,6 @@ class AddExerciseProvider with ChangeNotifier {
descriptionTrans = null;
alternateNamesEn = [];
alternateNamesTrans = [];
_variations = [];
_equipment = [];
_primaryMuscles = [];
_secondaryMuscles = [];
@@ -142,56 +136,14 @@ class AddExerciseProvider with ChangeNotifier {
}
/// Add images with optional license metadata
///
/// [images] - List of image files to add
/// [title] - License title
/// [author] - Author name
/// [authorUrl] - Author's URL
/// [sourceUrl] - Source/object URL
/// [derivativeSourceUrl] - Derivative source URL
/// [style] - Image style: 1=PHOTO, 2=3D, 3=LINE, 4=LOW-POLY, 5=OTHER
void addExerciseImages(
List<File> images, {
String? title,
String? author,
String? authorUrl,
String? sourceUrl,
String? derivativeSourceUrl,
String style = '1',
}) {
void addExerciseImages(List<ExerciseSubmissionImage> images) {
_exerciseImages.addAll(images);
// Store metadata for each image
for (final image in images) {
final details = <String, String>{'style': style};
// Only add non-empty fields
if (title != null && title.isNotEmpty) {
details['license_title'] = title;
}
if (author != null && author.isNotEmpty) {
details['license_author'] = author;
}
if (authorUrl != null && authorUrl.isNotEmpty) {
details['license_author_url'] = authorUrl;
}
if (sourceUrl != null && sourceUrl.isNotEmpty) {
details['license_object_url'] = sourceUrl;
}
if (derivativeSourceUrl != null && derivativeSourceUrl.isNotEmpty) {
details['license_derivative_source_url'] = derivativeSourceUrl;
}
_imageDetails[image.path] = details;
}
notifyListeners();
}
void removeExercise(String path) {
final file = _exerciseImages.where((element) => element.path == path).first;
void removeImage(String path) {
final file = _exerciseImages.where((element) => element.imageFile.path == path).first;
_exerciseImages.remove(file);
_imageDetails.remove(path);
notifyListeners();
}
@@ -199,16 +151,14 @@ class AddExerciseProvider with ChangeNotifier {
///
/// Returns the ID of the created exercise
/// Throws exception if submission fails
Future<int> addExercise() async {
Future<int> postExerciseToServer() async {
try {
// 1. Create the exercise
final exerciseId = await addExerciseSubmission();
// 2. Upload images if any exist
if (_exerciseImages.isNotEmpty) {
await addImages(exerciseId);
}
// 3. Clear all data after successful upload
@@ -216,7 +166,6 @@ class AddExerciseProvider with ChangeNotifier {
return exerciseId;
} catch (e) {
// Don't clear on error so user can retry
rethrow;
}
@@ -233,25 +182,20 @@ class AddExerciseProvider with ChangeNotifier {
}
/// Upload exercise images with license metadata
///
/// For each image:
/// - Sends multipart request with image file
/// - Includes license fields from _imageDetails map
Future<void> addImages(int exerciseId) async {
for (final image in _exerciseImages) {
final request = http.MultipartRequest('POST', baseProvider.makeUrl(_imagesUrlPath));
request.headers.addAll(baseProvider.getDefaultHeaders(includeAuth: true));
request.files.add(await http.MultipartFile.fromPath('image', image.path));
request.files.add(await http.MultipartFile.fromPath('image', image.imageFile.path));
request.fields['exercise'] = exerciseId.toString();
request.fields['license'] = '1';
request.fields['license'] = CC_BY_SA_4_ID.toString();
request.fields['is_main'] = 'false';
request.fields['style'] = ImageType.photo.id.toString();
final details = _imageDetails[image.path];
if (details != null && details.isNotEmpty) {
final details = image.toJson();
if (details.isNotEmpty) {
request.fields.addAll(details);
} else {
request.fields['style'] = '1';
}
try {
@@ -280,4 +224,4 @@ class AddExerciseProvider with ChangeNotifier {
return false;
}
}
}

View File

@@ -85,10 +85,8 @@ class _AddExerciseStepperState extends State<AddExerciseStepper> {
Exercise? exercise;
try {
final exerciseId = await addExerciseProvider.addExercise();
await addExerciseProvider.addImages(exerciseId);
final exerciseId = await addExerciseProvider.postExerciseToServer();
exercise = await exerciseProvider.fetchAndSetExercise(exerciseId);
addExerciseProvider.clear();
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {

View File

@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/widgets/add_exercise/license_info_widget.dart';
/// Form for collecting CC BY-SA 4.0 license metadata for exercise images
@@ -23,13 +22,13 @@ import 'package:wger/widgets/add_exercise/license_info_widget.dart';
/// All metadata is sent to the API's /exerciseimage endpoint along with
/// the image file when the exercise is submitted.
class ImageDetailsForm extends StatefulWidget {
final File imageFile;
final Function(File image, Map<String, String> details) onAdd;
final Function(ExerciseSubmissionImage image) onAdd;
final VoidCallback onCancel;
final ExerciseSubmissionImage submissionImage;
const ImageDetailsForm({
super.key,
required this.imageFile,
required this.submissionImage,
required this.onAdd,
required this.onCancel,
});
@@ -43,22 +42,13 @@ class _ImageDetailsFormState extends State<ImageDetailsForm> {
// Text controllers for license metadata fields
final _titleController = TextEditingController();
final _sourceLinkController = TextEditingController(); // license_object_url in API
final _authorController = TextEditingController(); // license_author in API
final _authorLinkController = TextEditingController(); // license_author_url in API
final _originalSourceController = TextEditingController(); // license_derivative_source_url in API
final _sourceLinkController = TextEditingController();
final _authorController = TextEditingController();
final _authorLinkController = TextEditingController();
final _originalSourceController = TextEditingController();
/// Currently selected image type
/// Maps to API 'style' field: PHOTO=1, 3D=2, LINE=3, LOW-POLY=4, OTHER=5
String _selectedImageType = 'PHOTO';
final List<Map<String, dynamic>> _imageTypes = [
{'type': 'PHOTO', 'icon': Icons.photo_camera, 'label': 'PHOTO'},
{'type': '3D', 'icon': Icons.view_in_ar, 'label': '3D'},
{'type': 'LINE', 'icon': Icons.show_chart, 'label': 'LINE'},
{'type': 'LOW-POLY', 'icon': Icons.filter_vintage, 'label': 'LOW-POLY'},
{'type': 'OTHER', 'icon': Icons.more_horiz, 'label': 'OTHER'},
];
ImageType _selectedImageType = ImageType.photo;
@override
void dispose() {
@@ -216,7 +206,7 @@ class _ImageDetailsFormState extends State<ImageDetailsForm> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(widget.imageFile, fit: BoxFit.contain),
child: Image.file(widget.submissionImage.imageFile, fit: BoxFit.contain),
),
),
);
@@ -269,12 +259,12 @@ class _ImageDetailsFormState extends State<ImageDetailsForm> {
Wrap(
spacing: 8,
runSpacing: 8,
children: _imageTypes.map((type) {
final isSelected = _selectedImageType == type['type'];
children: ImageType.values.map((type) {
final isSelected = _selectedImageType == type;
return InkWell(
onTap: () {
setState(() {
_selectedImageType = type['type'];
_selectedImageType = type;
});
},
borderRadius: BorderRadius.circular(4),
@@ -291,7 +281,7 @@ class _ImageDetailsFormState extends State<ImageDetailsForm> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
type['icon'],
type.icon,
size: 32,
color: isSelected
? theme.buttonTheme.colorScheme!.onPrimary
@@ -299,7 +289,7 @@ class _ImageDetailsFormState extends State<ImageDetailsForm> {
),
const SizedBox(height: 8),
Text(
type['label'],
type.label,
style: TextStyle(
fontSize: 11,
color: isSelected ? Colors.blue : Colors.grey.shade700,
@@ -328,40 +318,42 @@ class _ImageDetailsFormState extends State<ImageDetailsForm> {
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Build details map with API field names
// Style is always included, other fields only if non-empty
final details = <String, String>{'style': _getStyleValue()};
// Add optional fields only if user provided values
final title = _titleController.text.trim();
if (title.isNotEmpty) {
details['license_title'] = title;
}
final author = _authorController.text.trim();
if (author.isNotEmpty) {
details['license_author'] = author;
}
final sourceUrl = _sourceLinkController.text.trim();
if (sourceUrl.isNotEmpty) {
details['license_object_url'] = sourceUrl;
}
final authorUrl = _authorLinkController.text.trim();
if (authorUrl.isNotEmpty) {
details['license_author_url'] = authorUrl;
}
final derivativeUrl = _originalSourceController.text.trim();
if (derivativeUrl.isNotEmpty) {
details['license_derivative_source_url'] = derivativeUrl;
}
// Pass image and metadata back to parent
widget.onAdd(widget.imageFile, details);
if (!_formKey.currentState!.validate()) {
return;
}
// Build details map with API field names
// Style is always included, other fields only if non-empty
final details = <String, String>{'style': _getStyleValue()};
// Add optional fields only if user provided values
final title = _titleController.text.trim();
if (title.isNotEmpty) {
widget.submissionImage.title = title;
}
final author = _authorController.text.trim();
if (author.isNotEmpty) {
widget.submissionImage.author = author;
}
final sourceUrl = _sourceLinkController.text.trim();
if (sourceUrl.isNotEmpty) {
widget.submissionImage.sourceUrl = sourceUrl;
}
final authorUrl = _authorLinkController.text.trim();
if (authorUrl.isNotEmpty) {
widget.submissionImage.authorUrl = authorUrl;
}
final derivativeUrl = _originalSourceController.text.trim();
if (derivativeUrl.isNotEmpty) {
widget.submissionImage.derivativeSourceUrl = derivativeUrl;
}
// Pass image and metadata back to parent
widget.onAdd(widget.submissionImage);
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
const validFileExtensions = ['jpg', 'jpeg', 'png', 'webp'];
@@ -31,19 +32,21 @@ mixin ExerciseImagePickerMixin {
images = await imagePicker.pickMultiImage();
}
final selectedImages = <File>[];
final selectedImages = <ExerciseSubmissionImage>[];
if (images != null) {
selectedImages.addAll(images.map((e) => File(e.path)).toList());
selectedImages.addAll(
images.map((e) => ExerciseSubmissionImage(imageFile: File(e.path))).toList(),
);
for (final image in selectedImages) {
bool isFileValid = true;
String errorMessage = '';
if (!_validateFileType(image)) {
if (!_validateFileType(image.imageFile)) {
isFileValid = false;
errorMessage = "Select only 'jpg', 'jpeg', 'png', 'webp' files";
}
if (_validateFileSize(image.lengthSync())) {
if (_validateFileSize(image.imageFile.lengthSync())) {
isFileValid = true;
errorMessage = 'File Size should not be greater than 20 mb';
}

View File

@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
/// Widget to preview selected exercise images
@@ -9,16 +11,16 @@ import 'package:wger/providers/add_exercise.dart';
/// Each image shows a preview thumbnail and optionally a delete button.
/// Can optionally include an "add more" button at the end of the list.
class PreviewExerciseImages extends StatelessWidget {
final List<File> selectedImages;
final List<ExerciseSubmissionImage> selectedImages;
final VoidCallback? onAddMore;
final bool allowEdit;
const PreviewExerciseImages({
Key? key,
super.key,
required this.selectedImages,
this.onAddMore,
this.allowEdit = true,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
@@ -38,7 +40,7 @@ class PreviewExerciseImages extends StatelessWidget {
// Show image thumbnail
final image = selectedImages[index];
return _buildImageCard(context, image);
return _buildImageCard(context, image.imageFile);
},
),
);
@@ -53,12 +55,7 @@ class PreviewExerciseImages extends StatelessWidget {
// Image thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
image,
width: 120,
height: 120,
fit: BoxFit.cover,
),
child: Image.file(image, width: 120, height: 120, fit: BoxFit.cover),
),
// Delete button overlay (only shown if editing is allowed)
@@ -75,7 +72,7 @@ class PreviewExerciseImages extends StatelessWidget {
padding: const EdgeInsets.all(4),
),
onPressed: () {
context.read<AddExerciseProvider>().removeExercise(image.path);
context.read<AddExerciseProvider>().removeImage(image.path);
},
),
),
@@ -100,12 +97,8 @@ class PreviewExerciseImages extends StatelessWidget {
style: BorderStyle.solid,
),
),
child: Icon(
Icons.add,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
child: Icon(Icons.add, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
);
}
}
}

View File

@@ -1,12 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/widgets/add_exercise/image_details_form.dart';
import 'package:wger/widgets/add_exercise/mixins/image_picker_mixin.dart';
import 'package:wger/widgets/add_exercise/preview_images.dart';
import 'package:wger/widgets/add_exercise/image_details_form.dart';
/// Step 5 of exercise creation wizard - Image upload with license metadata
///
@@ -21,6 +23,7 @@ import 'package:wger/widgets/add_exercise/image_details_form.dart';
/// 4. Final upload happens in Step 6 when user clicks "Submit"
class Step5Images extends StatefulWidget {
final GlobalKey<FormState> formkey;
const Step5Images({required this.formkey});
@override
@@ -30,7 +33,7 @@ class Step5Images extends StatefulWidget {
class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin {
/// Currently selected image waiting for metadata input
/// When non-null, ImageDetailsForm is displayed instead of image picker
File? _currentImageToAdd;
ExerciseSubmissionImage? _currentImageToAddNew;
/// Show dialog to choose between Camera and Gallery
Future<void> _showImageSourceDialog(BuildContext context) async {
@@ -111,7 +114,7 @@ class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin
// Show metadata collection form for valid image
setState(() {
_currentImageToAdd = imageFile;
_currentImageToAddNew = ExerciseSubmissionImage(imageFile: imageFile);
});
}
}
@@ -124,30 +127,22 @@ class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin
///
/// [image] - The image file to add
/// [details] - Map containing license fields (license_title, license_author, etc.)
void _addImageWithDetails(File image, Map<String, String> details) {
void _addImageWithDetails(ExerciseSubmissionImage image) {
final provider = context.read<AddExerciseProvider>();
// Store image with metadata - actual upload happens in addExercise()
provider.addExerciseImages(
[image],
title: details['license_title'],
author: details['license_author'],
authorUrl: details['license_author_url'],
sourceUrl: details['license_object_url'],
derivativeSourceUrl: details['license_derivative_source_url'],
style: details['style'] ?? '1',
);
provider.addExerciseImages([image]);
// Reset form state - image is now visible in preview list
setState(() {
_currentImageToAdd = null;
_currentImageToAddNew = null;
});
}
/// Cancel metadata input and return to image picker
void _cancelImageAdd() {
setState(() {
_currentImageToAdd = null;
_currentImageToAddNew = null;
});
}
@@ -158,7 +153,7 @@ class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin
child: Column(
children: [
// License notice - shown when not entering metadata
if (_currentImageToAdd == null)
if (_currentImageToAddNew == null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
@@ -169,15 +164,15 @@ class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin
),
// Metadata collection form - shown when image is selected
if (_currentImageToAdd != null)
if (_currentImageToAddNew != null)
ImageDetailsForm(
imageFile: _currentImageToAdd!,
submissionImage: _currentImageToAddNew!,
onAdd: _addImageWithDetails,
onCancel: _cancelImageAdd,
),
// Image picker or preview - shown when not entering metadata
if (_currentImageToAdd == null)
if (_currentImageToAddNew == null)
Consumer<AddExerciseProvider>(
builder: (ctx, provider, __) {
if (provider.exerciseImages.isNotEmpty) {
@@ -253,4 +248,4 @@ class _Step5ImagesState extends State<Step5Images> with ExerciseImagePickerMixin
),
);
}
}
}

View File

@@ -1,8 +1,12 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:wger/providers/add_exercise.dart';
import '../core/settings_test.mocks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:wger/models/exercises/exercise_submission_images.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/providers/base_provider.dart';
import 'contribute_exercise_image_test.mocks.dart';
/// Unit tests for AddExerciseProvider image metadata handling
///
@@ -14,6 +18,8 @@ import '../core/settings_test.mocks.dart';
/// - Edge cases (empty lists, duplicates, special characters)
/// - State management (clear, remove)
/// - Image ordering and batch operations
@GenerateMocks([AddExerciseProvider, WgerBaseProvider])
void main() {
late MockWgerBaseProvider mockBaseProvider;
late AddExerciseProvider provider;
@@ -29,18 +35,20 @@ void main() {
test('should store image with all license fields', () {
final mockFile = File('test.jpg');
provider.addExerciseImages(
[mockFile],
title: 'Test Title',
author: 'Test Author',
authorUrl: 'https://test.com/author',
sourceUrl: 'https://source.com',
derivativeSourceUrl: 'https://derivative.com',
style: '4', // LOW-POLY
);
provider.addExerciseImages([
ExerciseSubmissionImage(
imageFile: mockFile,
title: 'Test Title',
author: 'Test Author',
authorUrl: 'https://test.com/author',
sourceUrl: 'https://source.com',
derivativeSourceUrl: 'https://derivative.com',
type: ImageType.lowPoly,
),
]);
expect(provider.exerciseImages.length, 1);
expect(provider.exerciseImages.first.path, mockFile.path);
expect(provider.exerciseImages.first.imageFile.path, mockFile.path);
});
/// License fields are optional - provider should handle null/empty values
@@ -48,13 +56,9 @@ void main() {
test('should handle empty fields gracefully', () {
final mockFile = File('test2.jpg');
provider.addExerciseImages(
[mockFile],
title: 'Only Title',
author: null, // null value
authorUrl: '', // empty string
style: '2', // 3D
);
provider.addExerciseImages([
ExerciseSubmissionImage(imageFile: mockFile, title: 'Only Title', type: ImageType.threeD),
]);
expect(provider.exerciseImages.length, 1);
});
@@ -65,33 +69,12 @@ void main() {
final file1 = File('image1.jpg');
final file2 = File('image2.jpg');
provider.addExerciseImages([file1], title: 'Image 1', author: 'Author 1', style: '1');
provider.addExerciseImages([file2], title: 'Image 2', author: 'Author 2', style: '2');
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: file1, author: 'Author 1')]);
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: file2, author: 'Author 1')]);
expect(provider.exerciseImages.length, 2);
expect(provider.exerciseImages[0].path, file1.path);
expect(provider.exerciseImages[1].path, file2.path);
});
/// Test all 5 image style types defined by the API
/// 1=PHOTO, 2=3D, 3=LINE, 4=LOW-POLY, 5=OTHER
test('should handle all image style types', () {
final styles = ['1', '2', '3', '4', '5'];
for (var i = 0; i < styles.length; i++) {
provider.addExerciseImages([File('image_$i.jpg')], style: styles[i]);
}
expect(provider.exerciseImages.length, 5);
});
/// If no style is specified, should default to '1' (PHOTO)
test('should use default style when not specified', () {
final mockFile = File('default.jpg');
provider.addExerciseImages([mockFile]);
expect(provider.exerciseImages.length, 1);
expect(provider.exerciseImages[0].imageFile.path, file1.path);
expect(provider.exerciseImages[1].imageFile.path, file2.path);
});
/// Edge case: calling addExerciseImages with empty list should not crash
@@ -101,26 +84,15 @@ void main() {
expect(provider.exerciseImages.length, 0);
});
/// Allows adding the same file multiple times with different metadata
/// (e.g., different crops or edits of the same original image)
test('should handle adding same file multiple times', () {
final mockFile = File('same.jpg');
provider.addExerciseImages([mockFile], title: 'First');
provider.addExerciseImages([mockFile], title: 'Second');
expect(provider.exerciseImages.length, 2);
});
/// Removing an image should also remove its associated metadata
/// to prevent memory leaks
test('should remove image and its metadata', () {
final mockFile = File('to_remove.jpg');
provider.addExerciseImages([mockFile], title: 'Will be removed', style: '1');
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: mockFile)]);
expect(provider.exerciseImages.length, 1);
provider.removeExercise(mockFile.path);
provider.removeImage(mockFile.path);
expect(provider.exerciseImages.length, 0);
});
@@ -128,13 +100,15 @@ void main() {
/// Attempting to remove a non-existent image should throw StateError
/// (from firstWhere with no orElse)
test('should handle removing non-existent image gracefully', () {
expect(() => provider.removeExercise('nonexistent.jpg'), throwsStateError);
expect(() => provider.removeImage('nonexistent.jpg'), throwsStateError);
});
/// clear() should reset all state including images and metadata
test('should clear all images and metadata', () {
provider.addExerciseImages([File('image1.jpg')], title: 'Image 1');
provider.addExerciseImages([File('image2.jpg')], title: 'Image 2');
provider.addExerciseImages([
ExerciseSubmissionImage(imageFile: File('image1.jpg'), title: 'Image 1'),
ExerciseSubmissionImage(imageFile: File('image2.jpg'), title: 'Image 2'),
]);
expect(provider.exerciseImages.length, 2);
@@ -152,49 +126,23 @@ void main() {
expect(provider.exerciseImages.length, 0);
});
/// Images should be stored in the order they were added
/// Important for display consistency
test('should preserve image order', () {
final file1 = File('first.jpg');
final file2 = File('second.jpg');
final file3 = File('third.jpg');
provider.addExerciseImages([file1]);
provider.addExerciseImages([file2]);
provider.addExerciseImages([file3]);
expect(provider.exerciseImages[0].path, 'first.jpg');
expect(provider.exerciseImages[1].path, 'second.jpg');
expect(provider.exerciseImages[2].path, 'third.jpg');
});
/// Multiple images can be added in a single call with shared metadata
/// Useful for bulk uploads from the same source
test('should handle batch adding multiple images at once', () {
final files = [File('batch1.jpg'), File('batch2.jpg'), File('batch3.jpg')];
provider.addExerciseImages(files, title: 'Batch Upload', style: '3');
expect(provider.exerciseImages.length, 3);
});
/// Removing one image from a set should not affect others
test('should allow removing specific image from multiple images', () {
final file1 = File('keep1.jpg');
final file2 = File('remove.jpg');
final file3 = File('keep2.jpg');
provider.addExerciseImages([file1]);
provider.addExerciseImages([file2]);
provider.addExerciseImages([file3]);
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: file1)]);
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: file2)]);
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: file3)]);
expect(provider.exerciseImages.length, 3);
provider.removeExercise(file2.path);
provider.removeImage(file2.path);
expect(provider.exerciseImages.length, 2);
expect(provider.exerciseImages[0].path, file1.path);
expect(provider.exerciseImages[1].path, file3.path);
expect(provider.exerciseImages[0].imageFile.path, file1.path);
expect(provider.exerciseImages[1].imageFile.path, file3.path);
});
/// Test with extremely long strings (1000 chars) to ensure no
@@ -203,12 +151,14 @@ void main() {
final mockFile = File('long.jpg');
final longString = 'a' * 1000;
provider.addExerciseImages(
[mockFile],
title: longString,
author: longString,
authorUrl: 'https://example.com/$longString',
);
provider.addExerciseImages([
ExerciseSubmissionImage(
imageFile: mockFile,
title: longString,
author: longString,
authorUrl: 'https://example.com/$longString',
),
]);
expect(provider.exerciseImages.length, 1);
});
@@ -218,12 +168,14 @@ void main() {
test('should handle special characters in metadata', () {
final mockFile = File('special.jpg');
provider.addExerciseImages(
[mockFile],
title: 'Title with émojis 🎉 and spëcial çhars',
author: 'Autör with ümlauts',
authorUrl: 'https://example.com/path?query=value&another=value',
);
provider.addExerciseImages([
ExerciseSubmissionImage(
imageFile: mockFile,
title: 'Title with émojis 🎉 and spëcial çhars',
author: 'Autör with ümlauts',
authorUrl: 'https://example.com/path?query=value&another=value',
),
]);
expect(provider.exerciseImages.length, 1);
});
@@ -235,7 +187,7 @@ void main() {
test('should reset all state after clear', () {
provider.exerciseNameEn = 'Test Exercise';
provider.descriptionEn = 'Description';
provider.addExerciseImages([File('test.jpg')]);
provider.addExerciseImages([ExerciseSubmissionImage(imageFile: File('test.jpg'))]);
provider.clear();

View File

@@ -0,0 +1,383 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in wger/test/exercises/contribute_exercise_image_test.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i14;
import 'dart:ui' as _i15;
import 'package:http/http.dart' as _i5;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i11;
import 'package:wger/models/exercises/category.dart' as _i13;
import 'package:wger/models/exercises/equipment.dart' as _i8;
import 'package:wger/models/exercises/exercise_submission.dart' as _i10;
import 'package:wger/models/exercises/exercise_submission_images.dart' as _i7;
import 'package:wger/models/exercises/language.dart' as _i12;
import 'package:wger/models/exercises/muscle.dart' as _i9;
import 'package:wger/models/exercises/variation.dart' as _i3;
import 'package:wger/providers/add_exercise.dart' as _i6;
import 'package:wger/providers/auth.dart' as _i4;
import 'package:wger/providers/base_provider.dart' as _i2;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider {
_FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeVariation_1 extends _i1.SmartFake implements _i3.Variation {
_FakeVariation_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
class _FakeAuthProvider_2 extends _i1.SmartFake implements _i4.AuthProvider {
_FakeAuthProvider_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
class _FakeClient_3 extends _i1.SmartFake implements _i5.Client {
_FakeClient_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
class _FakeUri_4 extends _i1.SmartFake implements Uri {
_FakeUri_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
class _FakeResponse_5 extends _i1.SmartFake implements _i5.Response {
_FakeResponse_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
/// A class which mocks [AddExerciseProvider].
///
/// See the documentation for Mockito's code generation for more information.
class MockAddExerciseProvider extends _i1.Mock implements _i6.AddExerciseProvider {
MockAddExerciseProvider() {
_i1.throwOnMissingStub(this);
}
@override
_i2.WgerBaseProvider get baseProvider =>
(super.noSuchMethod(
Invocation.getter(#baseProvider),
returnValue: _FakeWgerBaseProvider_0(this, Invocation.getter(#baseProvider)),
)
as _i2.WgerBaseProvider);
@override
List<_i7.ExerciseSubmissionImage> get exerciseImages =>
(super.noSuchMethod(
Invocation.getter(#exerciseImages),
returnValue: <_i7.ExerciseSubmissionImage>[],
)
as List<_i7.ExerciseSubmissionImage>);
@override
List<String> get alternateNamesEn =>
(super.noSuchMethod(Invocation.getter(#alternateNamesEn), returnValue: <String>[])
as List<String>);
@override
List<String> get alternateNamesTrans =>
(super.noSuchMethod(Invocation.getter(#alternateNamesTrans), returnValue: <String>[])
as List<String>);
@override
List<_i8.Equipment> get equipment =>
(super.noSuchMethod(Invocation.getter(#equipment), returnValue: <_i8.Equipment>[])
as List<_i8.Equipment>);
@override
bool get newVariation =>
(super.noSuchMethod(Invocation.getter(#newVariation), returnValue: false) as bool);
@override
_i3.Variation get variation =>
(super.noSuchMethod(
Invocation.getter(#variation),
returnValue: _FakeVariation_1(this, Invocation.getter(#variation)),
)
as _i3.Variation);
@override
List<_i9.Muscle> get primaryMuscles =>
(super.noSuchMethod(Invocation.getter(#primaryMuscles), returnValue: <_i9.Muscle>[])
as List<_i9.Muscle>);
@override
List<_i9.Muscle> get secondaryMuscles =>
(super.noSuchMethod(Invocation.getter(#secondaryMuscles), returnValue: <_i9.Muscle>[])
as List<_i9.Muscle>);
@override
_i10.ExerciseSubmissionApi get exerciseApiObject =>
(super.noSuchMethod(
Invocation.getter(#exerciseApiObject),
returnValue: _i11.dummyValue<_i10.ExerciseSubmissionApi>(
this,
Invocation.getter(#exerciseApiObject),
),
)
as _i10.ExerciseSubmissionApi);
@override
set exerciseNameEn(String? value) => super.noSuchMethod(
Invocation.setter(#exerciseNameEn, value),
returnValueForMissingStub: null,
);
@override
set exerciseNameTrans(String? value) => super.noSuchMethod(
Invocation.setter(#exerciseNameTrans, value),
returnValueForMissingStub: null,
);
@override
set descriptionEn(String? value) =>
super.noSuchMethod(Invocation.setter(#descriptionEn, value), returnValueForMissingStub: null);
@override
set descriptionTrans(String? value) => super.noSuchMethod(
Invocation.setter(#descriptionTrans, value),
returnValueForMissingStub: null,
);
@override
set languageEn(_i12.Language? value) =>
super.noSuchMethod(Invocation.setter(#languageEn, value), returnValueForMissingStub: null);
@override
set languageTranslation(_i12.Language? value) => super.noSuchMethod(
Invocation.setter(#languageTranslation, value),
returnValueForMissingStub: null,
);
@override
set alternateNamesEn(List<String>? value) => super.noSuchMethod(
Invocation.setter(#alternateNamesEn, value),
returnValueForMissingStub: null,
);
@override
set alternateNamesTrans(List<String>? value) => super.noSuchMethod(
Invocation.setter(#alternateNamesTrans, value),
returnValueForMissingStub: null,
);
@override
set category(_i13.ExerciseCategory? value) =>
super.noSuchMethod(Invocation.setter(#category, value), returnValueForMissingStub: null);
@override
set equipment(List<_i8.Equipment>? equipment) =>
super.noSuchMethod(Invocation.setter(#equipment, equipment), returnValueForMissingStub: null);
@override
set variationConnectToExercise(int? value) => super.noSuchMethod(
Invocation.setter(#variationConnectToExercise, value),
returnValueForMissingStub: null,
);
@override
set variationId(int? variation) => super.noSuchMethod(
Invocation.setter(#variationId, variation),
returnValueForMissingStub: null,
);
@override
set primaryMuscles(List<_i9.Muscle>? muscles) => super.noSuchMethod(
Invocation.setter(#primaryMuscles, muscles),
returnValueForMissingStub: null,
);
@override
set secondaryMuscles(List<_i9.Muscle>? muscles) => super.noSuchMethod(
Invocation.setter(#secondaryMuscles, muscles),
returnValueForMissingStub: null,
);
@override
bool get hasListeners =>
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool);
@override
void clear() =>
super.noSuchMethod(Invocation.method(#clear, []), returnValueForMissingStub: null);
@override
void addExerciseImages(List<_i7.ExerciseSubmissionImage>? images) => super.noSuchMethod(
Invocation.method(#addExerciseImages, [images]),
returnValueForMissingStub: null,
);
@override
void removeImage(String? path) =>
super.noSuchMethod(Invocation.method(#removeImage, [path]), returnValueForMissingStub: null);
@override
_i14.Future<int> postExerciseToServer() =>
(super.noSuchMethod(
Invocation.method(#postExerciseToServer, []),
returnValue: _i14.Future<int>.value(0),
)
as _i14.Future<int>);
@override
_i14.Future<int> addExerciseSubmission() =>
(super.noSuchMethod(
Invocation.method(#addExerciseSubmission, []),
returnValue: _i14.Future<int>.value(0),
)
as _i14.Future<int>);
@override
_i14.Future<void> addImages(int? exerciseId) =>
(super.noSuchMethod(
Invocation.method(#addImages, [exerciseId]),
returnValue: _i14.Future<void>.value(),
returnValueForMissingStub: _i14.Future<void>.value(),
)
as _i14.Future<void>);
@override
_i14.Future<bool> validateLanguage(String? input, String? languageCode) =>
(super.noSuchMethod(
Invocation.method(#validateLanguage, [input, languageCode]),
returnValue: _i14.Future<bool>.value(false),
)
as _i14.Future<bool>);
@override
void addListener(_i15.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
void removeListener(_i15.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
@override
void dispose() =>
super.noSuchMethod(Invocation.method(#dispose, []), returnValueForMissingStub: null);
@override
void notifyListeners() =>
super.noSuchMethod(Invocation.method(#notifyListeners, []), returnValueForMissingStub: null);
}
/// A class which mocks [WgerBaseProvider].
///
/// See the documentation for Mockito's code generation for more information.
class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider {
MockWgerBaseProvider() {
_i1.throwOnMissingStub(this);
}
@override
_i4.AuthProvider get auth =>
(super.noSuchMethod(
Invocation.getter(#auth),
returnValue: _FakeAuthProvider_2(this, Invocation.getter(#auth)),
)
as _i4.AuthProvider);
@override
_i5.Client get client =>
(super.noSuchMethod(
Invocation.getter(#client),
returnValue: _FakeClient_3(this, Invocation.getter(#client)),
)
as _i5.Client);
@override
set auth(_i4.AuthProvider? value) =>
super.noSuchMethod(Invocation.setter(#auth, value), returnValueForMissingStub: null);
@override
set client(_i5.Client? value) =>
super.noSuchMethod(Invocation.setter(#client, value), returnValueForMissingStub: null);
@override
Map<String, String> getDefaultHeaders({bool? includeAuth = false}) =>
(super.noSuchMethod(
Invocation.method(#getDefaultHeaders, [], {#includeAuth: includeAuth}),
returnValue: <String, String>{},
)
as Map<String, String>);
@override
Uri makeUrl(String? path, {int? id, String? objectMethod, Map<String, dynamic>? query}) =>
(super.noSuchMethod(
Invocation.method(
#makeUrl,
[path],
{#id: id, #objectMethod: objectMethod, #query: query},
),
returnValue: _FakeUri_4(
this,
Invocation.method(
#makeUrl,
[path],
{#id: id, #objectMethod: objectMethod, #query: query},
),
),
)
as Uri);
@override
_i14.Future<dynamic> fetch(Uri? uri) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
returnValue: _i14.Future<dynamic>.value(),
)
as _i14.Future<dynamic>);
@override
_i14.Future<List<dynamic>> fetchPaginated(Uri? uri) =>
(super.noSuchMethod(
Invocation.method(#fetchPaginated, [uri]),
returnValue: _i14.Future<List<dynamic>>.value(<dynamic>[]),
)
as _i14.Future<List<dynamic>>);
@override
_i14.Future<Map<String, dynamic>> post(Map<String, dynamic>? data, Uri? uri) =>
(super.noSuchMethod(
Invocation.method(#post, [data, uri]),
returnValue: _i14.Future<Map<String, dynamic>>.value(<String, dynamic>{}),
)
as _i14.Future<Map<String, dynamic>>);
@override
_i14.Future<Map<String, dynamic>> patch(Map<String, dynamic>? data, Uri? uri) =>
(super.noSuchMethod(
Invocation.method(#patch, [data, uri]),
returnValue: _i14.Future<Map<String, dynamic>>.value(<String, dynamic>{}),
)
as _i14.Future<Map<String, dynamic>>);
@override
_i14.Future<_i5.Response> deleteRequest(String? url, int? id) =>
(super.noSuchMethod(
Invocation.method(#deleteRequest, [url, id]),
returnValue: _i14.Future<_i5.Response>.value(
_FakeResponse_5(this, Invocation.method(#deleteRequest, [url, id])),
),
)
as _i14.Future<_i5.Response>);
}

View File

@@ -4,7 +4,6 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i15;
import 'dart:io' as _i12;
import 'dart:ui' as _i16;
import 'package:flutter/material.dart' as _i18;
@@ -16,6 +15,7 @@ import 'package:wger/models/exercises/category.dart' as _i7;
import 'package:wger/models/exercises/equipment.dart' as _i8;
import 'package:wger/models/exercises/exercise.dart' as _i6;
import 'package:wger/models/exercises/exercise_submission.dart' as _i13;
import 'package:wger/models/exercises/exercise_submission_images.dart' as _i12;
import 'package:wger/models/exercises/language.dart' as _i10;
import 'package:wger/models/exercises/muscle.dart' as _i9;
import 'package:wger/models/exercises/variation.dart' as _i3;
@@ -97,9 +97,12 @@ class MockAddExerciseProvider extends _i1.Mock implements _i11.AddExerciseProvid
as _i2.WgerBaseProvider);
@override
List<_i12.File> get exerciseImages =>
(super.noSuchMethod(Invocation.getter(#exerciseImages), returnValue: <_i12.File>[])
as List<_i12.File>);
List<_i12.ExerciseSubmissionImage> get exerciseImages =>
(super.noSuchMethod(
Invocation.getter(#exerciseImages),
returnValue: <_i12.ExerciseSubmissionImage>[],
)
as List<_i12.ExerciseSubmissionImage>);
@override
List<String> get alternateNamesEn =>
@@ -234,40 +237,19 @@ class MockAddExerciseProvider extends _i1.Mock implements _i11.AddExerciseProvid
super.noSuchMethod(Invocation.method(#clear, []), returnValueForMissingStub: null);
@override
void addExerciseImages(
List<_i12.File>? images, {
String? title,
String? author,
String? authorUrl,
String? sourceUrl,
String? derivativeSourceUrl,
String? style = '1',
}) => super.noSuchMethod(
Invocation.method(
#addExerciseImages,
[images],
{
#title: title,
#author: author,
#authorUrl: authorUrl,
#sourceUrl: sourceUrl,
#derivativeSourceUrl: derivativeSourceUrl,
#style: style,
},
),
void addExerciseImages(List<_i12.ExerciseSubmissionImage>? images) => super.noSuchMethod(
Invocation.method(#addExerciseImages, [images]),
returnValueForMissingStub: null,
);
@override
void removeExercise(String? path) => super.noSuchMethod(
Invocation.method(#removeExercise, [path]),
returnValueForMissingStub: null,
);
void removeImage(String? path) =>
super.noSuchMethod(Invocation.method(#removeImage, [path]), returnValueForMissingStub: null);
@override
_i15.Future<int> addExercise() =>
_i15.Future<int> postExerciseToServer() =>
(super.noSuchMethod(
Invocation.method(#addExercise, []),
Invocation.method(#postExerciseToServer, []),
returnValue: _i15.Future<int>.value(0),
)
as _i15.Future<int>);