import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
import '../../core/errors/api_error_code.dart';
import '../../core/location/location_controller.dart';
import '../../core/navigation/canonical_module_routes.dart';
import '../../l10n/app_localizations.dart';
import '../../models/account_models.dart';
import '../../models/content_models.dart';
import '../../repositories/account_repository.dart';
import '../../repositories/discovery_repository.dart';
import '../../repositories/geo_repository.dart';
import '../../repositories/image_suggestion_repository.dart';
import '../../shared/states/app_state_widgets.dart';
import '../../shared/media/public_media_image.dart';
import '../detail/content_detail_page.dart';
import '../discovery/discovery_page.dart';
class SavedPage extends StatefulWidget {
const SavedPage({
super.key,
required this.accountRepository,
required this.discoveryRepository,
required this.geoRepository,
required this.geoConfig,
this.imageSuggestionRepository,
this.locationController,
this.contentSchemas = const {},
this.contentCategories = const {},
this.fieldIcons = const {},
});
final AccountRepository accountRepository;
final DiscoveryRepository discoveryRepository;
final GeoRepository geoRepository;
final GeoConfig geoConfig;
final ImageSuggestionRepository? imageSuggestionRepository;
final LocationController? locationController;
final Map<String, Map<String, ContentFieldSchema>> contentSchemas;
final Map<String, ContentCategorySchema> contentCategories;
final Map<String, AppIconConfig> fieldIcons;
@override
State<SavedPage> createState() => _SavedPageState();
}
class _SavedPageState extends State<SavedPage> {
bool _loading = true;
ApiErrorCode? _errorCode;
List<SavedItem> _items = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorCode = null;
});
try {
final response = await widget.accountRepository.favorites();
if (!mounted) return;
setState(() {
_items = response.items;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() {
_errorCode = error is ApiException ? error.code : ApiErrorCode.unknown;
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: RefreshIndicator(
onRefresh: _load,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
sliver: SliverToBoxAdapter(
child: Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.bookmark_outline,
size: 32,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.navSaved,
style: Theme.of(
context,
).textTheme.headlineSmall,
),
const SizedBox(height: 6),
Text(l10n.savedIntroBody),
],
),
),
],
),
),
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: LoadingState(),
)
else if (_errorCode == ApiErrorCode.permissionDenied)
const SliverFillRemaining(
hasScrollBody: false,
child: AuthRequiredState(),
)
else if (_errorCode != null)
SliverFillRemaining(
hasScrollBody: false,
child: ErrorState(onRetry: _load, code: _errorCode),
)
else if (_items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(
title: l10n.emptySavedTitle,
body: l10n.emptySavedBody,
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList.separated(
itemCount: _items.length,
itemBuilder: (context, index) => _SavedCard(
saved: _items[index],
onTap: () => _openDetail(_items[index]),
),
separatorBuilder: (context, index) =>
const SizedBox(height: 10),
),
),
],
),
),
),
);
}
Future<void> _openDetail(SavedItem saved) async {
final item = saved.item;
if (item == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ContentDetailPage(
item: item,
repository: widget.discoveryRepository,
accountRepository: widget.accountRepository,
imageSuggestionRepository: widget.imageSuggestionRepository,
onOpenMap: CanonicalModuleRoutes.isGeoResource(item.resource)
? () => _openMapWithFocus(item)
: null,
geoConfig: widget.geoConfig,
userLatitude: widget.locationController?.coordinate?.latitude,
userLongitude: widget.locationController?.coordinate?.longitude,
fieldSchemas: widget.contentSchemas[item.resource] ?? const {},
fieldIcons: widget.fieldIcons,
),
),
);
if (mounted) {
await _load();
}
}
void _openMapWithFocus(DiscoveryItem item) {
if (!CanonicalModuleRoutes.isGeoResource(item.resource)) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).mapView)),
body: DiscoveryPage(
repository: widget.discoveryRepository,
types: [item.resource],
geoRepository: widget.geoRepository,
geoConfig: widget.geoConfig,
showMapInitially: true,
showTitle: false,
initialFocusItem: item,
accountRepository: widget.accountRepository,
imageSuggestionRepository: widget.imageSuggestionRepository,
locationController: widget.locationController,
categorySchema: widget.contentCategories[item.resource],
fieldSchemas: widget.contentSchemas[item.resource] ?? const {},
fieldIcons: widget.fieldIcons,
titleBuilder: (l10n) => l10n.mapView,
),
),
),
);
}
}
class _SavedCard extends StatelessWidget {
const _SavedCard({required this.saved, required this.onTap});
final SavedItem saved;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final item = saved.item!;
final media = item.media.where(
(entry) => !entry.isPlaceholder && entry.url.trim().isNotEmpty,
);
final imageUrl = media.isEmpty ? item.imageUrl : media.first.url;
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
onTap: onTap,
leading: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PublicMediaImage(
url: imageUrl,
alt: item.title,
width: 84,
height: 76,
fallbackHeight: 76,
emptyIcon: Icons.place_outlined,
errorIcon: Icons.broken_image_outlined,
iconSize: 28,
),
),
title: Text(item.title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.address.formatted,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right),
),
);
}
}
