import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../../core/errors/api_error_code.dart';
import '../../core/formatting/localized_formatters.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../../models/image_suggestion_models.dart';
import '../../repositories/image_suggestion_repository.dart';
import '../../shared/states/app_state_widgets.dart';
import 'image_suggestion_labels.dart';
class ImageSuggestionPage extends StatefulWidget {
const ImageSuggestionPage({
super.key,
required this.item,
required this.repository,
});
final DiscoveryItem item;
final ImageSuggestionRepository repository;
@override
State<ImageSuggestionPage> createState() => _ImageSuggestionPageState();
}
class _ImageSuggestionPageState extends State<ImageSuggestionPage> {
final _sourceController = TextEditingController();
final _licenseController = TextEditingController();
final _photographerController = TextEditingController();
final _descriptionController = TextEditingController();
Uint8List? _bytes;
String _filename = '';
bool _rightsHolderConfirmed = false;
bool _lawfulSourceConfirmed = false;
bool _personsConsentConfirmed = false;
bool _platformLicenseConfirmed = false;
bool _loading = false;
String? _idempotencyKey;
ApiErrorCode? _errorCode;
ImageSuggestionData? _submitted;
@override
void dispose() {
_sourceController.dispose();
_licenseController.dispose();
_photographerController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (!isImageSuggestionTargetAllowed(widget.item.resource)) {
return Scaffold(
appBar: AppBar(
title: Text(l10n.imageSuggestTitle),
leading: IconButton(
onPressed: () => Navigator.of(context).maybePop(),
tooltip: l10n.back,
icon: const Icon(Icons.arrow_back),
),
),
body: EmptyState(
title: l10n.imageSuggestUnavailableTitle,
body: l10n.imageSuggestUnavailableBody,
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(l10n.imageSuggestTitle),
leading: IconButton(
onPressed: () => Navigator.of(context).maybePop(),
tooltip: l10n.back,
icon: const Icon(Icons.arrow_back),
),
),
body: _submitted != null
? _StatusView(suggestion: _submitted!)
: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
l10n.imageSuggestBody(widget.item.title),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
_targetCard(context),
const SizedBox(height: 16),
if (_errorCode == ApiErrorCode.permissionDenied)
AuthRequiredState(
title: l10n.imageSuggestLoginTitle,
body: l10n.imageSuggestLoginBody,
)
else if (_errorCode != null)
ErrorState(
code: _errorCode,
onRetry: () => setState(() => _errorCode = null),
),
if (_errorCode != ApiErrorCode.permissionDenied) ...[
_uploadCard(context),
if (_bytes != null) ...[
const SizedBox(height: 16),
_rightsCard(context),
const SizedBox(height: 16),
_metadataCard(context),
],
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _canSubmit && !_loading ? _submit : null,
icon: _loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send_outlined),
label: Text(
_loading
? l10n.imageSuggestSubmitting
: l10n.imageSuggestSubmit,
),
),
],
],
),
);
}
bool get _canSubmit =>
_bytes != null &&
_rightsHolderConfirmed &&
_lawfulSourceConfirmed &&
_personsConsentConfirmed &&
_platformLicenseConfirmed;
Widget _targetCard(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: ListTile(
leading: Icon(
Icons.place_outlined,
color: AppTheme.palette(context).brand,
),
title: Text(widget.item.title),
subtitle: Text(
l10n.imageSuggestTarget(
imageSuggestionTargetLabel(l10n, widget.item.resource),
),
),
),
);
}
Widget _uploadCard(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.imageSuggestUploadTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (_bytes != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
_bytes!,
height: 180,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => SizedBox(
height: 180,
child: Center(child: Text(l10n.imageSuggestPreviewFailed)),
),
),
)
else
SizedBox(
height: 150,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Icon(
Icons.photo_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
const SizedBox(height: 12),
Text(
_filename.isEmpty ? l10n.imageSuggestNoFile : _filename,
style: TextStyle(color: AppTheme.palette(context).muted),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _loading ? null : _pickImage,
icon: const Icon(Icons.file_upload_outlined),
label: Text(
_bytes == null
? l10n.imageSuggestSelect
: l10n.imageSuggestSelectAnother,
),
),
],
),
),
);
}
Widget _rightsCard(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.imageSuggestRightsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_check(
value: _rightsHolderConfirmed,
label: l10n.imageSuggestRightsHolder,
onChanged: (value) =>
setState(() => _rightsHolderConfirmed = value ?? false),
),
_check(
value: _lawfulSourceConfirmed,
label: l10n.imageSuggestLawfulSource,
onChanged: (value) =>
setState(() => _lawfulSourceConfirmed = value ?? false),
),
_check(
value: _personsConsentConfirmed,
label: l10n.imageSuggestPersons,
onChanged: (value) =>
setState(() => _personsConsentConfirmed = value ?? false),
),
_check(
value: _platformLicenseConfirmed,
label: l10n.imageSuggestPlatformLicense,
onChanged: (value) =>
setState(() => _platformLicenseConfirmed = value ?? false),
),
],
),
),
);
}
Widget _check({
required bool value,
required String label,
required ValueChanged<bool?> onChanged,
}) {
return CheckboxListTile(
value: value,
onChanged: onChanged,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(label),
);
}
Widget _metadataCard(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.imageSuggestMetadataTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(l10n.imageSuggestOptional),
const SizedBox(height: 12),
TextField(
controller: _sourceController,
decoration: InputDecoration(labelText: l10n.imageSuggestSource),
),
TextField(
controller: _licenseController,
decoration: InputDecoration(labelText: l10n.imageSuggestLicense),
),
TextField(
controller: _photographerController,
decoration: InputDecoration(
labelText: l10n.imageSuggestPhotographer,
),
),
TextField(
controller: _descriptionController,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
labelText: l10n.imageSuggestDescription,
),
),
],
),
),
);
}
Future<void> _pickImage() async {
final l10n = AppLocalizations.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
);
if (!mounted) return;
final file = result?.files.single;
if (file == null || file.bytes == null || file.bytes!.isEmpty) {
if (result != null) {
setState(() => _errorCode = ApiErrorCode.invalidResponse);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.imageSuggestFileUnavailable)),
);
}
return;
}
setState(() {
_bytes = file.bytes;
_filename = file.name;
_errorCode = null;
_submitted = null;
// A different file must never replay an idempotency key that may have
// already been committed by a previous upload attempt.
_idempotencyKey = null;
});
}
Future<void> _submit() async {
if (!_canSubmit || _bytes == null) return;
setState(() {
_loading = true;
_errorCode = null;
});
try {
final result = await widget.repository.submit(
targetType: widget.item.resource,
targetId: widget.item.id,
bytes: _bytes!,
filename: _filename,
rightsHolderConfirmed: _rightsHolderConfirmed,
lawfulSourceConfirmed: _lawfulSourceConfirmed,
personsConsentConfirmed: _personsConsentConfirmed,
platformLicenseConfirmed: _platformLicenseConfirmed,
idempotencyKey: _idempotencyKey ??= _newIdempotencyKey(),
source: _sourceController.text,
license: _licenseController.text,
photographer: _photographerController.text,
description: _descriptionController.text,
);
if (!mounted) return;
setState(() {
_submitted = result;
_loading = false;
_idempotencyKey = null;
});
} catch (error) {
if (!mounted) return;
setState(() {
_loading = false;
_errorCode = error is ApiException ? error.code : ApiErrorCode.unknown;
});
}
}
String _newIdempotencyKey() =>
'mw-${widget.item.id}-${DateTime.now().microsecondsSinceEpoch}';
}
class _StatusView extends StatelessWidget {
const _StatusView({required this.suggestion});
final ImageSuggestionData suggestion;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle_outline,
color: AppTheme.palette(context).brand,
),
const SizedBox(height: 12),
Text(
l10n.imageSuggestStatusTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(_statusLabel(l10n, suggestion.status)),
const SizedBox(height: 8),
Text(suggestion.originalFilename),
if (suggestion.createdAt.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
l10n.imageSuggestSubmittedAt(
formatDate(
suggestion.createdAt,
Localizations.localeOf(context).toLanguageTag(),
),
),
style: Theme.of(context).textTheme.bodySmall,
),
],
if (suggestion.reasonCode.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'${l10n.imageSuggestReason}: ${_reasonLabel(l10n, suggestion.reasonCode)}',
),
],
],
),
),
),
],
);
}
String _statusLabel(AppLocalizations l10n, String status) {
return switch (status) {
'submitted' => l10n.imageSuggestStatusSubmitted,
'in_review' => l10n.imageSuggestStatusInReview,
'accepted' => l10n.imageSuggestStatusAccepted,
'rejected' => l10n.imageSuggestStatusRejected,
'needs_info' => l10n.imageSuggestStatusNeedsInfo,
_ => l10n.imageSuggestStatusPending,
};
}
String _reasonLabel(AppLocalizations l10n, String reason) {
return switch (reason) {
'copyright' => l10n.imageSuggestReasonCopyright,
'privacy' => l10n.imageSuggestReasonPrivacy,
'quality' => l10n.imageSuggestReasonQuality,
'duplicate' => l10n.imageSuggestReasonDuplicate,
'malware' => l10n.imageSuggestReasonMalware,
'unclear_rights' => l10n.imageSuggestReasonUnclearRights,
'wrong_target' => l10n.imageSuggestReasonWrongTarget,
'other' => l10n.imageSuggestReasonOther,
'needs_info' => l10n.imageSuggestStatusNeedsInfo,
_ => l10n.imageSuggestReasonUnknown,
};
}
}
