import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
import '../../core/errors/api_error_code.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../l10n/locale_controller.dart';
import '../../models/account_models.dart';
import '../../models/content_models.dart';
import '../../models/image_suggestion_models.dart';
import '../../repositories/account_repository.dart';
import '../../repositories/claim_repository.dart';
import '../claim/claim_page.dart';
import '../discovery/resource_browser_page.dart';
import '../../repositories/discovery_repository.dart';
import '../../repositories/geo_repository.dart';
import '../../repositories/image_suggestion_repository.dart';
import '../../repositories/notifications_repository.dart';
import '../notifications/notification_inbox_page.dart';
import '../../shared/states/app_state_widgets.dart';
import '../image_suggestion/image_suggestion_labels.dart';
import '../image_suggestion/image_suggestion_status_page.dart';
class MorePage extends StatefulWidget {
const MorePage({
super.key,
required this.localeController,
required this.snapshot,
required this.accountRepository,
required this.claimRepository,
required this.discoveryRepository,
required this.geoRepository,
this.imageSuggestionRepository,
this.notificationsRepository,
required this.onOpenModule,
});
final LocaleController localeController;
final BootstrapSnapshot snapshot;
final AccountRepository accountRepository;
final ClaimRepository claimRepository;
final DiscoveryRepository discoveryRepository;
final GeoRepository geoRepository;
final ImageSuggestionRepository? imageSuggestionRepository;
final NotificationsRepository? notificationsRepository;
final Future<void> Function(String id) onOpenModule;
@override
State<MorePage> createState() => _MorePageState();
}
class _MorePageState extends State<MorePage> {
late Future<ProfileData> _profile;
@override
void initState() {
super.initState();
_profile = widget.accountRepository.me();
}
void _retryProfile() {
setState(() {
_profile = widget.accountRepository.me();
});
}
Future<void> _openProfileEditor(ProfileData profile) async {
final updated = await Navigator.of(context).push<ProfileData>(
MaterialPageRoute<ProfileData>(
builder: (_) => _EditProfilePage(
profile: profile,
accountRepository: widget.accountRepository,
),
),
);
if (updated != null && mounted) {
_retryProfile();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final currentLanguage = _localeLabel(l10n, widget.localeController.locale);
final supportedLocales = AppLocalizations.supportedLocales;
final currentLocaleTag = localeLanguageTag(widget.localeController.locale);
final selectedLocale = supportedLocales.firstWhere(
(locale) => localeLanguageTag(locale) == currentLocaleTag,
orElse: () => supportedLocales.first,
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
l10n.moreTitle,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
_ProfileCard(
profile: _profile,
onRetry: _retryProfile,
onEdit: _openProfileEditor,
),
const SizedBox(height: 16),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.languageLabel),
subtitle: Text(currentLanguage),
trailing: DropdownButton<Locale>(
value: selectedLocale,
underline: const SizedBox.shrink(),
items: supportedLocales
.map(
(locale) => DropdownMenuItem(
value: locale,
child: Text(_localeLabel(l10n, locale)),
),
)
.toList(growable: false),
onChanged: (locale) {
if (locale != null) {
widget.localeController.select(locale);
}
},
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.tune),
title: Text(l10n.settingsTitle),
onTap: () => _openInfo(
context,
title: l10n.settingsTitle,
body: l10n.settingsAvailabilityBody,
icon: Icons.tune,
),
),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: Text(l10n.privacyTitle),
onTap: () => _openInfo(
context,
title: l10n.privacyTitle,
body: l10n.privacyAvailabilityBody,
icon: Icons.privacy_tip_outlined,
),
),
ListTile(
leading: const Icon(Icons.verified_user_outlined),
title: Text(l10n.claimTitle),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ClaimPage(repository: widget.claimRepository),
),
);
},
),
ListTile(
leading: const Icon(Icons.notifications_none_outlined),
title: Text(l10n.notificationsTitle),
onTap: widget.notificationsRepository == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => NotificationInboxPage(
repository: widget.notificationsRepository!,
imageSuggestionRepository:
widget.imageSuggestionRepository,
),
),
),
),
],
),
),
const SizedBox(height: 16),
_ModuleCard(
resources: widget.snapshot.resources,
repository: widget.discoveryRepository,
geoRepository: widget.geoRepository,
geoConfig: widget.snapshot.config.geo,
imageSuggestionRepository: widget.imageSuggestionRepository,
contentSchemas: widget.snapshot.config.contentSchemas,
onOpenModule: widget.onOpenModule,
),
if (widget.imageSuggestionRepository != null) ...[
const SizedBox(height: 16),
_ImageSuggestionStatusCard(
repository: widget.imageSuggestionRepository!,
),
],
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.apiContractTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(l10n.apiContractBody),
const SizedBox(height: 8),
Text(
widget.snapshot.config.apiBaseUrl.toString(),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
),
);
}
void _openInfo(
BuildContext context, {
required String title,
required String body,
required IconData icon,
}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _InfoPage(title: title, body: body, icon: icon),
),
);
}
String _localeLabel(AppLocalizations l10n, Locale locale) {
return switch (locale.languageCode.toLowerCase()) {
'de' => l10n.languageGerman,
'en' => l10n.languageEnglish,
_ => localeLanguageTag(locale),
};
}
}
class _ModuleCard extends StatelessWidget {
const _ModuleCard({
required this.resources,
required this.repository,
required this.geoRepository,
required this.geoConfig,
required this.imageSuggestionRepository,
required this.contentSchemas,
required this.onOpenModule,
});
final List<ResourceDefinition> resources;
final DiscoveryRepository repository;
final GeoRepository geoRepository;
final GeoConfig geoConfig;
final ImageSuggestionRepository? imageSuggestionRepository;
final Map<String, Map<String, ContentFieldSchema>> contentSchemas;
final Future<void> Function(String id) onOpenModule;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final visibleResources = resources
.where((resource) => !{'places', 'events'}.contains(resource.id))
.toList(growable: false);
if (visibleResources.isEmpty) return const SizedBox.shrink();
return Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.apps_outlined),
title: Text(l10n.availableModulesTitle),
subtitle: Text(l10n.availableModulesBody),
),
const Divider(height: 1),
for (final resource in visibleResources)
ListTile(
leading: Icon(resourceIcon(resource.id)),
title: Text(localizedResourceLabel(l10n, resource)),
trailing: const Icon(Icons.chevron_right),
onTap: () => onOpenModule(resource.id),
),
],
),
);
}
}
class _InfoPage extends StatelessWidget {
const _InfoPage({
required this.title,
required this.body,
required this.icon,
});
final String title;
final String body;
final IconData icon;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 36, color: AppTheme.palette(context).brand),
const SizedBox(height: 16),
Text(body, style: Theme.of(context).textTheme.bodyLarge),
],
),
),
),
],
),
);
}
}
class _ImageSuggestionStatusCard extends StatefulWidget {
const _ImageSuggestionStatusCard({required this.repository});
final ImageSuggestionRepository repository;
@override
State<_ImageSuggestionStatusCard> createState() =>
_ImageSuggestionStatusCardState();
}
class _ImageSuggestionStatusCardState
extends State<_ImageSuggestionStatusCard> {
late Future<ImageSuggestionList> _suggestions;
@override
void initState() {
super.initState();
_suggestions = widget.repository.own();
}
void _retry() {
setState(() {
_suggestions = widget.repository.own();
});
}
Future<void> _openStatus({int? highlightedId}) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ImageSuggestionStatusPage(
repository: widget.repository,
highlightedId: highlightedId,
),
),
);
if (mounted) _retry();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: FutureBuilder<ImageSuggestionList>(
future: _suggestions,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(height: 96, child: LoadingState());
}
if (snapshot.hasError) {
final error = snapshot.error;
final code = error is ApiException
? error.code
: ApiErrorCode.unknown;
if (code == ApiErrorCode.permissionDenied) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.imageSuggestMyTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const SizedBox(height: 150, child: AuthRequiredState()),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.imageSuggestMyTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ErrorState(onRetry: _retry, code: code),
],
);
}
final items = snapshot.data?.items ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.imageSuggestMyTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(l10n.imageSuggestMyBody),
const SizedBox(height: 12),
if (items.isEmpty)
Text(
l10n.imageSuggestMyEmpty,
style: TextStyle(color: AppTheme.palette(context).muted),
)
else
for (final item in items.take(5))
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.photo_outlined),
title: Text(
item.originalFilename.isEmpty
? l10n.imageSuggestTarget(
imageSuggestionTargetLabel(
l10n,
item.targetType,
),
)
: item.originalFilename,
),
subtitle: Text(
l10n.imageSuggestMyItemStatus(
_statusLabel(l10n, item.status),
),
),
trailing: item.status == 'needs_info'
? const Icon(Icons.reply_outlined)
: const Icon(Icons.chevron_right),
onTap: () => _openStatus(highlightedId: item.id),
),
TextButton.icon(
onPressed: _openStatus,
icon: const Icon(Icons.list_alt_outlined),
label: Text(l10n.imageSuggestViewAll),
),
],
);
},
),
),
);
}
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,
};
}
}
class _ProfileCard extends StatelessWidget {
const _ProfileCard({
required this.profile,
required this.onRetry,
required this.onEdit,
});
final Future<ProfileData> profile;
final VoidCallback onRetry;
final Future<void> Function(ProfileData profile) onEdit;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: FutureBuilder<ProfileData>(
future: profile,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(height: 100, child: LoadingState());
}
if (snapshot.hasError) {
final error = snapshot.error;
final code = error is ApiException
? error.code
: ApiErrorCode.unknown;
if (code == ApiErrorCode.permissionDenied) {
return const SizedBox(height: 190, child: AuthRequiredState());
}
return SizedBox(
height: 230,
child: ErrorState(onRetry: onRetry, code: code),
);
}
final data = snapshot.data;
if (data == null || !data.visible) {
return Text(l10n.profileUnavailable);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
foregroundImage:
data.avatarVisible && data.avatarUrl.trim().isNotEmpty
? NetworkImage(data.avatarUrl)
: null,
child:
data.avatarVisible && data.avatarUrl.trim().isNotEmpty
? null
: const Icon(Icons.person_outline, size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.profileTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
data.nickname.isNotEmpty
? data.nickname
: l10n.unknownValue,
style: Theme.of(context).textTheme.titleLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 4),
Text(
l10n.profileSignedIn,
style: TextStyle(color: AppTheme.palette(context).muted),
),
if (data.status.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'${l10n.profileStatusLabel}: ${data.status}',
style: TextStyle(color: AppTheme.palette(context).muted),
),
],
const SizedBox(height: 12),
TextButton.icon(
onPressed: () => onEdit(data),
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.profileEdit),
),
],
);
},
),
),
);
}
}
class _EditProfilePage extends StatefulWidget {
const _EditProfilePage({
required this.profile,
required this.accountRepository,
});
final ProfileData profile;
final AccountRepository accountRepository;
@override
State<_EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends State<_EditProfilePage> {
late final TextEditingController _nickname;
late final TextEditingController _status;
late final TextEditingController _age;
late final TextEditingController _gender;
late final TextEditingController _bio;
late String _profileVisibility;
late String _avatarVisibility;
bool _saving = false;
ApiErrorCode? _errorCode;
@override
void initState() {
super.initState();
final profile = widget.profile;
_nickname = TextEditingController(text: profile.nickname);
_status = TextEditingController(text: profile.status);
_age = TextEditingController(
text: profile.age > 0 ? profile.age.toString() : '',
);
_gender = TextEditingController(text: profile.gender);
_bio = TextEditingController(text: profile.bio);
_profileVisibility = _visibility(profile.profileVisibility);
_avatarVisibility = _visibility(profile.avatarVisibility);
}
String _visibility(String value) =>
value == 'public' || value == 'private' ? value : 'private';
@override
void dispose() {
_nickname.dispose();
_status.dispose();
_age.dispose();
_gender.dispose();
_bio.dispose();
super.dispose();
}
Future<void> _save() async {
if (_saving) return;
setState(() {
_saving = true;
_errorCode = null;
});
final age = int.tryParse(_age.text.trim()) ?? 0;
try {
final updated = await widget.accountRepository.updateMe({
'nickname': _nickname.text.trim(),
'status': _status.text.trim(),
'age': age,
'gender': _gender.text.trim(),
'bio': _bio.text.trim(),
'profile_visibility': _profileVisibility,
'avatar_visibility': _avatarVisibility,
});
if (!mounted) return;
Navigator.of(context).pop(updated);
} catch (error) {
if (!mounted) return;
setState(() {
_errorCode = error is ApiException ? error.code : ApiErrorCode.unknown;
_saving = false;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.profileEdit)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _nickname,
textInputAction: TextInputAction.next,
decoration: InputDecoration(labelText: l10n.profileNickname),
),
const SizedBox(height: 12),
TextField(
controller: _status,
textInputAction: TextInputAction.next,
decoration: InputDecoration(labelText: l10n.profileStatusLabel),
),
const SizedBox(height: 12),
TextField(
controller: _age,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: InputDecoration(labelText: l10n.profileAge),
),
const SizedBox(height: 12),
TextField(
controller: _gender,
textInputAction: TextInputAction.next,
decoration: InputDecoration(labelText: l10n.profileGender),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _profileVisibility,
decoration: InputDecoration(labelText: l10n.profileVisibility),
items: [
DropdownMenuItem(
value: 'public',
child: Text(l10n.profilePublic),
),
DropdownMenuItem(
value: 'private',
child: Text(l10n.profilePrivate),
),
],
onChanged: _saving
? null
: (value) {
if (value != null) {
setState(() => _profileVisibility = value);
}
},
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _avatarVisibility,
decoration: InputDecoration(
labelText: l10n.profileAvatarVisibility,
),
items: [
DropdownMenuItem(
value: 'public',
child: Text(l10n.profilePublic),
),
DropdownMenuItem(
value: 'private',
child: Text(l10n.profilePrivate),
),
],
onChanged: _saving
? null
: (value) {
if (value != null) {
setState(() => _avatarVisibility = value);
}
},
),
const SizedBox(height: 12),
TextField(
controller: _bio,
minLines: 4,
maxLines: 8,
decoration: InputDecoration(labelText: l10n.profileBio),
),
if (_errorCode != null) ...[
const SizedBox(height: 16),
ErrorState(code: _errorCode!, onRetry: _save),
],
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: Text(_saving ? l10n.profileSaving : l10n.profileSave),
),
],
),
);
}
}
