import 'package:flutter/material.dart';
import '../../core/localization/app_localizations.dart';
import '../../core/theme/app_colors.dart';
import '../../data/app_data_scope.dart';
import '../../data/location_scope.dart';
import '../../models/poi.dart';
import '../../services/analytics_service.dart';
import '../../services/guide_audio_service.dart';
import '../../services/external_navigation_service.dart';
import '../../services/contextual_recommendation_service.dart';
import '../../services/opening_hours_service.dart';
import '../../widgets/affiliate_ticket_button.dart';
import '../../widgets/media_gallery.dart';
import '../../widgets/image_caption.dart';
import '../../widgets/opening_hours.dart';
import '../../widgets/remote_image.dart';
import '../../widgets/proximity_label.dart';
import '../../widgets/favorite_toggle_button.dart';
import '../../widgets/poi_recommendations.dart';
class PoiDetailPage extends StatefulWidget {
const PoiDetailPage({super.key, required this.poi});
final Poi poi;
@override
State<PoiDetailPage> createState() => _PoiDetailPageState();
}
class _PoiDetailPageState extends State<PoiDetailPage> {
final GuideAudioService _audio = GuideAudioService();
late Poi _poi;
bool _loadingDetail = true;
bool _speaking = false;
bool _audioBusy = false;
@override
void initState() {
super.initState();
_poi = widget.poi;
AnalyticsService.instance.poiView(widget.poi.id);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadDetail());
}
@override
void dispose() {
_audio.dispose();
super.dispose();
}
Future<void> _loadDetail() async {
final detail = await AppDataScope.read(context).poiDetail(widget.poi.id);
if (!mounted) return;
setState(() {
_poi = detail;
_loadingDetail = false;
});
_prewarmAudio();
}
Future<void> _prewarmAudio() async {
if (!mounted) return;
final lang = Localizations.localeOf(context).languageCode;
final resolved = _poi.guideText.resolve(lang);
final prerecorded =
_poi.audioUrls[resolved.languageCode] ?? _poi.audioUrls[lang];
await _audio.prewarm(
text: resolved.text,
languageCode: resolved.languageCode,
prerecordedUrl: prerecorded,
);
}
Future<void> _toggleAudio(String requestedLanguage) async {
if (_audioBusy) return;
if (_speaking) {
setState(() => _audioBusy = true);
await _audio.stop();
if (!mounted) return;
setState(() {
_speaking = false;
_audioBusy = false;
});
return;
}
final resolved = _poi.guideText.resolve(requestedLanguage);
if (resolved.text.trim().isEmpty) return;
final prerecorded = _poi.audioUrls[resolved.languageCode] ??
_poi.audioUrls[requestedLanguage];
setState(() => _audioBusy = true);
await AnalyticsService.instance.audioStart(
poiId: _poi.id,
source: prerecorded?.trim().isNotEmpty == true ? 'prerecorded' : 'tts',
);
try {
await _audio.speak(
text: resolved.text,
languageCode: resolved.languageCode,
prerecordedUrl: prerecorded,
);
if (!mounted) return;
setState(() => _speaking = true);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(AppLocalizations.of(context).t('audioPlaybackFailed'))),
);
} finally {
if (mounted) setState(() => _audioBusy = false);
}
}
@override
Widget build(BuildContext context) {
final poi = _poi;
final lang = Localizations.localeOf(context).languageCode;
final l10n = AppLocalizations.of(context);
final guide = poi.guideText.resolve(lang);
return Scaffold(
appBar: AppBar(
actions: [
FavoriteToggleButton(kind: FavoriteKind.poi, id: poi.id),
const SizedBox(width: 6),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 34),
children: [
RemoteImage(
url: poi.imageUrl,
height: 260,
width: double.infinity,
borderRadius: BorderRadius.circular(28),
fallbackIcon: Icons.location_city_rounded,
fallbackIconSize: 72,
semanticLabel: poi.title.get(lang),
),
if (poi.imageUrl?.trim().isNotEmpty == true)
ImageCaption(text: poi.imageCaption),
const SizedBox(height: 20),
OpeningStatusBadge(openingHours: poi.openingHours),
if (poi.openingHours?.trim().isNotEmpty == true)
const SizedBox(height: 10),
Text(
l10n.categoryLabel(poi.category).toUpperCase(),
style: const TextStyle(
color: AppColors.heartRed,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
poi.title.get(lang),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 10),
Text(
poi.shortDescription.get(lang),
style: Theme.of(context).textTheme.bodyLarge,
),
Builder(
builder: (context) {
final location = LocationScope.of(context);
final meters = location.distanceMetersTo(
poi.latitude,
poi.longitude,
);
if (meters == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: ProximityLabel(
distanceMeters: meters,
walkingMinutes: location.walkingMinutesTo(
poi.latitude,
poi.longitude,
),
),
);
},
),
const SizedBox(height: 14),
_VisitAtAGlance(poi: poi),
if (poi.familyFriendly ||
poi.wheelchairAccessible ||
poi.freeAccess ||
guide.text.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (poi.familyFriendly)
_FeatureChip(
icon: Icons.family_restroom_rounded,
label: l10n.t('family')),
if (poi.wheelchairAccessible)
_FeatureChip(
icon: Icons.accessible_rounded,
label: l10n.t('wheelchairAccessible')),
if (poi.freeAccess)
_FeatureChip(
icon: Icons.euro_rounded,
label: l10n.t('freeAccessible')),
if (guide.text.trim().isNotEmpty)
_FeatureChip(
icon: Icons.headphones_rounded,
label: l10n.t('audioGuideFeature')),
],
),
],
if (_loadingDetail) ...[
const SizedBox(height: 14),
const LinearProgressIndicator(minHeight: 2),
],
if (poi.hasAffiliateTicket) ...[
const SizedBox(height: 18),
AffiliateTicketButton(poi: poi),
],
if (poi.galleryUrls.isNotEmpty) ...[
const SizedBox(height: 30),
MediaGallery(
urls: poi.galleryUrls,
title: l10n.t('moreImages'),
),
],
if (poi.openingHours?.trim().isNotEmpty == true) ...[
const SizedBox(height: 26),
OpeningHoursSection(openingHours: poi.openingHours),
],
if (poi.address != null) ...[
const SizedBox(height: 14),
_InfoRow(
icon: Icons.place_outlined,
title: l10n.t('address'),
value: poi.address!,
),
],
const SizedBox(height: 18),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: () async {
await AnalyticsService.instance.track('poi_navigation_click',
properties: {'poi_id': poi.id});
await ExternalNavigationService.navigateTo(
latitude: poi.latitude,
longitude: poi.longitude,
label: poi.title.get(lang),
);
},
icon: const Icon(Icons.directions_rounded),
label: Text(l10n.t('openInMaps')),
),
if (poi.websiteUrl?.trim().isNotEmpty == true)
OutlinedButton.icon(
onPressed: () async {
await AnalyticsService.instance.track('poi_website_click',
properties: {'poi_id': poi.id});
await ExternalNavigationService.openWebsite(
poi.websiteUrl!);
},
icon: const Icon(Icons.language_rounded),
label: Text(l10n.t('openWebsite')),
),
],
),
if (poi.wheelchairAccessible) ...[
const SizedBox(height: 14),
_InfoRow(
icon: Icons.accessible_rounded,
title: l10n.t('wheelchairAccessible'),
value: l10n.t('wheelchairAccessible'),
),
],
if (guide.text.trim().isNotEmpty) ...[
const SizedBox(height: 30),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.ink,
borderRadius: BorderRadius.circular(22),
),
child: Row(
children: [
const Icon(Icons.headphones, color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.t('listenGuide'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
l10n.t('audioTtsNote'),
style: TextStyle(
color: Colors.white.withValues(alpha: .72),
fontSize: 12,
),
),
],
),
),
IconButton(
tooltip:
_speaking ? l10n.t('stopAudio') : l10n.t('listenGuide'),
onPressed: _audioBusy ? null : () => _toggleAudio(lang),
icon: _audioBusy
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
_speaking
? Icons.stop_circle_outlined
: Icons.play_circle_fill,
color: Colors.white,
size: 38,
),
),
],
),
),
const SizedBox(height: 24),
Text(
l10n.t('guide'),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 10),
SelectableText(
guide.text,
style:
Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6),
),
],
const SizedBox(height: 30),
PoiRecommendations(
items: const ContextualRecommendationService()
.relatedPois(poi, AppDataScope.of(context).pois),
languageCode: lang,
onTap: (item) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PoiDetailPage(poi: item))),
),
],
),
);
}
}
class _FeatureChip extends StatelessWidget {
const _FeatureChip({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: AppColors.softPink,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: AppColors.heartRed),
const SizedBox(width: 6),
Text(label,
style:
const TextStyle(fontSize: 12, fontWeight: FontWeight.w800)),
],
),
);
}
class _VisitAtAGlance extends StatelessWidget {
const _VisitAtAGlance({required this.poi});
final Poi poi;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final location = LocationScope.of(context);
final walking = location.walkingMinutesTo(poi.latitude, poi.longitude);
final info = OpeningHoursService.parse(poi.openingHours);
final stateText = switch (info.state) {
OpeningState.open => l10n.t('openingStatusOpen'),
OpeningState.closingSoon => l10n.t('openingStatusClosingSoon'),
OpeningState.openingSoon => l10n.t('openingStatusOpeningSoon'),
OpeningState.closed => l10n.t('openingStatusClosed'),
OpeningState.alwaysAccessible => l10n.t('openingStatusAlwaysAccessible'),
OpeningState.unknown => l10n.t('openingStatusUnknown'),
};
final parts = <String>[
stateText,
if (walking != null) l10n.tf('approxWalkMinutes', {'n': walking}),
];
return Semantics(
label: parts.join(', '),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.line),
),
child: Row(
children: [
const Icon(Icons.schedule_rounded, color: AppColors.heartRed),
const SizedBox(width: 10),
Expanded(
child: Text(parts.join(' · '),
style: const TextStyle(fontWeight: FontWeight.w800))),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.icon,
required this.title,
required this.value,
});
final IconData icon;
final String title;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.line),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: AppColors.heartRed),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 12,
color: AppColors.mutedInk,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(value),
],
),
),
],
),
);
}
}

