From ed8e9ec744d5002370916e2be9e05da8667a4a76 Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 10:22:01 +0200 Subject: [PATCH 01/29] Added option in mobile app to add more info about image --- lib/providers/add_exercise.dart | 206 ++++++++- .../add_exercise/image_details_form.dart | 412 ++++++++++++++++++ .../add_exercise/steps/step5images.dart | 208 ++++++++- .../add_exercise/steps/step6Overview.dart | 2 +- .../add_exercise_image_withDetail_test.dart | 218 +++++++++ 5 files changed, 1004 insertions(+), 42 deletions(-) create mode 100644 lib/widgets/add_exercise/image_details_form.dart create mode 100644 test/exercises/add_exercise_image_withDetail_test.dart diff --git a/lib/providers/add_exercise.dart b/lib/providers/add_exercise.dart index cdda6710..99c0db8f 100644 --- a/lib/providers/add_exercise.dart +++ b/lib/providers/add_exercise.dart @@ -1,9 +1,9 @@ +import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -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'; @@ -21,6 +21,10 @@ class AddExerciseProvider with ChangeNotifier { List get exerciseImages => [..._exerciseImages]; List _exerciseImages = []; + + // Map storing image metadata (license info, style), keyed by file path + final Map> _imageDetails = {}; + String? exerciseNameEn; String? exerciseNameTrans; String? descriptionEn; @@ -43,6 +47,7 @@ class AddExerciseProvider with ChangeNotifier { void clear() { _exerciseImages = []; + _imageDetails.clear(); // Also clear image metadata languageTranslation = null; category = null; exerciseNameEn = null; @@ -135,18 +140,63 @@ class AddExerciseProvider with ChangeNotifier { ); } - void addExerciseImages(List exercises) { - _exerciseImages.addAll(exercises); + /// 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 images, { + String? title, + String? author, + String? authorUrl, + String? sourceUrl, + String? derivativeSourceUrl, + String style = '1', + }) { + _exerciseImages.addAll(images); + + // Store metadata for each image + for (final image in images) { + final details = { + '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; _exerciseImages.remove(file); + _imageDetails.remove(path); // Also remove associated metadata notifyListeners(); } - //Just to Debug Provider + // Debug method to print all collected exercise data void printValues() { log('Collected exercise data'); log('------------------------'); @@ -164,24 +214,41 @@ class AddExerciseProvider with ChangeNotifier { log('Name: en/$exerciseNameEn translation/$exerciseNameTrans'); log('Description: en/$descriptionEn translation/$descriptionTrans'); log('Alternate names: en/$alternateNamesEn translation/$alternateNamesTrans'); + + log(''); + log('Images: ${_exerciseImages.length} images with details'); } + /// Main method to submit exercise with images + /// + /// Returns the ID of the created exercise + /// Throws exception if submission fails Future addExercise() async { printValues(); - // Create the variations if needed - // if (newVariation) { - // await addVariation(); - // } + try { + // 1. Create the exercise + final exerciseId = await addExerciseSubmission(); + print('Exercise created with ID: $exerciseId'); - // Create the exercise - final exerciseId = await addExerciseSubmission(); + // 2. Upload images if any exist + if (_exerciseImages.isNotEmpty) { + print('Uploading ${_exerciseImages.length} images...'); + await addImages(exerciseId); + print('Images uploaded successfully'); + } else { + print('ℹ No images to upload'); + } - // Clear everything - clear(); + // 3. Clear all data after successful upload + clear(); - // Return exercise ID - return exerciseId; + return exerciseId; + } catch (e) { + print('Error adding exercise: $e'); + // Don't clear on error so user can retry + rethrow; + } } Future addExerciseSubmission() async { @@ -194,16 +261,119 @@ class AddExerciseProvider with ChangeNotifier { return result['id']; } + /// Upload exercise images with license metadata + /// + /// For each image: + /// - Sends multipart request with image file + /// - Includes license fields from _imageDetails map + /// - Validates server response contains all submitted fields Future addImages(int exerciseId) async { + print('Starting image upload for exercise ID: $exerciseId'); + print('Number of images to upload: ${_exerciseImages.length}'); + for (final image in _exerciseImages) { + print('Processing image: ${image.path}'); + 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.fields['exercise'] = exerciseId.toString(); - request.fields['style'] = EXERCISE_IMAGE_ART_STYLE.PHOTO.index.toString(); + request.fields['license'] = '1'; + request.fields['is_main'] = 'false'; - await request.send(); + final details = _imageDetails[image.path]; + print('Image details to send: $details'); + + if (details != null && details.isNotEmpty) { + request.fields.addAll(details); + print('Request fields: ${request.fields}'); + } else { + request.fields['style'] = '1'; + } + + try { + print('Sending request...'); + final streamedResponse = await request.send(); + print('Response status: ${streamedResponse.statusCode}'); + + // Read response body to verify upload success + final response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 201 || response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + log('Image uploaded successfully!'); + log('Response body: ${response.body}'); + + // Debug: Print all fields from server response + print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + print('SERVER RESPONSE:'); + print('ID: ${responseData['id']}'); + print('UUID: ${responseData['uuid']}'); + print('Image URL: ${responseData['image']}'); + print('Exercise: ${responseData['exercise']}'); + print('Style: ${responseData['style']}'); + print('License: ${responseData['license']}'); + print('License Title: ${responseData['license_title']}'); + print('License Author: ${responseData['license_author']}'); + print('License Author URL: ${responseData['license_author_url']}'); + print('License Object URL: ${responseData['license_object_url']}'); + print('License Derivative: ${responseData['license_derivative_source_url']}'); + print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Validate that license fields were saved correctly + if (details != null) { + bool allFieldsMatch = true; + + if (details.containsKey('license_title') && + responseData['license_title'] != details['license_title']) { + print('WARNING: license_title mismatch!'); + print('Sent: ${details['license_title']}'); + print('Received: ${responseData['license_title']}'); + allFieldsMatch = false; + } + + if (details.containsKey('license_author') && + responseData['license_author'] != details['license_author']) { + print('WARNING: license_author mismatch!'); + allFieldsMatch = false; + } + + if (details.containsKey('license_author_url') && + responseData['license_author_url'] != details['license_author_url']) { + print('WARNING: license_author_url mismatch!'); + allFieldsMatch = false; + } + + if (details.containsKey('license_object_url') && + responseData['license_object_url'] != details['license_object_url']) { + print('WARNING: license_object_url mismatch!'); + allFieldsMatch = false; + } + + if (details.containsKey('license_derivative_source_url') && + responseData['license_derivative_source_url'] != details['license_derivative_source_url']) { + print('WARNING: license_derivative_source_url mismatch!'); + allFieldsMatch = false; + } + + if (allFieldsMatch) { + print('ALL LICENSE FIELDS SAVED CORRECTLY!'); + } else { + print('SOME LICENSE FIELDS NOT SAVED CORRECTLY!'); + } + } + + } else { + log('Failed to upload image: ${response.statusCode}'); + log('Response body: ${response.body}'); + throw Exception('Upload failed: ${response.statusCode}'); + } + } catch (e) { + log('Error uploading image: $e'); + rethrow; + } } notifyListeners(); @@ -220,7 +390,7 @@ class AddExerciseProvider with ChangeNotifier { return false; } - /* +/* Note: all this logic is not needed now since we are using the /exercise-submission endpoint, however, if we ever want to implement editing of exercises, we will need basically all of it again, so this is kept here for reference. @@ -275,4 +445,4 @@ class AddExerciseProvider with ChangeNotifier { } */ -} +} \ No newline at end of file diff --git a/lib/widgets/add_exercise/image_details_form.dart b/lib/widgets/add_exercise/image_details_form.dart new file mode 100644 index 00000000..315d270e --- /dev/null +++ b/lib/widgets/add_exercise/image_details_form.dart @@ -0,0 +1,412 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; + +/// Form for collecting image metadata including license information +/// +/// Displayed after image selection in Step 5 of exercise creation +/// Collects: title, source URL, author info, derivative work info, and image style +class ImageDetailsForm extends StatefulWidget { + final File imageFile; + final Function(File image, Map details) onAdd; + final VoidCallback onCancel; + + const ImageDetailsForm({ + Key? key, + required this.imageFile, + required this.onAdd, + required this.onCancel, + }) : super(key: key); + + @override + State createState() => _ImageDetailsFormState(); +} + +class _ImageDetailsFormState extends State { + final _formKey = GlobalKey(); + + final _titleController = TextEditingController(); + final _sourceLinkController = TextEditingController(); + final _authorController = TextEditingController(); + final _authorLinkController = TextEditingController(); + final _originalSourceController = TextEditingController(); + + String _selectedImageType = 'PHOTO'; + + final List> _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'}, + ]; + + @override + void dispose() { + _titleController.dispose(); + _sourceLinkController.dispose(); + _authorController.dispose(); + _authorLinkController.dispose(); + _originalSourceController.dispose(); + super.dispose(); + } + + /// Maps UI image type selection to API style value + /// PHOTO=1, 3D=2, LINE=3, LOW-POLY=4, OTHER=5 + String _getStyleValue() { + switch (_selectedImageType) { + case 'PHOTO': + return '1'; + case '3D': + return '2'; + case 'LINE': + return '3'; + case 'LOW-POLY': + return '4'; + case 'OTHER': + return '5'; + default: + return '1'; + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Image details', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + _buildImagePreview(), + const SizedBox(height: 24), + + _buildTextField( + controller: _titleController, + label: 'Title', + hint: 'Enter image title', + ), + const SizedBox(height: 16), + + _buildTextField( + controller: _sourceLinkController, + label: 'Link to the source website, if available', + hint: 'https://example.com', + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + + _buildTextField( + controller: _authorController, + label: 'Author(s)', + hint: 'Enter author name', + ), + const SizedBox(height: 16), + + _buildTextField( + controller: _authorLinkController, + label: 'Link to author website or profile, if available', + hint: 'https://example.com/author', + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + + _buildTextField( + controller: _originalSourceController, + label: 'Link to the original source, if this is a derivative work', + hint: 'https://example.com/original', + keyboardType: TextInputType.url, + ), + const SizedBox(height: 8), + + _buildDerivativeWorkNote(), + const SizedBox(height: 24), + + _buildImageTypeSelector(), + const SizedBox(height: 24), + + _buildLicenseInfo(), + const SizedBox(height: 24), + + _buildButtons(), + ], + ), + ), + ), + ); + } + + Widget _buildImagePreview() { + return Center( + child: Container( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 200, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + widget.imageFile, + fit: BoxFit.contain, + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + String? hint, + TextInputType? keyboardType, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ], + ); + } + + /// Info box explaining what constitutes a derivative work + Widget _buildDerivativeWorkNote() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Colors.blue.shade700, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.', + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade900, + ), + ), + ), + ], + ), + ); + } + + /// Selector for image style (PHOTO, 3D, LINE, LOW-POLY, OTHER) + Widget _buildImageTypeSelector() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Image Type', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _imageTypes.map((type) { + final isSelected = _selectedImageType == type['type']; + return InkWell( + onTap: () { + setState(() { + _selectedImageType = type['type']; + }); + }, + borderRadius: BorderRadius.circular(4), + child: Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: isSelected ? Colors.blue.shade50 : Colors.white, + border: Border.all( + color: isSelected ? Colors.blue : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + type['icon'], + size: 32, + color: isSelected ? Colors.blue : Colors.grey.shade600, + ), + const SizedBox(height: 8), + Text( + type['label'], + style: TextStyle( + fontSize: 11, + color: isSelected ? Colors.blue : Colors.grey.shade700, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ); + } + + /// Warning box about CC BY-SA 4.0 license requirements + Widget _buildLicenseInfo() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amber.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Colors.amber.shade900, + ), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade900, + ), + children: const [ + TextSpan( + text: 'By submitting this image, you agree to release it under the ', + ), + TextSpan( + text: 'CC BY-SA 4.0', + style: TextStyle( + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + TextSpan( + text: ' license. The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.', + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildButtons() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: widget.onCancel, + child: const Text('CANCEL'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + // Build details map with API field names + final details = { + 'style': _getStyleValue(), + }; + + // Add only non-empty fields + 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; + } + + widget.onAdd(widget.imageFile, details); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: const Text( + 'ADD', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_exercise/steps/step5images.dart b/lib/widgets/add_exercise/steps/step5images.dart index 54f89f9b..413be166 100644 --- a/lib/widgets/add_exercise/steps/step5images.dart +++ b/lib/widgets/add_exercise/steps/step5images.dart @@ -1,13 +1,15 @@ +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/providers/add_exercise.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'; class Step5Images extends StatefulWidget { final GlobalKey formkey; - const Step5Images({required this.formkey}); @override @@ -15,39 +17,199 @@ class Step5Images extends StatefulWidget { } class _Step5ImagesState extends State with ExerciseImagePickerMixin { + File? _currentImageToAdd; + + /// Pick image and show details form for license metadata + void _pickAndShowImageDetails(BuildContext context, {bool pickFromCamera = false}) async { + final imagePicker = ImagePicker(); + + XFile? selectedImage; + if (pickFromCamera) { + selectedImage = await imagePicker.pickImage(source: ImageSource.camera); + } else { + selectedImage = await imagePicker.pickImage(source: ImageSource.gallery); + } + + if (selectedImage != null) { + final imageFile = File(selectedImage.path); + + // Validate file type and size + bool isFileValid = true; + String errorMessage = ''; + + final extension = imageFile.path.split('.').last; + const validFileExtensions = ['jpg', 'jpeg', 'png', 'webp']; + if (!validFileExtensions.any((ext) => extension.toLowerCase() == ext)) { + isFileValid = false; + errorMessage = "Select only 'jpg', 'jpeg', 'png', 'webp' files"; + } + + final fileSizeInMB = imageFile.lengthSync() / 1024 / 1024; + if (fileSizeInMB > 20) { + isFileValid = false; + errorMessage = 'File Size should not be greater than 20 MB'; + } + + if (!isFileValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(errorMessage)), + ); + } + return; + } + + // Show details form for valid image + setState(() { + _currentImageToAdd = imageFile; + }); + } + } + + /// Add image with license metadata to provider + void _addImageWithDetails(File image, Map details) { + final provider = context.read(); + + 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', + ); + + setState(() { + _currentImageToAdd = null; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Image added with details'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + + void _cancelImageAdd() { + setState(() { + _currentImageToAdd = null; + }); + } + @override Widget build(BuildContext context) { return Form( key: widget.formkey, child: Column( children: [ - Text( - AppLocalizations.of(context).add_exercise_image_license, - style: Theme.of(context).textTheme.bodySmall, - ), - Consumer( - builder: (ctx, provider, __) => provider.exerciseImages.isNotEmpty - ? PreviewExerciseImages(selectedImages: provider.exerciseImages) - : Row( - mainAxisAlignment: MainAxisAlignment.center, + // Show license notice when not adding image + if (_currentImageToAdd == null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text( + AppLocalizations.of(context).add_exercise_image_license, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + + // Show image details form when image is selected + if (_currentImageToAdd != null) + ImageDetailsForm( + imageFile: _currentImageToAdd!, + onAdd: _addImageWithDetails, + onCancel: _cancelImageAdd, + ), + + // Show image picker or preview when no image is being added + if (_currentImageToAdd == null) + Consumer( + builder: (ctx, provider, __) { + if (provider.exerciseImages.isNotEmpty) { + // Show preview of existing images + return Column( children: [ - IconButton( - onPressed: () => pickImages(context, pickFromCamera: true), - icon: const Icon(Icons.camera_alt), + PreviewExerciseImages( + selectedImages: provider.exerciseImages, ), - IconButton( - onPressed: () => pickImages(context), - icon: const Icon(Icons.collections), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _pickAndShowImageDetails(context), + icon: const Icon(Icons.add_photo_alternate), + label: const Text('Add Another Image'), ), ], - ), - ), - Text( - 'Only JPEG, PNG and WEBP files below 20 MB are supported', - style: Theme.of(context).textTheme.bodySmall, - ), + ); + } + + // Show empty state with camera/gallery buttons + return Column( + children: [ + const SizedBox(height: 20), + Icon( + Icons.add_photo_alternate, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No images selected', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => _pickAndShowImageDetails( + context, + pickFromCamera: true, + ), + icon: const Icon(Icons.camera_alt), + label: const Text('Camera'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: () => _pickAndShowImageDetails(context), + icon: const Icon(Icons.collections), + label: const Text('Gallery'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Only JPEG, PNG and WEBP files below 20 MB are supported', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + }, + ), ], ), ); } -} +} \ No newline at end of file diff --git a/lib/widgets/add_exercise/steps/step6Overview.dart b/lib/widgets/add_exercise/steps/step6Overview.dart index fbe65664..43f707d8 100644 --- a/lib/widgets/add_exercise/steps/step6Overview.dart +++ b/lib/widgets/add_exercise/steps/step6Overview.dart @@ -108,4 +108,4 @@ class Step6Overview extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/test/exercises/add_exercise_image_withDetail_test.dart b/test/exercises/add_exercise_image_withDetail_test.dart new file mode 100644 index 00000000..5e4435d3 --- /dev/null +++ b/test/exercises/add_exercise_image_withDetail_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/providers/add_exercise.dart'; + +import '../core/settings_test.mocks.dart'; + +void main() { + late MockWgerBaseProvider mockBaseProvider; + late AddExerciseProvider provider; + + setUp(() { + mockBaseProvider = MockWgerBaseProvider(); + provider = AddExerciseProvider(mockBaseProvider); + }); + + group('Image metadata handling', () { + 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', + ); + + expect(provider.exerciseImages.length, 1); + expect(provider.exerciseImages.first.path, mockFile.path); + }); + + test('should handle empty fields gracefully', () { + final mockFile = File('test2.jpg'); + + provider.addExerciseImages( + [mockFile], + title: 'Only Title', + author: null, + authorUrl: '', + style: '2', + ); + + expect(provider.exerciseImages.length, 1); + }); + + test('should handle multiple images with different metadata', () { + 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'); + + expect(provider.exerciseImages.length, 2); + expect(provider.exerciseImages[0].path, file1.path); + expect(provider.exerciseImages[1].path, file2.path); + }); + + 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); + }); + + test('should use default style when not specified', () { + final mockFile = File('default.jpg'); + + provider.addExerciseImages([mockFile]); + + expect(provider.exerciseImages.length, 1); + }); + + test('should handle empty image list', () { + provider.addExerciseImages([]); + + expect(provider.exerciseImages.length, 0); + }); + + 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); + }); + + test('should remove image and its metadata', () { + final mockFile = File('to_remove.jpg'); + provider.addExerciseImages([mockFile], title: 'Will be removed', style: '1'); + + expect(provider.exerciseImages.length, 1); + + provider.removeExercise(mockFile.path); + + expect(provider.exerciseImages.length, 0); + }); + + test('should handle removing non-existent image gracefully', () { + expect( + () => provider.removeExercise('nonexistent.jpg'), + throwsStateError, + ); + }); + + test('should clear all images and metadata', () { + provider.addExerciseImages([File('image1.jpg')], title: 'Image 1'); + provider.addExerciseImages([File('image2.jpg')], title: 'Image 2'); + + expect(provider.exerciseImages.length, 2); + + provider.clear(); + + expect(provider.exerciseImages.length, 0); + }); + + test('should handle clearing empty list', () { + expect(provider.exerciseImages.length, 0); + + provider.clear(); + + expect(provider.exerciseImages.length, 0); + }); + + 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'); + }); + + 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); + }); + + 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]); + + expect(provider.exerciseImages.length, 3); + + provider.removeExercise(file2.path); + + expect(provider.exerciseImages.length, 2); + expect(provider.exerciseImages[0].path, file1.path); + expect(provider.exerciseImages[1].path, file3.path); + }); + + test('should handle very long metadata strings', () { + final mockFile = File('long.jpg'); + final longString = 'a' * 1000; + + provider.addExerciseImages( + [mockFile], + title: longString, + author: longString, + authorUrl: 'https://example.com/$longString', + ); + + expect(provider.exerciseImages.length, 1); + }); + + 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', + ); + + expect(provider.exerciseImages.length, 1); + }); + }); + + group('State management', () { + test('should reset all state after clear', () { + provider.exerciseNameEn = 'Test Exercise'; + provider.descriptionEn = 'Description'; + provider.addExerciseImages([File('test.jpg')]); + + provider.clear(); + + expect(provider.exerciseImages.length, 0); + expect(provider.exerciseNameEn, isNull); + expect(provider.descriptionEn, isNull); + }); + }); +} \ No newline at end of file From e9ab11c8bb0f5dba45002030ca64494d9ec54750 Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 10:58:36 +0200 Subject: [PATCH 02/29] added better comments --- .../add_exercise/image_details_form.dart | 67 +++++++++++++++---- .../add_exercise/steps/step5images.dart | 48 ++++++++++--- .../add_exercise_image_withDetail_test.dart | 47 +++++++++++-- 3 files changed, 135 insertions(+), 27 deletions(-) diff --git a/lib/widgets/add_exercise/image_details_form.dart b/lib/widgets/add_exercise/image_details_form.dart index 315d270e..805fb586 100644 --- a/lib/widgets/add_exercise/image_details_form.dart +++ b/lib/widgets/add_exercise/image_details_form.dart @@ -1,10 +1,24 @@ import 'dart:io'; import 'package:flutter/material.dart'; -/// Form for collecting image metadata including license information +/// Form for collecting CC BY-SA 4.0 license metadata for exercise images /// -/// Displayed after image selection in Step 5 of exercise creation -/// Collects: title, source URL, author info, derivative work info, and image style +/// This form is displayed after image selection in Step 5 of exercise creation. +/// It collects all required and optional license attribution fields: +/// +/// Required by CC BY-SA 4.0: +/// - Author name +/// - License type (implicitly CC BY-SA 4.0) +/// +/// Optional but recommended: +/// - Title (helps identify the image) +/// - Source URL (where image was found) +/// - Author URL (author's website/profile) +/// - Derivative source URL (if modified from another work) +/// - Image style (PHOTO, 3D, LINE, LOW-POLY, OTHER) +/// +/// 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 details) onAdd; @@ -24,12 +38,15 @@ class ImageDetailsForm extends StatefulWidget { class _ImageDetailsFormState extends State { final _formKey = GlobalKey(); + // Text controllers for license metadata fields final _titleController = TextEditingController(); - final _sourceLinkController = TextEditingController(); - final _authorController = TextEditingController(); - final _authorLinkController = TextEditingController(); - final _originalSourceController = 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 + /// 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> _imageTypes = [ @@ -50,8 +67,14 @@ class _ImageDetailsFormState extends State { super.dispose(); } - /// Maps UI image type selection to API style value - /// PHOTO=1, 3D=2, LINE=3, LOW-POLY=4, OTHER=5 + /// Maps UI image type selection to API 'style' field value + /// + /// API expects numeric string: + /// - PHOTO = '1' + /// - 3D = '2' + /// - LINE = '3' + /// - LOW-POLY = '4' + /// - OTHER = '5' String _getStyleValue() { switch (_selectedImageType) { case 'PHOTO': @@ -65,7 +88,7 @@ class _ImageDetailsFormState extends State { case 'OTHER': return '5'; default: - return '1'; + return '1'; // Default to PHOTO if unknown } } @@ -90,6 +113,7 @@ class _ImageDetailsFormState extends State { _buildImagePreview(), const SizedBox(height: 24), + // License title field - helps identify the image _buildTextField( controller: _titleController, label: 'Title', @@ -97,6 +121,7 @@ class _ImageDetailsFormState extends State { ), const SizedBox(height: 16), + // Source URL - where the image was found (license_object_url in API) _buildTextField( controller: _sourceLinkController, label: 'Link to the source website, if available', @@ -105,6 +130,7 @@ class _ImageDetailsFormState extends State { ), const SizedBox(height: 16), + // Author name - required for proper CC BY-SA attribution _buildTextField( controller: _authorController, label: 'Author(s)', @@ -112,6 +138,7 @@ class _ImageDetailsFormState extends State { ), const SizedBox(height: 16), + // Author's website/profile URL _buildTextField( controller: _authorLinkController, label: 'Link to author website or profile, if available', @@ -120,6 +147,7 @@ class _ImageDetailsFormState extends State { ), const SizedBox(height: 16), + // Original source if this is a derivative work (modified from another image) _buildTextField( controller: _originalSourceController, label: 'Link to the original source, if this is a derivative work', @@ -202,7 +230,10 @@ class _ImageDetailsFormState extends State { ); } - /// Info box explaining what constitutes a derivative work + /// Informational box explaining what constitutes a derivative work + /// + /// Important for CC BY-SA compliance - users need to understand when + /// they must cite the original work they modified Widget _buildDerivativeWorkNote() { return Container( padding: const EdgeInsets.all(12), @@ -234,7 +265,10 @@ class _ImageDetailsFormState extends State { ); } - /// Selector for image style (PHOTO, 3D, LINE, LOW-POLY, OTHER) + /// Visual selector for image style/type + /// + /// Allows user to categorize the image as PHOTO, 3D render, LINE drawing, + /// LOW-POLY art, or OTHER. This helps users find appropriate exercise images. Widget _buildImageTypeSelector() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -298,7 +332,10 @@ class _ImageDetailsFormState extends State { ); } - /// Warning box about CC BY-SA 4.0 license requirements + /// Legal notice about CC BY-SA 4.0 license + /// + /// Critical for compliance - informs user that by uploading, they're + /// releasing the image under CC BY-SA 4.0 and must have rights to do so Widget _buildLicenseInfo() { return Container( padding: const EdgeInsets.all(12), @@ -359,11 +396,12 @@ class _ImageDetailsFormState extends State { onPressed: () { if (_formKey.currentState!.validate()) { // Build details map with API field names + // Style is always included, other fields only if non-empty final details = { 'style': _getStyleValue(), }; - // Add only non-empty fields + // Add optional fields only if user provided values final title = _titleController.text.trim(); if (title.isNotEmpty) { details['license_title'] = title; @@ -389,6 +427,7 @@ class _ImageDetailsFormState extends State { details['license_derivative_source_url'] = derivativeUrl; } + // Pass image and metadata back to parent widget.onAdd(widget.imageFile, details); } }, diff --git a/lib/widgets/add_exercise/steps/step5images.dart b/lib/widgets/add_exercise/steps/step5images.dart index 413be166..8ccaf83a 100644 --- a/lib/widgets/add_exercise/steps/step5images.dart +++ b/lib/widgets/add_exercise/steps/step5images.dart @@ -8,6 +8,17 @@ 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 +/// +/// This step allows users to add exercise images with proper CC BY-SA 4.0 license +/// attribution. Unlike the previous implementation that uploaded images directly, +/// this version collects license metadata (title, author, URLs) before adding images. +/// +/// Flow: +/// 1. User picks image from camera/gallery +/// 2. ImageDetailsForm is shown to collect license metadata +/// 3. Image + metadata is stored in AddExerciseProvider +/// 4. Final upload happens in Step 6 when user clicks "Submit" class Step5Images extends StatefulWidget { final GlobalKey formkey; const Step5Images({required this.formkey}); @@ -17,9 +28,16 @@ class Step5Images extends StatefulWidget { } class _Step5ImagesState extends State with ExerciseImagePickerMixin { + /// Currently selected image waiting for metadata input + /// When non-null, ImageDetailsForm is displayed instead of image picker File? _currentImageToAdd; - /// Pick image and show details form for license metadata + /// Pick image from camera or gallery and show metadata collection form + /// + /// Validates file format (jpg, jpeg, png, webp) and size (<20MB) before + /// showing the form. Invalid files are rejected with a snackbar message. + /// + /// [pickFromCamera] - If true, opens camera; otherwise opens gallery void _pickAndShowImageDetails(BuildContext context, {bool pickFromCamera = false}) async { final imagePicker = ImagePicker(); @@ -33,7 +51,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin if (selectedImage != null) { final imageFile = File(selectedImage.path); - // Validate file type and size + // Validate file type - only common image formats accepted bool isFileValid = true; String errorMessage = ''; @@ -44,6 +62,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin errorMessage = "Select only 'jpg', 'jpeg', 'png', 'webp' files"; } + // Validate file size - 20MB limit matches server-side restriction final fileSizeInMB = imageFile.lengthSync() / 1024 / 1024; if (fileSizeInMB > 20) { isFileValid = false; @@ -59,17 +78,25 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin return; } - // Show details form for valid image + // Show metadata collection form for valid image setState(() { _currentImageToAdd = imageFile; }); } } - /// Add image with license metadata to provider + /// Add image with its license metadata to the provider + /// + /// Called when user clicks "ADD" in ImageDetailsForm. The image and metadata + /// are stored locally in AddExerciseProvider and will be uploaded together + /// when the exercise is submitted in Step 6. + /// + /// [image] - The image file to add + /// [details] - Map containing license fields (license_title, license_author, etc.) void _addImageWithDetails(File image, Map details) { final provider = context.read(); + // Store image with metadata - actual upload happens in addExercise() provider.addExerciseImages( [image], title: details['license_title'], @@ -80,6 +107,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin style: details['style'] ?? '1', ); + // Reset form state setState(() { _currentImageToAdd = null; }); @@ -93,6 +121,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ); } + /// Cancel metadata input and return to image picker void _cancelImageAdd() { setState(() { _currentImageToAdd = null; @@ -105,7 +134,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin key: widget.formkey, child: Column( children: [ - // Show license notice when not adding image + // License notice - shown when not entering metadata if (_currentImageToAdd == null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), @@ -116,7 +145,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ), ), - // Show image details form when image is selected + // Metadata collection form - shown when image is selected if (_currentImageToAdd != null) ImageDetailsForm( imageFile: _currentImageToAdd!, @@ -124,12 +153,12 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin onCancel: _cancelImageAdd, ), - // Show image picker or preview when no image is being added + // Image picker or preview - shown when not entering metadata if (_currentImageToAdd == null) Consumer( builder: (ctx, provider, __) { if (provider.exerciseImages.isNotEmpty) { - // Show preview of existing images + // Show preview of images that have been added with metadata return Column( children: [ PreviewExerciseImages( @@ -145,7 +174,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ); } - // Show empty state with camera/gallery buttons + // Empty state - no images added yet return Column( children: [ const SizedBox(height: 20), @@ -162,6 +191,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ), ), const SizedBox(height: 24), + // Camera and Gallery buttons Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/test/exercises/add_exercise_image_withDetail_test.dart b/test/exercises/add_exercise_image_withDetail_test.dart index 5e4435d3..b7428085 100644 --- a/test/exercises/add_exercise_image_withDetail_test.dart +++ b/test/exercises/add_exercise_image_withDetail_test.dart @@ -4,6 +4,16 @@ import 'package:wger/providers/add_exercise.dart'; import '../core/settings_test.mocks.dart'; +/// Unit tests for AddExerciseProvider image metadata handling +/// +/// Tests the functionality added for issue #931 - storing and managing +/// CC BY-SA 4.0 license metadata alongside exercise images. +/// +/// Key areas tested: +/// - Adding images with complete/partial/no metadata +/// - Edge cases (empty lists, duplicates, special characters) +/// - State management (clear, remove) +/// - Image ordering and batch operations void main() { late MockWgerBaseProvider mockBaseProvider; late AddExerciseProvider provider; @@ -14,6 +24,8 @@ void main() { }); group('Image metadata handling', () { + /// Verify that all CC BY-SA license fields are stored correctly + /// Tests: title, author, authorUrl, sourceUrl, derivativeSourceUrl, style test('should store image with all license fields', () { final mockFile = File('test.jpg'); @@ -24,27 +36,31 @@ void main() { authorUrl: 'https://test.com/author', sourceUrl: 'https://source.com', derivativeSourceUrl: 'https://derivative.com', - style: '4', + style: '4', // LOW-POLY ); expect(provider.exerciseImages.length, 1); expect(provider.exerciseImages.first.path, mockFile.path); }); + /// License fields are optional - provider should handle null/empty values + /// Only non-empty fields should be included in the metadata map test('should handle empty fields gracefully', () { final mockFile = File('test2.jpg'); provider.addExerciseImages( [mockFile], title: 'Only Title', - author: null, - authorUrl: '', - style: '2', + author: null, // null value + authorUrl: '', // empty string + style: '2', // 3D ); expect(provider.exerciseImages.length, 1); }); + /// Each image can have different metadata - test storing multiple + /// images with unique license information test('should handle multiple images with different metadata', () { final file1 = File('image1.jpg'); final file2 = File('image2.jpg'); @@ -57,6 +73,8 @@ void main() { 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']; @@ -70,6 +88,7 @@ void main() { 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'); @@ -78,12 +97,15 @@ void main() { expect(provider.exerciseImages.length, 1); }); + /// Edge case: calling addExerciseImages with empty list should not crash test('should handle empty image list', () { provider.addExerciseImages([]); 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'); @@ -93,6 +115,8 @@ void main() { 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'); @@ -104,6 +128,8 @@ void main() { expect(provider.exerciseImages.length, 0); }); + /// 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'), @@ -111,6 +137,7 @@ void main() { ); }); + /// 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'); @@ -122,6 +149,7 @@ void main() { expect(provider.exerciseImages.length, 0); }); + /// Clearing an already empty list should not cause errors test('should handle clearing empty list', () { expect(provider.exerciseImages.length, 0); @@ -130,6 +158,8 @@ 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'); @@ -144,6 +174,8 @@ void main() { 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'), @@ -156,6 +188,7 @@ void main() { 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'); @@ -174,6 +207,8 @@ void main() { expect(provider.exerciseImages[1].path, file3.path); }); + /// Test with extremely long strings (1000 chars) to ensure no + /// buffer overflow or validation issues test('should handle very long metadata strings', () { final mockFile = File('long.jpg'); final longString = 'a' * 1000; @@ -188,6 +223,8 @@ void main() { expect(provider.exerciseImages.length, 1); }); + /// Unicode characters, emojis, and URL-encoded strings should all work + /// Tests international character support test('should handle special characters in metadata', () { final mockFile = File('special.jpg'); @@ -203,6 +240,8 @@ void main() { }); group('State management', () { + /// clear() should reset ALL provider state, not just images + /// Ensures no data leaks between exercises test('should reset all state after clear', () { provider.exerciseNameEn = 'Test Exercise'; provider.descriptionEn = 'Description'; From cb138f41f7ccb9c6f16212cbe934e3efdba3534f Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 11:07:41 +0200 Subject: [PATCH 03/29] missing button fix --- .../add_exercise/image_details_form.dart | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/widgets/add_exercise/image_details_form.dart b/lib/widgets/add_exercise/image_details_form.dart index 805fb586..a8985069 100644 --- a/lib/widgets/add_exercise/image_details_form.dart +++ b/lib/widgets/add_exercise/image_details_form.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; /// Form for collecting CC BY-SA 4.0 license metadata for exercise images /// @@ -360,18 +361,30 @@ class _ImageDetailsFormState extends State { fontSize: 12, color: Colors.amber.shade900, ), - children: const [ - TextSpan( + children: [ + const TextSpan( text: 'By submitting this image, you agree to release it under the ', ), - TextSpan( - text: 'CC BY-SA 4.0', - style: TextStyle( - fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, + WidgetSpan( + child: GestureDetector( + onTap: () async { + final url = Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + child: Text( + 'CC BY-SA 4.0', + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade900, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), ), ), - TextSpan( + const TextSpan( text: ' license. The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.', ), ], @@ -433,6 +446,7 @@ class _ImageDetailsFormState extends State { }, style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 32, vertical: 12, @@ -442,6 +456,7 @@ class _ImageDetailsFormState extends State { 'ADD', style: TextStyle( fontWeight: FontWeight.bold, + color: Colors.white, ), ), ), From 7bc84634068848635701767cfd8958da322cb37f Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 11:13:10 +0200 Subject: [PATCH 04/29] dar format was performed --- lib/providers/add_exercise.dart | 28 +++--- .../add_exercise/image_details_form.dart | 87 +++++-------------- .../add_exercise/steps/step5images.dart | 43 +++------ .../add_exercise/steps/step6Overview.dart | 2 +- .../add_exercise_image_withDetail_test.dart | 18 +--- 5 files changed, 51 insertions(+), 127 deletions(-) diff --git a/lib/providers/add_exercise.dart b/lib/providers/add_exercise.dart index 99c0db8f..5b8ef729 100644 --- a/lib/providers/add_exercise.dart +++ b/lib/providers/add_exercise.dart @@ -150,21 +150,19 @@ class AddExerciseProvider with ChangeNotifier { /// [derivativeSourceUrl] - Derivative source URL /// [style] - Image style: 1=PHOTO, 2=3D, 3=LINE, 4=LOW-POLY, 5=OTHER void addExerciseImages( - List images, { - String? title, - String? author, - String? authorUrl, - String? sourceUrl, - String? derivativeSourceUrl, - String style = '1', - }) { + List images, { + String? title, + String? author, + String? authorUrl, + String? sourceUrl, + String? derivativeSourceUrl, + String style = '1', + }) { _exerciseImages.addAll(images); // Store metadata for each image for (final image in images) { - final details = { - 'style': style, - }; + final details = {'style': style}; // Only add non-empty fields if (title != null && title.isNotEmpty) { @@ -353,7 +351,8 @@ class AddExerciseProvider with ChangeNotifier { } if (details.containsKey('license_derivative_source_url') && - responseData['license_derivative_source_url'] != details['license_derivative_source_url']) { + responseData['license_derivative_source_url'] != + details['license_derivative_source_url']) { print('WARNING: license_derivative_source_url mismatch!'); allFieldsMatch = false; } @@ -364,7 +363,6 @@ class AddExerciseProvider with ChangeNotifier { print('SOME LICENSE FIELDS NOT SAVED CORRECTLY!'); } } - } else { log('Failed to upload image: ${response.statusCode}'); log('Response body: ${response.body}'); @@ -390,7 +388,7 @@ class AddExerciseProvider with ChangeNotifier { return false; } -/* + /* Note: all this logic is not needed now since we are using the /exercise-submission endpoint, however, if we ever want to implement editing of exercises, we will need basically all of it again, so this is kept here for reference. @@ -445,4 +443,4 @@ class AddExerciseProvider with ChangeNotifier { } */ -} \ No newline at end of file +} diff --git a/lib/widgets/add_exercise/image_details_form.dart b/lib/widgets/add_exercise/image_details_form.dart index a8985069..6479782b 100644 --- a/lib/widgets/add_exercise/image_details_form.dart +++ b/lib/widgets/add_exercise/image_details_form.dart @@ -105,9 +105,9 @@ class _ImageDetailsFormState extends State { children: [ Text( 'Image details', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), @@ -177,20 +177,14 @@ class _ImageDetailsFormState extends State { Widget _buildImagePreview() { return Center( child: Container( - constraints: const BoxConstraints( - maxWidth: 300, - maxHeight: 200, - ), + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 200), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.file( - widget.imageFile, - fit: BoxFit.contain, - ), + child: Image.file(widget.imageFile, fit: BoxFit.contain), ), ), ); @@ -205,26 +199,15 @@ class _ImageDetailsFormState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), + Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), const SizedBox(height: 8), TextFormField( controller: controller, keyboardType: keyboardType, decoration: InputDecoration( hintText: hint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), ), ], @@ -246,19 +229,12 @@ class _ImageDetailsFormState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.info_outline, - size: 20, - color: Colors.blue.shade700, - ), + Icon(Icons.info_outline, size: 20, color: Colors.blue.shade700), const SizedBox(width: 8), Expanded( child: Text( 'Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.', - style: TextStyle( - fontSize: 12, - color: Colors.blue.shade900, - ), + style: TextStyle(fontSize: 12, color: Colors.blue.shade900), ), ), ], @@ -274,13 +250,7 @@ class _ImageDetailsFormState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Image Type', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), + const Text('Image Type', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), const SizedBox(height: 12), Wrap( spacing: 8, @@ -348,19 +318,12 @@ class _ImageDetailsFormState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.info_outline, - size: 20, - color: Colors.amber.shade900, - ), + Icon(Icons.info_outline, size: 20, color: Colors.amber.shade900), const SizedBox(width: 8), Expanded( child: RichText( text: TextSpan( - style: TextStyle( - fontSize: 12, - color: Colors.amber.shade900, - ), + style: TextStyle(fontSize: 12, color: Colors.amber.shade900), children: [ const TextSpan( text: 'By submitting this image, you agree to release it under the ', @@ -385,7 +348,8 @@ class _ImageDetailsFormState extends State { ), ), const TextSpan( - text: ' license. The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.', + text: + ' license. The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.', ), ], ), @@ -400,19 +364,14 @@ class _ImageDetailsFormState extends State { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - onPressed: widget.onCancel, - child: const Text('CANCEL'), - ), + TextButton(onPressed: widget.onCancel, child: const Text('CANCEL')), 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 = { - 'style': _getStyleValue(), - }; + final details = {'style': _getStyleValue()}; // Add optional fields only if user provided values final title = _titleController.text.trim(); @@ -447,20 +406,14 @@ class _ImageDetailsFormState extends State { style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 12, - ), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), ), child: const Text( 'ADD', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - ), + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), ), ), ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/add_exercise/steps/step5images.dart b/lib/widgets/add_exercise/steps/step5images.dart index 8ccaf83a..179e0514 100644 --- a/lib/widgets/add_exercise/steps/step5images.dart +++ b/lib/widgets/add_exercise/steps/step5images.dart @@ -71,9 +71,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin if (!isFileValid) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(errorMessage)), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage))); } return; } @@ -161,9 +159,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin // Show preview of images that have been added with metadata return Column( children: [ - PreviewExerciseImages( - selectedImages: provider.exerciseImages, - ), + PreviewExerciseImages(selectedImages: provider.exerciseImages), const SizedBox(height: 16), ElevatedButton.icon( onPressed: () => _pickAndShowImageDetails(context), @@ -178,17 +174,13 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin return Column( children: [ const SizedBox(height: 20), - Icon( - Icons.add_photo_alternate, - size: 80, - color: Colors.grey.shade400, - ), + Icon(Icons.add_photo_alternate, size: 80, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'No images selected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.grey.shade600, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Colors.grey.shade600), ), const SizedBox(height: 24), // Camera and Gallery buttons @@ -196,17 +188,11 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton.icon( - onPressed: () => _pickAndShowImageDetails( - context, - pickFromCamera: true, - ), + onPressed: () => _pickAndShowImageDetails(context, pickFromCamera: true), icon: const Icon(Icons.camera_alt), label: const Text('Camera'), style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), ), const SizedBox(width: 16), @@ -215,10 +201,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin icon: const Icon(Icons.collections), label: const Text('Gallery'), style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), ), ], @@ -228,9 +211,9 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( 'Only JPEG, PNG and WEBP files below 20 MB are supported', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey.shade600, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey.shade600), textAlign: TextAlign.center, ), ), @@ -242,4 +225,4 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/add_exercise/steps/step6Overview.dart b/lib/widgets/add_exercise/steps/step6Overview.dart index 43f707d8..fbe65664 100644 --- a/lib/widgets/add_exercise/steps/step6Overview.dart +++ b/lib/widgets/add_exercise/steps/step6Overview.dart @@ -108,4 +108,4 @@ class Step6Overview extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/test/exercises/add_exercise_image_withDetail_test.dart b/test/exercises/add_exercise_image_withDetail_test.dart index b7428085..e56a36fb 100644 --- a/test/exercises/add_exercise_image_withDetail_test.dart +++ b/test/exercises/add_exercise_image_withDetail_test.dart @@ -79,10 +79,7 @@ void main() { final styles = ['1', '2', '3', '4', '5']; for (var i = 0; i < styles.length; i++) { - provider.addExerciseImages( - [File('image_$i.jpg')], - style: styles[i], - ); + provider.addExerciseImages([File('image_$i.jpg')], style: styles[i]); } expect(provider.exerciseImages.length, 5); @@ -131,10 +128,7 @@ 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.removeExercise('nonexistent.jpg'), throwsStateError); }); /// clear() should reset all state including images and metadata @@ -177,11 +171,7 @@ void main() { /// 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'), - ]; + final files = [File('batch1.jpg'), File('batch2.jpg'), File('batch3.jpg')]; provider.addExerciseImages(files, title: 'Batch Upload', style: '3'); @@ -254,4 +244,4 @@ void main() { expect(provider.descriptionEn, isNull); }); }); -} \ No newline at end of file +} From b1ef5df97aa7041243d4f4c90c054e1409926efe Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 15:39:35 +0200 Subject: [PATCH 05/29] data for localization --- lib/l10n/app_en.arb | 98 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 163674e2..9133f4ab 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -719,21 +719,89 @@ } } }, - "scanBarcode": "Scan barcode", - "@scanBarcode": { - "description": "Label for scan barcode button" - }, - "close": "Close", - "@close": { - "description": "Translation for close" - }, - "identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", - "checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", - "add_exercise_image_license": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", - "variations": "Variations", - "@variations": { - "description": "Variations of one exercise (e.g. benchpress and benchpress narrow)" - }, +"scanBarcode": "Scan barcode", +"@scanBarcode": { + "description": "Label for scan barcode button" +}, +"close": "Close", +"@close": { + "description": "Translation for close" +}, +"identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", +"checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", +"add_exercise_image_license": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", +"imageDetailsTitle": "Image details", +"@imageDetailsTitle": { + "description": "Title for image details form" +}, +"imageDetailsLicenseTitle": "Title", +"@imageDetailsLicenseTitle": { + "description": "Label for image title field" +}, +"imageDetailsLicenseTitleHint": "Enter image title", +"@imageDetailsLicenseTitleHint": { + "description": "Hint text for image title field" +}, +"imageDetailsSourceLink": "Link to the source website, if available", +"@imageDetailsSourceLink": { + "description": "Label for source link field" +}, +"imageDetailsSourceLinkHint": "https://example.com", +"@imageDetailsSourceLinkHint": { + "description": "Hint text for source link field" +}, +"imageDetailsAuthor": "Author(s)", +"@imageDetailsAuthor": { + "description": "Label for author field" +}, +"imageDetailsAuthorHint": "Enter author name", +"@imageDetailsAuthorHint": { + "description": "Hint text for author field" +}, +"imageDetailsAuthorLink": "Link to author website or profile, if available", +"@imageDetailsAuthorLink": { + "description": "Label for author link field" +}, +"imageDetailsAuthorLinkHint": "https://example.com/author", +"@imageDetailsAuthorLinkHint": { + "description": "Hint text for author link field" +}, +"imageDetailsDerivativeSource": "Link to the original source, if this is a derivative work", +"@imageDetailsDerivativeSource": { + "description": "Label for derivative source field" +}, +"imageDetailsDerivativeSourceHint": "https://example.com/original", +"@imageDetailsDerivativeSourceHint": { + "description": "Hint text for derivative source field" +}, +"imageDetailsDerivativeHelp": "A derivative work is based on a previous work but contains sufficient new, creative content to entitle it to its own copyright.", +"@imageDetailsDerivativeHelp": { + "description": "Helper text explaining derivative works" +}, +"imageDetailsImageType": "Image Type", +"@imageDetailsImageType": { + "description": "Label for image type selector" +}, +"imageDetailsLicenseNoticePrefix": "By submitting this image, you agree to release it under the ", +"@imageDetailsLicenseNoticePrefix": { + "description": "First part of license notice text" +}, +"imageDetailsLicenseNoticeSuffix": " The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.", +"@imageDetailsLicenseNoticeSuffix": { + "description": "Second part of license notice text" +}, +"imageDetailsCancel": "CANCEL", +"@imageDetailsCancel": { + "description": "Cancel button text" +}, +"imageDetailsAdd": "ADD", +"@imageDetailsAdd": { + "description": "Add button text" +}, +"variations": "Variations", +"@variations": { + "description": "Variations of one exercise (e.g. benchpress and benchpress narrow)" +}, "alsoKnownAs": "Also known as: {aliases}", "@alsoKnownAs": { "placeholders": { From 65fd2f09069284f986bfc9fff7f1868709c8f18f Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 16:12:18 +0200 Subject: [PATCH 06/29] Add separate license info widget for CC BY-SA notice --- .../add_exercise/license_info_widget.dart | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 lib/widgets/add_exercise/license_info_widget.dart diff --git a/lib/widgets/add_exercise/license_info_widget.dart b/lib/widgets/add_exercise/license_info_widget.dart new file mode 100644 index 00000000..146a2e4c --- /dev/null +++ b/lib/widgets/add_exercise/license_info_widget.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; + +/// Static widget displaying CC BY-SA 4.0 license notice for image uploads +/// +/// This widget informs users that by uploading images, they agree to release +/// them under the CC BY-SA 4.0 license. The license name is clickable and +/// opens the Creative Commons license page. +/// +/// Being a separate widget allows Flutter to optimize rendering since +/// this content never changes. +class LicenseInfoWidget extends StatelessWidget { + const LicenseInfoWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: colorScheme.outline), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + children: [ + TextSpan( + text: AppLocalizations.of(context).imageDetailsLicenseNoticePrefix, + ), + WidgetSpan( + child: GestureDetector( + onTap: () async { + final url = Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + child: Text( + 'CC BY-SA 4.0', + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + ), + TextSpan( + text: AppLocalizations.of(context).imageDetailsLicenseNoticeSuffix, + ), + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file From 1903fb4d5ed9d73cccac696acd163cad3e0df34b Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 16:12:41 +0200 Subject: [PATCH 07/29] Add image details form with license metadata collection Implement form for collecting CC BY-SA 4.0 license metadata including title, author, URLs, and image style. Includes URL validation and uses localized strings. --- .../add_exercise/image_details_form.dart | 175 +++++++----------- 1 file changed, 65 insertions(+), 110 deletions(-) diff --git a/lib/widgets/add_exercise/image_details_form.dart b/lib/widgets/add_exercise/image_details_form.dart index 6479782b..2e33a3d4 100644 --- a/lib/widgets/add_exercise/image_details_form.dart +++ b/lib/widgets/add_exercise/image_details_form.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/widgets/add_exercise/license_info_widget.dart'; /// Form for collecting CC BY-SA 4.0 license metadata for exercise images /// @@ -68,6 +69,34 @@ class _ImageDetailsFormState extends State { super.dispose(); } + /// Validates URL format + /// + /// Returns error message if URL is invalid, null if valid or empty + String? _validateUrl(String? value) { + if (value == null || value.trim().isEmpty) { + return null; // Empty is OK (optional field) + } + + final trimmedValue = value.trim(); + + // Check if starts with http:// or https:// + if (!trimmedValue.startsWith('http://') && !trimmedValue.startsWith('https://')) { + return AppLocalizations.of(context).invalidUrl; + } + + // Try to parse as URI + try { + final uri = Uri.parse(trimmedValue); + if (!uri.hasScheme || !uri.hasAuthority) { + return AppLocalizations.of(context).invalidUrl; + } + } catch (e) { + return AppLocalizations.of(context).invalidUrl; + } + + return null; + } + /// Maps UI image type selection to API 'style' field value /// /// API expects numeric string: @@ -104,10 +133,8 @@ class _ImageDetailsFormState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Image details', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + AppLocalizations.of(context).imageDetailsTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), @@ -117,53 +144,55 @@ class _ImageDetailsFormState extends State { // License title field - helps identify the image _buildTextField( controller: _titleController, - label: 'Title', - hint: 'Enter image title', + label: AppLocalizations.of(context).imageDetailsLicenseTitle, + hint: AppLocalizations.of(context).imageDetailsLicenseTitleHint, ), const SizedBox(height: 16), // Source URL - where the image was found (license_object_url in API) _buildTextField( controller: _sourceLinkController, - label: 'Link to the source website, if available', - hint: 'https://example.com', + label: AppLocalizations.of(context).imageDetailsSourceLink, + hint: AppLocalizations.of(context).imageDetailsSourceLinkHint, keyboardType: TextInputType.url, + validator: _validateUrl, ), const SizedBox(height: 16), // Author name - required for proper CC BY-SA attribution _buildTextField( controller: _authorController, - label: 'Author(s)', - hint: 'Enter author name', + label: AppLocalizations.of(context).imageDetailsAuthor, + hint: AppLocalizations.of(context).imageDetailsAuthorHint, ), const SizedBox(height: 16), // Author's website/profile URL _buildTextField( controller: _authorLinkController, - label: 'Link to author website or profile, if available', - hint: 'https://example.com/author', + label: AppLocalizations.of(context).imageDetailsAuthorLink, + hint: AppLocalizations.of(context).imageDetailsAuthorLinkHint, keyboardType: TextInputType.url, + validator: _validateUrl, ), const SizedBox(height: 16), // Original source if this is a derivative work (modified from another image) _buildTextField( controller: _originalSourceController, - label: 'Link to the original source, if this is a derivative work', - hint: 'https://example.com/original', + label: AppLocalizations.of(context).imageDetailsDerivativeSource, + hint: AppLocalizations.of(context).imageDetailsDerivativeSourceHint, keyboardType: TextInputType.url, + helperText: AppLocalizations.of(context).imageDetailsDerivativeHelp, + validator: _validateUrl, ), - const SizedBox(height: 8), - - _buildDerivativeWorkNote(), const SizedBox(height: 24), _buildImageTypeSelector(), const SizedBox(height: 24), - _buildLicenseInfo(), + // License info as separate widget for better optimization + const LicenseInfoWidget(), const SizedBox(height: 24), _buildButtons(), @@ -195,6 +224,8 @@ class _ImageDetailsFormState extends State { required String label, String? hint, TextInputType? keyboardType, + String? helperText, + String? Function(String?)? validator, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -204,8 +235,11 @@ class _ImageDetailsFormState extends State { TextFormField( controller: controller, keyboardType: keyboardType, + validator: validator, decoration: InputDecoration( hintText: hint, + helperText: helperText, + helperMaxLines: 3, border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), @@ -214,34 +248,6 @@ class _ImageDetailsFormState extends State { ); } - /// Informational box explaining what constitutes a derivative work - /// - /// Important for CC BY-SA compliance - users need to understand when - /// they must cite the original work they modified - Widget _buildDerivativeWorkNote() { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.blue.shade200), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, size: 20, color: Colors.blue.shade700), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.', - style: TextStyle(fontSize: 12, color: Colors.blue.shade900), - ), - ), - ], - ), - ); - } - /// Visual selector for image style/type /// /// Allows user to categorize the image as PHOTO, 3D render, LINE drawing, @@ -250,7 +256,10 @@ class _ImageDetailsFormState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Image Type', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + Text( + AppLocalizations.of(context).imageDetailsImageType, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), const SizedBox(height: 12), Wrap( spacing: 8, @@ -303,68 +312,14 @@ class _ImageDetailsFormState extends State { ); } - /// Legal notice about CC BY-SA 4.0 license - /// - /// Critical for compliance - informs user that by uploading, they're - /// releasing the image under CC BY-SA 4.0 and must have rights to do so - Widget _buildLicenseInfo() { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.amber.shade50, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.amber.shade200), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, size: 20, color: Colors.amber.shade900), - const SizedBox(width: 8), - Expanded( - child: RichText( - text: TextSpan( - style: TextStyle(fontSize: 12, color: Colors.amber.shade900), - children: [ - const TextSpan( - text: 'By submitting this image, you agree to release it under the ', - ), - WidgetSpan( - child: GestureDetector( - onTap: () async { - final url = Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/'); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } - }, - child: Text( - 'CC BY-SA 4.0', - style: TextStyle( - fontSize: 12, - color: Colors.amber.shade900, - fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, - ), - ), - ), - ), - const TextSpan( - text: - ' license. The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.', - ), - ], - ), - ), - ), - ], - ), - ); - } - Widget _buildButtons() { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton(onPressed: widget.onCancel, child: const Text('CANCEL')), + TextButton( + onPressed: widget.onCancel, + child: Text(AppLocalizations.of(context).imageDetailsCancel), + ), const SizedBox(width: 8), ElevatedButton( onPressed: () { @@ -408,12 +363,12 @@ class _ImageDetailsFormState extends State { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), ), - child: const Text( - 'ADD', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), + child: Text( + AppLocalizations.of(context).imageDetailsAdd, + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white), ), ), ], ); } -} +} \ No newline at end of file From c00246dedbf8f1d2d05142cfa088c6a23a3ae3ab Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 16:13:02 +0200 Subject: [PATCH 08/29] Add image source selection and preview improvements Add dialog for camera/gallery selection and improve preview widget with edit mode toggle. Preview now supports add-more callback and can be displayed in read-only mode. --- lib/widgets/add_exercise/preview_images.dart | 156 +++++++++++------- .../add_exercise/steps/step5images.dart | 58 +++++-- 2 files changed, 140 insertions(+), 74 deletions(-) diff --git a/lib/widgets/add_exercise/preview_images.dart b/lib/widgets/add_exercise/preview_images.dart index 23f7660c..5a6249a2 100644 --- a/lib/widgets/add_exercise/preview_images.dart +++ b/lib/widgets/add_exercise/preview_images.dart @@ -1,73 +1,111 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:wger/providers/add_exercise.dart'; -import 'mixins/image_picker_mixin.dart'; - -class PreviewExerciseImages extends StatelessWidget with ExerciseImagePickerMixin { +/// Widget to preview selected exercise images +/// +/// Displays images in a horizontal scrollable list with thumbnails. +/// 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 selectedImages; + final VoidCallback? onAddMore; final bool allowEdit; - const PreviewExerciseImages({super.key, required this.selectedImages, this.allowEdit = true}); + const PreviewExerciseImages({ + Key? key, + required this.selectedImages, + this.onAddMore, + this.allowEdit = true, + }) : super(key: key); @override Widget build(BuildContext context) { + // Calculate item count: images + optional "add more" button + final itemCount = selectedImages.length + (allowEdit && onAddMore != null ? 1 : 0); + return SizedBox( - height: 300, - child: ListView(scrollDirection: Axis.horizontal, children: [ - ...selectedImages.map( - (file) => SizedBox( - height: 200, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Stack( - children: [ - Image.file(file), - if (allowEdit) - Positioned( - bottom: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.all(3.0), - child: Container( - decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: 0.5), - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - iconSize: 20, - onPressed: () => - context.read().removeExercise(file.path), - color: Colors.white, - icon: const Icon(Icons.delete), - ), - ), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 10), - if (allowEdit) - Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - color: Colors.grey, - height: 200, - width: 100, - child: Center( - child: IconButton( - icon: const Icon(Icons.add), - onPressed: () => pickImages(context), - ), - ), - ), - ), - ]), + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: itemCount, + itemBuilder: (context, index) { + // Show "add more" button at the end (only if editing is allowed) + if (index == selectedImages.length) { + return _buildAddMoreButton(context); + } + + // Show image thumbnail + final image = selectedImages[index]; + return _buildImageCard(context, image); + }, + ), ); } -} + + Widget _buildImageCard(BuildContext context, File image) { + return Container( + width: 120, + margin: const EdgeInsets.only(right: 8), + child: Stack( + children: [ + // Image thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + image, + width: 120, + height: 120, + fit: BoxFit.cover, + ), + ), + + // Delete button overlay (only shown if editing is allowed) + if (allowEdit) + Positioned( + top: 4, + right: 4, + child: IconButton( + icon: const Icon(Icons.close), + color: Colors.white, + iconSize: 20, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withOpacity(0.6), + padding: const EdgeInsets.all(4), + ), + onPressed: () { + context.read().removeExercise(image.path); + }, + ), + ), + ], + ), + ); + } + + Widget _buildAddMoreButton(BuildContext context) { + return GestureDetector( + onTap: onAddMore, + child: Container( + width: 120, + height: 120, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + width: 2, + style: BorderStyle.solid, + ), + ), + child: Icon( + Icons.add, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_exercise/steps/step5images.dart b/lib/widgets/add_exercise/steps/step5images.dart index 179e0514..e0a23ae2 100644 --- a/lib/widgets/add_exercise/steps/step5images.dart +++ b/lib/widgets/add_exercise/steps/step5images.dart @@ -32,6 +32,39 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin /// When non-null, ImageDetailsForm is displayed instead of image picker File? _currentImageToAdd; + /// Show dialog to choose between Camera and Gallery + Future _showImageSourceDialog(BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context).selectImage), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: Text(AppLocalizations.of(context).takePicture), + onTap: () { + Navigator.of(context).pop(); + _pickAndShowImageDetails(context, pickFromCamera: true); + }, + ), + ListTile( + leading: const Icon(Icons.collections), + title: Text(AppLocalizations.of(context).gallery), + onTap: () { + Navigator.of(context).pop(); + _pickAndShowImageDetails(context, pickFromCamera: false); + }, + ), + ], + ), + ); + }, + ); + } + /// Pick image from camera or gallery and show metadata collection form /// /// Validates file format (jpg, jpeg, png, webp) and size (<20MB) before @@ -105,18 +138,10 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin style: details['style'] ?? '1', ); - // Reset form state + // Reset form state - image is now visible in preview list setState(() { _currentImageToAdd = null; }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Image added with details'), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), - ); } /// Cancel metadata input and return to image picker @@ -159,12 +184,15 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin // Show preview of images that have been added with metadata return Column( children: [ - PreviewExerciseImages(selectedImages: provider.exerciseImages), + PreviewExerciseImages( + selectedImages: provider.exerciseImages, + onAddMore: () => _showImageSourceDialog(context), + ), const SizedBox(height: 16), ElevatedButton.icon( - onPressed: () => _pickAndShowImageDetails(context), + onPressed: () => _showImageSourceDialog(context), icon: const Icon(Icons.add_photo_alternate), - label: const Text('Add Another Image'), + label: Text(AppLocalizations.of(context).addImage), ), ], ); @@ -190,7 +218,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ElevatedButton.icon( onPressed: () => _pickAndShowImageDetails(context, pickFromCamera: true), icon: const Icon(Icons.camera_alt), - label: const Text('Camera'), + label: Text(AppLocalizations.of(context).takePicture), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), @@ -199,7 +227,7 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ElevatedButton.icon( onPressed: () => _pickAndShowImageDetails(context), icon: const Icon(Icons.collections), - label: const Text('Gallery'), + label: Text(AppLocalizations.of(context).gallery), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), @@ -225,4 +253,4 @@ class _Step5ImagesState extends State with ExerciseImagePickerMixin ), ); } -} +} \ No newline at end of file From 6f5777fbcb2a28e72cb0b2264c48023c5fa5e879 Mon Sep 17 00:00:00 2001 From: Branislav Nohaj Date: Sat, 4 Oct 2025 16:13:22 +0200 Subject: [PATCH 09/29] Simplify image upload and improve logging Remove unnecessary response validation, replace print statements with proper Logger module, and clean up debug output. --- lib/providers/add_exercise.dart | 203 ++++---------------------------- 1 file changed, 20 insertions(+), 183 deletions(-) diff --git a/lib/providers/add_exercise.dart b/lib/providers/add_exercise.dart index 5b8ef729..7fd69c32 100644 --- a/lib/providers/add_exercise.dart +++ b/lib/providers/add_exercise.dart @@ -1,9 +1,9 @@ import 'dart:convert'; -import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; import 'package:wger/models/exercises/category.dart'; import 'package:wger/models/exercises/equipment.dart'; import 'package:wger/models/exercises/exercise.dart'; @@ -16,6 +16,7 @@ import 'base_provider.dart'; class AddExerciseProvider with ChangeNotifier { final WgerBaseProvider baseProvider; + static final _logger = Logger('AddExerciseProvider'); AddExerciseProvider(this.baseProvider); @@ -47,7 +48,7 @@ class AddExerciseProvider with ChangeNotifier { void clear() { _exerciseImages = []; - _imageDetails.clear(); // Also clear image metadata + _imageDetails.clear(); languageTranslation = null; category = null; exerciseNameEn = null; @@ -150,14 +151,14 @@ class AddExerciseProvider with ChangeNotifier { /// [derivativeSourceUrl] - Derivative source URL /// [style] - Image style: 1=PHOTO, 2=3D, 3=LINE, 4=LOW-POLY, 5=OTHER void addExerciseImages( - List images, { - String? title, - String? author, - String? authorUrl, - String? sourceUrl, - String? derivativeSourceUrl, - String style = '1', - }) { + List images, { + String? title, + String? author, + String? authorUrl, + String? sourceUrl, + String? derivativeSourceUrl, + String style = '1', + }) { _exerciseImages.addAll(images); // Store metadata for each image @@ -190,52 +191,24 @@ class AddExerciseProvider with ChangeNotifier { void removeExercise(String path) { final file = _exerciseImages.where((element) => element.path == path).first; _exerciseImages.remove(file); - _imageDetails.remove(path); // Also remove associated metadata + _imageDetails.remove(path); notifyListeners(); } - // Debug method to print all collected exercise data - void printValues() { - log('Collected exercise data'); - log('------------------------'); - - log('Base data...'); - log('Target area : $category'); - log('Primary muscles: $_primaryMuscles'); - log('Secondary muscles: $_secondaryMuscles'); - log('Equipment: $_equipment'); - log('Variations: $_variations'); - - log(''); - log('Language specific...'); - log('Language: ${languageTranslation?.shortName}'); - log('Name: en/$exerciseNameEn translation/$exerciseNameTrans'); - log('Description: en/$descriptionEn translation/$descriptionTrans'); - log('Alternate names: en/$alternateNamesEn translation/$alternateNamesTrans'); - - log(''); - log('Images: ${_exerciseImages.length} images with details'); - } - /// Main method to submit exercise with images /// /// Returns the ID of the created exercise /// Throws exception if submission fails Future addExercise() async { - printValues(); - try { // 1. Create the exercise final exerciseId = await addExerciseSubmission(); - print('Exercise created with ID: $exerciseId'); + // 2. Upload images if any exist if (_exerciseImages.isNotEmpty) { - print('Uploading ${_exerciseImages.length} images...'); await addImages(exerciseId); - print('Images uploaded successfully'); - } else { - print('ℹ No images to upload'); + } // 3. Clear all data after successful upload @@ -243,7 +216,7 @@ class AddExerciseProvider with ChangeNotifier { return exerciseId; } catch (e) { - print('Error adding exercise: $e'); + // Don't clear on error so user can retry rethrow; } @@ -264,14 +237,8 @@ class AddExerciseProvider with ChangeNotifier { /// For each image: /// - Sends multipart request with image file /// - Includes license fields from _imageDetails map - /// - Validates server response contains all submitted fields Future addImages(int exerciseId) async { - print('Starting image upload for exercise ID: $exerciseId'); - print('Number of images to upload: ${_exerciseImages.length}'); - for (final image in _exerciseImages) { - print('Processing image: ${image.path}'); - final request = http.MultipartRequest('POST', baseProvider.makeUrl(_imagesUrlPath)); request.headers.addAll(baseProvider.getDefaultHeaders(includeAuth: true)); @@ -281,95 +248,22 @@ class AddExerciseProvider with ChangeNotifier { request.fields['is_main'] = 'false'; final details = _imageDetails[image.path]; - print('Image details to send: $details'); - if (details != null && details.isNotEmpty) { request.fields.addAll(details); - print('Request fields: ${request.fields}'); } else { request.fields['style'] = '1'; } try { - print('Sending request...'); final streamedResponse = await request.send(); - print('Response status: ${streamedResponse.statusCode}'); - // Read response body to verify upload success - final response = await http.Response.fromStream(streamedResponse); - - if (response.statusCode == 201 || response.statusCode == 200) { - final responseData = jsonDecode(response.body); - - log('Image uploaded successfully!'); - log('Response body: ${response.body}'); - - // Debug: Print all fields from server response - print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - print('SERVER RESPONSE:'); - print('ID: ${responseData['id']}'); - print('UUID: ${responseData['uuid']}'); - print('Image URL: ${responseData['image']}'); - print('Exercise: ${responseData['exercise']}'); - print('Style: ${responseData['style']}'); - print('License: ${responseData['license']}'); - print('License Title: ${responseData['license_title']}'); - print('License Author: ${responseData['license_author']}'); - print('License Author URL: ${responseData['license_author_url']}'); - print('License Object URL: ${responseData['license_object_url']}'); - print('License Derivative: ${responseData['license_derivative_source_url']}'); - print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - - // Validate that license fields were saved correctly - if (details != null) { - bool allFieldsMatch = true; - - if (details.containsKey('license_title') && - responseData['license_title'] != details['license_title']) { - print('WARNING: license_title mismatch!'); - print('Sent: ${details['license_title']}'); - print('Received: ${responseData['license_title']}'); - allFieldsMatch = false; - } - - if (details.containsKey('license_author') && - responseData['license_author'] != details['license_author']) { - print('WARNING: license_author mismatch!'); - allFieldsMatch = false; - } - - if (details.containsKey('license_author_url') && - responseData['license_author_url'] != details['license_author_url']) { - print('WARNING: license_author_url mismatch!'); - allFieldsMatch = false; - } - - if (details.containsKey('license_object_url') && - responseData['license_object_url'] != details['license_object_url']) { - print('WARNING: license_object_url mismatch!'); - allFieldsMatch = false; - } - - if (details.containsKey('license_derivative_source_url') && - responseData['license_derivative_source_url'] != - details['license_derivative_source_url']) { - print('WARNING: license_derivative_source_url mismatch!'); - allFieldsMatch = false; - } - - if (allFieldsMatch) { - print('ALL LICENSE FIELDS SAVED CORRECTLY!'); - } else { - print('SOME LICENSE FIELDS NOT SAVED CORRECTLY!'); - } - } + if (streamedResponse.statusCode == 201 || streamedResponse.statusCode == 200) { + _logger.fine('Image uploaded successfully'); } else { - log('Failed to upload image: ${response.statusCode}'); - log('Response body: ${response.body}'); - throw Exception('Upload failed: ${response.statusCode}'); + final response = await http.Response.fromStream(streamedResponse); + throw Exception('Upload failed: ${streamedResponse.statusCode}'); } } catch (e) { - log('Error uploading image: $e'); rethrow; } } @@ -383,64 +277,7 @@ class AddExerciseProvider with ChangeNotifier { 'language_code': languageCode, }, baseProvider.makeUrl(_checkLanguageUrlPath)); notifyListeners(); - print(result); return false; } - - /* - Note: all this logic is not needed now since we are using the /exercise-submission - endpoint, however, if we ever want to implement editing of exercises, we will - need basically all of it again, so this is kept here for reference. - - - - - Future addVariation() async { - final Uri postUri = baseProvider.makeUrl(_exerciseVariationPath); - - // We send an empty dictionary since at the moment the variations only have an ID - final Map variationMap = await baseProvider.post({}, postUri); - final Variation newVariation = Variation.fromJson(variationMap); - _variationId = newVariation.id; - notifyListeners(); - return newVariation; - } - - Future addImages(Exercise exercise) 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.fields['exercise'] = exercise.id!.toString(); - request.fields['style'] = EXERCISE_IMAGE_ART_STYLE.PHOTO.index.toString(); - - await request.send(); - } - - notifyListeners(); - } - - Future addExerciseTranslation(Translation exercise) async { - final Uri postUri = baseProvider.makeUrl(_exerciseTranslationUrlPath); - - final Map newTranslation = await baseProvider.post(exercise.toJson(), postUri); - final Translation newExercise = Translation.fromJson(newTranslation); - notifyListeners(); - - return newExercise; - } - - Future addExerciseAlias(String name, int exerciseId) async { - final alias = Alias(translationId: exerciseId, alias: name); - final Uri postUri = baseProvider.makeUrl(_exerciseAliasPath); - - final Alias newAlias = Alias.fromJson(await baseProvider.post(alias.toJson(), postUri)); - notifyListeners(); - - return newAlias; - } - - */ -} +} \ No newline at end of file From 796b142c7ad8e89533cf864f2cb0853bc9845045 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:05:15 +0000 Subject: [PATCH 10/29] Bump sqlite3_flutter_libs from 0.5.39 to 0.5.40 Bumps [sqlite3_flutter_libs](https://github.com/simolus3/sqlite3.dart) from 0.5.39 to 0.5.40. - [Release notes](https://github.com/simolus3/sqlite3.dart/releases) - [Commits](https://github.com/simolus3/sqlite3.dart/compare/sqlite3_flutter_libs-0.5.39...sqlite3_flutter_libs-0.5.40) --- updated-dependencies: - dependency-name: sqlite3_flutter_libs dependency-version: 0.5.40 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index a1d24d6d..724e9c1c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1205,10 +1205,10 @@ packages: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "2b03273e71867a8a4d030861fc21706200debe5c5858a4b9e58f4a1c129586a4" + sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" url: "https://pub.dev" source: hosted - version: "0.5.39" + version: "0.5.40" sqlparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3eaff457..73a4a903 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: provider: ^6.1.5 rive: ^0.13.20 shared_preferences: ^2.5.3 - sqlite3_flutter_libs: ^0.5.39 + sqlite3_flutter_libs: ^0.5.40 table_calendar: ^3.0.8 url_launcher: ^6.3.2 version: ^3.0.2 From fa7f670e9f88bba81ae0a1b76641e6b66527d181 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Mon, 6 Oct 2025 12:31:12 +0200 Subject: [PATCH 11/29] Some light cleanup --- lib/l10n/app_en.arb | 154 +- lib/models/exercises/exercise.g.dart | 65 +- .../exercises/exercise_api.freezed.dart | 1370 +++++------- lib/models/exercises/exercise_api.g.dart | 109 +- .../exercise_submission.freezed.dart | 8 + lib/models/exercises/image.g.dart | 17 +- lib/providers/exercises.dart | 92 +- .../add_exercise/image_details_form.dart | 31 +- lib/widgets/core/settings.dart | 22 +- lib/widgets/core/settings/exercise_cache.dart | 117 +- .../contribute_exercise_test.mocks.dart | 1239 ++++------- .../exercises_detail_widget_test.mocks.dart | 669 +++--- .../plate_calculator_test.mocks.dart | 266 +-- test/routine/day_form_test.mocks.dart | 992 +++------ .../forms/session_form_test.mocks.dart | 992 +++------ test/routine/gym_mode_screen_test.mocks.dart | 1907 ++++++----------- .../gym_mode_session_screen_test.mocks.dart | 992 +++------ ...epetition_unit_form_widget_test.mocks.dart | 992 +++------ .../routine_edit_screen_test.mocks.dart | 992 +++------ test/routine/routine_edit_test.mocks.dart | 992 +++------ test/routine/routine_form_test.mocks.dart | 992 +++------ .../routine_logs_screen_test.mocks.dart | 992 +++------ test/routine/routine_screen_test.mocks.dart | 255 +-- .../routine/routines_provider_test.mocks.dart | 924 +++----- test/routine/routines_screen_test.mocks.dart | 255 +-- test/routine/slot_entry_form_test.mocks.dart | 992 +++------ .../weight_unit_form_widget_test.mocks.dart | 992 +++------ 27 files changed, 6260 insertions(+), 11160 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9133f4ab..df9a85bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -719,89 +719,77 @@ } } }, -"scanBarcode": "Scan barcode", -"@scanBarcode": { - "description": "Label for scan barcode button" -}, -"close": "Close", -"@close": { - "description": "Translation for close" -}, -"identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", -"checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", -"add_exercise_image_license": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", -"imageDetailsTitle": "Image details", -"@imageDetailsTitle": { - "description": "Title for image details form" -}, -"imageDetailsLicenseTitle": "Title", -"@imageDetailsLicenseTitle": { - "description": "Label for image title field" -}, -"imageDetailsLicenseTitleHint": "Enter image title", -"@imageDetailsLicenseTitleHint": { - "description": "Hint text for image title field" -}, -"imageDetailsSourceLink": "Link to the source website, if available", -"@imageDetailsSourceLink": { - "description": "Label for source link field" -}, -"imageDetailsSourceLinkHint": "https://example.com", -"@imageDetailsSourceLinkHint": { - "description": "Hint text for source link field" -}, -"imageDetailsAuthor": "Author(s)", -"@imageDetailsAuthor": { - "description": "Label for author field" -}, -"imageDetailsAuthorHint": "Enter author name", -"@imageDetailsAuthorHint": { - "description": "Hint text for author field" -}, -"imageDetailsAuthorLink": "Link to author website or profile, if available", -"@imageDetailsAuthorLink": { - "description": "Label for author link field" -}, -"imageDetailsAuthorLinkHint": "https://example.com/author", -"@imageDetailsAuthorLinkHint": { - "description": "Hint text for author link field" -}, -"imageDetailsDerivativeSource": "Link to the original source, if this is a derivative work", -"@imageDetailsDerivativeSource": { - "description": "Label for derivative source field" -}, -"imageDetailsDerivativeSourceHint": "https://example.com/original", -"@imageDetailsDerivativeSourceHint": { - "description": "Hint text for derivative source field" -}, -"imageDetailsDerivativeHelp": "A derivative work is based on a previous work but contains sufficient new, creative content to entitle it to its own copyright.", -"@imageDetailsDerivativeHelp": { - "description": "Helper text explaining derivative works" -}, -"imageDetailsImageType": "Image Type", -"@imageDetailsImageType": { - "description": "Label for image type selector" -}, -"imageDetailsLicenseNoticePrefix": "By submitting this image, you agree to release it under the ", -"@imageDetailsLicenseNoticePrefix": { - "description": "First part of license notice text" -}, -"imageDetailsLicenseNoticeSuffix": " The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.", -"@imageDetailsLicenseNoticeSuffix": { - "description": "Second part of license notice text" -}, -"imageDetailsCancel": "CANCEL", -"@imageDetailsCancel": { - "description": "Cancel button text" -}, -"imageDetailsAdd": "ADD", -"@imageDetailsAdd": { - "description": "Add button text" -}, -"variations": "Variations", -"@variations": { - "description": "Variations of one exercise (e.g. benchpress and benchpress narrow)" -}, + "scanBarcode": "Scan barcode", + "@scanBarcode": { + "description": "Label for scan barcode button" + }, + "close": "Close", + "@close": { + "description": "Translation for close" + }, + "identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", + "checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", + "add_exercise_image_license": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", + "imageDetailsTitle": "Image details", + "@imageDetailsTitle": { + "description": "Title for image details form" + }, + "imageDetailsLicenseTitle": "Title", + "@imageDetailsLicenseTitle": { + "description": "Label for image title field" + }, + "imageDetailsLicenseTitleHint": "Enter image title", + "@imageDetailsLicenseTitleHint": { + "description": "Hint text for image title field" + }, + "imageDetailsSourceLink": "Link to the source website, if available", + "@imageDetailsSourceLink": { + "description": "Label for source link field" + }, + "imageDetailsAuthor": "Author(s)", + "@imageDetailsAuthor": { + "description": "Label for author field" + }, + "imageDetailsAuthorHint": "Enter author name", + "@imageDetailsAuthorHint": { + "description": "Hint text for author field" + }, + "imageDetailsAuthorLink": "Link to author website or profile, if available", + "@imageDetailsAuthorLink": { + "description": "Label for author link field" + }, + "imageDetailsDerivativeSource": "Link to the original source, if this is a derivative work", + "@imageDetailsDerivativeSource": { + "description": "Label for derivative source field" + }, + "imageDetailsDerivativeHelp": "A derivative work is based on a previous work but contains sufficient new, creative content to entitle it to its own copyright.", + "@imageDetailsDerivativeHelp": { + "description": "Helper text explaining derivative works" + }, + "imageDetailsImageType": "Image Type", + "@imageDetailsImageType": { + "description": "Label for image type selector" + }, + "imageDetailsLicenseNoticePrefix": "By submitting this image, you agree to release it under the ", + "@imageDetailsLicenseNoticePrefix": { + "description": "First part of license notice text" + }, + "imageDetailsLicenseNoticeSuffix": " The image must be either your own work or the author must have released it under a license compatible with CC BY-SA 4.0.", + "@imageDetailsLicenseNoticeSuffix": { + "description": "Second part of license notice text" + }, + "imageDetailsCancel": "CANCEL", + "@imageDetailsCancel": { + "description": "Cancel button text" + }, + "imageDetailsAdd": "ADD", + "@imageDetailsAdd": { + "description": "Add button text" + }, + "variations": "Variations", + "@variations": { + "description": "Variations of one exercise (e.g. benchpress and benchpress narrow)" + }, "alsoKnownAs": "Also known as: {aliases}", "@alsoKnownAs": { "placeholders": { diff --git a/lib/models/exercises/exercise.g.dart b/lib/models/exercises/exercise.g.dart index 5c9fcd4a..8d68fe3d 100644 --- a/lib/models/exercises/exercise.g.dart +++ b/lib/models/exercises/exercise.g.dart @@ -19,43 +19,46 @@ Exercise _$ExerciseFromJson(Map json) { 'category', 'muscles', 'muscles_secondary', - 'equipment' + 'equipment', ], ); return Exercise( - id: (json['id'] as num?)?.toInt(), - uuid: json['uuid'] as String?, - created: json['created'] == null ? null : DateTime.parse(json['created'] as String), - lastUpdate: json['last_update'] == null ? null : DateTime.parse(json['last_update'] as String), - lastUpdateGlobal: json['last_update_global'] == null - ? null - : DateTime.parse(json['last_update_global'] as String), - variationId: (json['variations'] as num?)?.toInt(), - translations: (json['translations'] as List?) - ?.map((e) => Translation.fromJson(e as Map)) - .toList(), - category: json['categories'] == null - ? null - : ExerciseCategory.fromJson(json['categories'] as Map), - ) + id: (json['id'] as num?)?.toInt(), + uuid: json['uuid'] as String?, + created: json['created'] == null ? null : DateTime.parse(json['created'] as String), + lastUpdate: json['last_update'] == null + ? null + : DateTime.parse(json['last_update'] as String), + lastUpdateGlobal: json['last_update_global'] == null + ? null + : DateTime.parse(json['last_update_global'] as String), + variationId: (json['variations'] as num?)?.toInt(), + translations: (json['translations'] as List?) + ?.map((e) => Translation.fromJson(e as Map)) + .toList(), + category: json['categories'] == null + ? null + : ExerciseCategory.fromJson(json['categories'] as Map), + ) ..categoryId = (json['category'] as num).toInt() ..musclesIds = (json['muscles'] as List).map((e) => (e as num).toInt()).toList() - ..musclesSecondaryIds = - (json['muscles_secondary'] as List).map((e) => (e as num).toInt()).toList() + ..musclesSecondaryIds = (json['muscles_secondary'] as List) + .map((e) => (e as num).toInt()) + .toList() ..equipmentIds = (json['equipment'] as List).map((e) => (e as num).toInt()).toList(); } Map _$ExerciseToJson(Exercise instance) => { - 'id': instance.id, - 'uuid': instance.uuid, - 'variations': instance.variationId, - 'created': instance.created?.toIso8601String(), - 'last_update': instance.lastUpdate?.toIso8601String(), - 'last_update_global': instance.lastUpdateGlobal?.toIso8601String(), - 'category': instance.categoryId, - 'categories': instance.category?.toJson(), - 'muscles': instance.musclesIds, - 'muscles_secondary': instance.musclesSecondaryIds, - 'musclesSecondary': instance.musclesSecondary.map((e) => e.toJson()).toList(), - 'equipment': instance.equipmentIds, - }; + 'id': instance.id, + 'uuid': instance.uuid, + 'variations': instance.variationId, + 'created': instance.created?.toIso8601String(), + 'last_update': instance.lastUpdate?.toIso8601String(), + 'last_update_global': instance.lastUpdateGlobal?.toIso8601String(), + 'category': instance.categoryId, + 'categories': instance.category?.toJson(), + 'muscles': instance.musclesIds, + 'muscles_secondary': instance.musclesSecondaryIds, + 'musclesSecondary': instance.musclesSecondary.map((e) => e.toJson()).toList(), + 'equipment': instance.equipmentIds, +}; diff --git a/lib/models/exercises/exercise_api.freezed.dart b/lib/models/exercises/exercise_api.freezed.dart index 85554ce8..740ec6c0 100644 --- a/lib/models/exercises/exercise_api.freezed.dart +++ b/lib/models/exercises/exercise_api.freezed.dart @@ -11,35 +11,37 @@ part of 'exercise_api.dart'; // dart format off T _$identity(T value) => value; + ExerciseApiData _$ExerciseApiDataFromJson(Map json) { - return _ExerciseBaseData.fromJson(json); + return _ExerciseBaseData.fromJson( + json + ); } /// @nodoc mixin _$ExerciseApiData { + int get id; + String get uuid; // ignore: invalid_annotation_target - @JsonKey(name: 'variations') - int? get variationId; // ignore: invalid_annotation_target - @JsonKey(name: 'created') - DateTime get created; // ignore: invalid_annotation_target - @JsonKey(name: 'last_update') - DateTime get lastUpdate; // ignore: invalid_annotation_target - @JsonKey(name: 'last_update_global') - DateTime get lastUpdateGlobal; + @JsonKey(name: 'variations') int? get variationId; // ignore: invalid_annotation_target + @JsonKey(name: 'created') DateTime get created; // ignore: invalid_annotation_target + @JsonKey(name: 'last_update') DateTime get lastUpdate; // ignore: invalid_annotation_target + @JsonKey(name: 'last_update_global') DateTime get lastUpdateGlobal; + ExerciseCategory get category; + List get muscles; // ignore: invalid_annotation_target - @JsonKey(name: 'muscles_secondary') - List get musclesSecondary; // ignore: invalid_annotation_target + @JsonKey(name: 'muscles_secondary') List< + Muscle> get musclesSecondary; // ignore: invalid_annotation_target List get equipment; // ignore: invalid_annotation_target - @JsonKey(name: 'translations', defaultValue: []) - List get translations; + @JsonKey(name: 'translations', defaultValue: []) List get translations; + List get images; + List