mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
Now the images are kept in a single list, instead of having two for the files themselves and the metadata.
372 lines
12 KiB
Dart
372 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:wger/l10n/generated/app_localizations.dart';
|
|
import 'package:wger/models/exercises/exercise_submission_images.dart';
|
|
import 'package:wger/widgets/add_exercise/license_info_widget.dart';
|
|
|
|
/// Form for collecting CC BY-SA 4.0 license metadata for exercise images
|
|
///
|
|
/// 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 Function(ExerciseSubmissionImage image) onAdd;
|
|
final VoidCallback onCancel;
|
|
final ExerciseSubmissionImage submissionImage;
|
|
|
|
const ImageDetailsForm({
|
|
super.key,
|
|
required this.submissionImage,
|
|
required this.onAdd,
|
|
required this.onCancel,
|
|
});
|
|
|
|
@override
|
|
State<ImageDetailsForm> createState() => _ImageDetailsFormState();
|
|
}
|
|
|
|
class _ImageDetailsFormState extends State<ImageDetailsForm> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Text controllers for license metadata fields
|
|
final _titleController = TextEditingController();
|
|
final _sourceLinkController = TextEditingController();
|
|
final _authorController = TextEditingController();
|
|
final _authorLinkController = TextEditingController();
|
|
final _originalSourceController = TextEditingController();
|
|
|
|
/// Currently selected image type
|
|
ImageType _selectedImageType = ImageType.photo;
|
|
|
|
@override
|
|
void dispose() {
|
|
_titleController.dispose();
|
|
_sourceLinkController.dispose();
|
|
_authorController.dispose();
|
|
_authorLinkController.dispose();
|
|
_originalSourceController.dispose();
|
|
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:
|
|
/// - 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'; // Default to PHOTO if unknown
|
|
}
|
|
}
|
|
|
|
@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(
|
|
AppLocalizations.of(context).imageDetailsTitle,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
_buildImagePreview(),
|
|
const SizedBox(height: 24),
|
|
|
|
// License title field - helps identify the image
|
|
_buildTextField(
|
|
controller: _titleController,
|
|
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: AppLocalizations.of(context).imageDetailsSourceLink,
|
|
hint: 'https://example.com',
|
|
keyboardType: TextInputType.url,
|
|
validator: _validateUrl,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Author name - required for proper CC BY-SA attribution
|
|
_buildTextField(
|
|
controller: _authorController,
|
|
label: AppLocalizations.of(context).imageDetailsAuthor,
|
|
hint: AppLocalizations.of(context).imageDetailsAuthorHint,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Author's website/profile URL
|
|
_buildTextField(
|
|
controller: _authorLinkController,
|
|
label: AppLocalizations.of(context).imageDetailsAuthorLink,
|
|
hint: 'https://example.com/author',
|
|
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: AppLocalizations.of(context).imageDetailsDerivativeSource,
|
|
hint: 'https://example.com/original',
|
|
keyboardType: TextInputType.url,
|
|
helperText: AppLocalizations.of(context).imageDetailsDerivativeHelp,
|
|
validator: _validateUrl,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
_buildImageTypeSelector(),
|
|
const SizedBox(height: 24),
|
|
|
|
// License info as separate widget for better optimization
|
|
const LicenseInfoWidget(),
|
|
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.submissionImage.imageFile, fit: BoxFit.contain),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField({
|
|
required TextEditingController controller,
|
|
required String label,
|
|
String? hint,
|
|
TextInputType? keyboardType,
|
|
String? helperText,
|
|
String? Function(String?)? validator,
|
|
}) {
|
|
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,
|
|
validator: validator,
|
|
decoration: InputDecoration(
|
|
hintText: hint,
|
|
helperText: helperText,
|
|
helperMaxLines: 3,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 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() {
|
|
final theme = Theme.of(context);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
AppLocalizations.of(context).imageDetailsImageType,
|
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: ImageType.values.map((type) {
|
|
final isSelected = _selectedImageType == type;
|
|
return InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
_selectedImageType = type;
|
|
});
|
|
},
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Container(
|
|
width: 90,
|
|
height: 90,
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? theme.buttonTheme.colorScheme!.primary
|
|
: theme.buttonTheme.colorScheme!.primaryContainer,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
type.icon,
|
|
size: 32,
|
|
color: isSelected
|
|
? theme.buttonTheme.colorScheme!.onPrimary
|
|
: theme.buttonTheme.colorScheme!.primary,
|
|
),
|
|
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(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildButtons() {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: widget.onCancel,
|
|
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
// Build details map with API field names
|
|
// Style is always included, other fields only if non-empty
|
|
final details = <String, String>{'style': _getStyleValue()};
|
|
|
|
// Add optional fields only if user provided values
|
|
final title = _titleController.text.trim();
|
|
if (title.isNotEmpty) {
|
|
widget.submissionImage.title = title;
|
|
}
|
|
|
|
final author = _authorController.text.trim();
|
|
if (author.isNotEmpty) {
|
|
widget.submissionImage.author = author;
|
|
}
|
|
|
|
final sourceUrl = _sourceLinkController.text.trim();
|
|
if (sourceUrl.isNotEmpty) {
|
|
widget.submissionImage.sourceUrl = sourceUrl;
|
|
}
|
|
|
|
final authorUrl = _authorLinkController.text.trim();
|
|
if (authorUrl.isNotEmpty) {
|
|
widget.submissionImage.authorUrl = authorUrl;
|
|
}
|
|
|
|
final derivativeUrl = _originalSourceController.text.trim();
|
|
if (derivativeUrl.isNotEmpty) {
|
|
widget.submissionImage.derivativeSourceUrl = derivativeUrl;
|
|
}
|
|
|
|
// Pass image and metadata back to parent
|
|
widget.onAdd(widget.submissionImage);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Theme.of(context).primaryColor,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
|
),
|
|
child: Text(
|
|
AppLocalizations.of(context).add,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|