mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
262 lines
8.9 KiB
Dart
262 lines
8.9 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:wger/core/validators.dart';
|
|
import 'package:wger/helpers/exercises/validators.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';
|
|
|
|
import 'add_exercise_text_area.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>();
|
|
|
|
/// Currently selected image type
|
|
ImageType _selectedImageType = ImageType.photo;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final i18n = AppLocalizations.of(context);
|
|
|
|
return SingleChildScrollView(
|
|
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: 8),
|
|
ImagePreview(imageFile: widget.submissionImage.imageFile),
|
|
const SizedBox(height: 8),
|
|
|
|
// Author name - required for proper CC BY-SA attribution
|
|
AddExerciseTextArea(
|
|
title: '${AppLocalizations.of(context).author}*',
|
|
initialValue: widget.submissionImage.author,
|
|
onSaved: (value) => widget.submissionImage.author = value,
|
|
validator: (name) => validateAuthorName(name, context),
|
|
),
|
|
|
|
// License title field - helps identify the image
|
|
AddExerciseTextArea(
|
|
title: AppLocalizations.of(context).imageDetailsLicenseTitle,
|
|
helperText: AppLocalizations.of(context).imageDetailsLicenseTitleHint,
|
|
initialValue: widget.submissionImage.title,
|
|
onSaved: (value) => widget.submissionImage.title = value,
|
|
),
|
|
|
|
// Source URL - where the image was found (license_object_url in API)
|
|
AddExerciseTextArea(
|
|
title: AppLocalizations.of(context).imageDetailsSourceLink,
|
|
initialValue: widget.submissionImage.sourceUrl,
|
|
onSaved: (value) => widget.submissionImage.sourceUrl = value,
|
|
validator: (value) => validateUrl(value, i18n, required: false),
|
|
),
|
|
|
|
// Author's website/profile URL
|
|
AddExerciseTextArea(
|
|
title: AppLocalizations.of(context).imageDetailsAuthorLink,
|
|
initialValue: widget.submissionImage.authorUrl,
|
|
onSaved: (value) => widget.submissionImage.authorUrl = value,
|
|
validator: (value) => validateUrl(value, i18n, required: false),
|
|
),
|
|
|
|
// Original source if this is a derivative work (modified from another image)
|
|
AddExerciseTextArea(
|
|
title: AppLocalizations.of(context).imageDetailsDerivativeSource,
|
|
helperText: AppLocalizations.of(context).imageDetailsDerivativeHelp,
|
|
initialValue: widget.submissionImage.derivativeSourceUrl,
|
|
onSaved: (value) => widget.submissionImage.derivativeSourceUrl = value,
|
|
validator: (value) => validateUrl(value, i18n, required: false),
|
|
),
|
|
|
|
ImageTypeSelector(
|
|
selectedType: _selectedImageType,
|
|
onTypeSelected: (type) {
|
|
setState(() {
|
|
_selectedImageType = type;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// License info as separate widget for better optimization
|
|
const LicenseInfoWidget(),
|
|
const SizedBox(height: 8),
|
|
|
|
_buildButtons(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
_formKey.currentState?.save();
|
|
|
|
// 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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImagePreview extends StatelessWidget {
|
|
final File imageFile;
|
|
|
|
const ImagePreview({super.key, required this.imageFile});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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(imageFile, fit: BoxFit.contain),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImageTypeSelector extends StatelessWidget {
|
|
final ImageType selectedType;
|
|
final ValueChanged<ImageType> onTypeSelected;
|
|
|
|
const ImageTypeSelector({super.key, required this.selectedType, required this.onTypeSelected});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final i18n = AppLocalizations.of(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
i18n.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 = selectedType == type;
|
|
return InkWell(
|
|
onTap: () => onTypeSelected(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
|
|
? theme.buttonTheme.colorScheme!.onPrimary
|
|
: theme.buttonTheme.colorScheme!.primary,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|