MIF_E31231623/android/wisata_app/lib/screens/detail_destination_screen.dart

527 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:wisata_app/models/destination.dart';
import '../services/destination_service.dart';
import '../services/maps_launcher_service.dart';
import '../widgets/destination_image.dart';
import '../widgets/primary_button.dart';
import 'ar_view_screen.dart';
import 'video_page.dart';
class DetailDestinationScreen extends StatefulWidget {
const DetailDestinationScreen({super.key});
static const routeName = '/detail';
@override
State<DetailDestinationScreen> createState() =>
_DetailDestinationScreenState();
}
class _DetailDestinationScreenState extends State<DetailDestinationScreen> {
static const MapsLauncherService _mapsLauncher = MapsLauncherService();
static const DestinationService _destinationService = DestinationService();
Future<Destination?>? _destinationFuture;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_destinationFuture ??= _resolveDestination();
}
Future<Destination?> _resolveDestination() async {
final argument = ModalRoute.of(context)?.settings.arguments;
if (argument is Destination) return argument;
if (argument == null) return null;
return _destinationService.getDestinationDetail(argument.toString());
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Destination?>(
future: _destinationFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
backgroundColor: Color(0xFFF4F8FB),
body: Center(child: CircularProgressIndicator()),
);
}
final destination = snapshot.data;
if (snapshot.hasError || destination == null) {
return Scaffold(
backgroundColor: const Color(0xFFF4F8FB),
appBar: AppBar(),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
snapshot.hasError
? 'Detail wisata gagal dimuat.'
: 'Destinasi tidak ditemukan.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
),
);
}
return _DetailContent(
destination: destination,
mapsLauncher: _mapsLauncher,
);
},
);
}
}
class _DetailContent extends StatelessWidget {
const _DetailContent({
required this.destination,
required this.mapsLauncher,
});
final Destination destination;
final MapsLauncherService mapsLauncher;
@override
Widget build(BuildContext context) {
// ignore: avoid_print
print(destination.imagePath);
return Scaffold(
backgroundColor: const Color(0xFFF4F8FB),
body: Stack(
children: [
CustomScrollView(
slivers: [
_Header(destination: destination),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 116),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
destination.title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0B1F33),
),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.place_rounded,
size: 20, color: Color(0xFF4B6475)),
const SizedBox(width: 6),
Expanded(
child: Text(
destination.displayLocation,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: const Color(0xFF4B6475),
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 22),
Column(
children: [
_DetailInfoCard(
icon: Icons.star_rounded,
label: 'Rating',
value: destination.rating.toStringAsFixed(1),
accentColor: const Color(0xFFD97706),
),
const SizedBox(height: 10),
_DetailInfoCard(
icon: Icons.height_rounded,
label: 'Ketinggian',
value: destination.elevation,
),
const SizedBox(height: 10),
_DetailInfoCard(
icon: Icons.confirmation_number,
label: 'Tiket & Parkir',
value: destination.tiketParkir,
),
const SizedBox(height: 10),
_DetailInfoCard(
icon: Icons.access_time,
label: 'Jam Operasional',
value: destination.jamOperasional,
),
],
),
const SizedBox(height: 28),
_SectionTitle(title: 'Deskripsi'),
const SizedBox(height: 10),
Text(
destination.overview,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.65,
color: const Color(0xFF405466),
),
),
const SizedBox(height: 28),
_SectionTitle(title: 'Lokasi'),
const SizedBox(height: 12),
_MapPreview(destination: destination),
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: destination.hasCoordinates
? () => _openGoogleMaps(
context,
destination,
)
: null,
icon: const Icon(Icons.map_outlined),
label: const Text('Buka Google Maps'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
foregroundColor: const Color(0xFF0F4C81),
side: const BorderSide(color: Color(0xFF0F4C81)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
],
),
),
),
],
),
Positioned(
left: 20,
right: 20,
bottom: 22,
child: SafeArea(
child: PrimaryButton(
label: destination.isVideo && destination.videoPath.isNotEmpty
? 'Lihat Video Wisata'
: 'Lihat AR',
icon: destination.isVideo && destination.videoPath.isNotEmpty
? Icons.play_circle_fill
: Icons.view_in_ar_rounded,
onPressed: () {
if (destination.isVideo && destination.videoPath.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoPage(
title: destination.title,
videoPath: destination.videoPath,
),
),
);
return;
}
final modelPath = destination.modelPath;
if (modelPath.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Model AR untuk ${destination.title} belum tersedia.',
),
),
);
return;
}
Navigator.pushNamed(
context,
ArViewScreen.routeName,
arguments: destination,
);
},
),
),
),
],
),
);
}
Future<void> _openGoogleMaps(
BuildContext context,
Destination destination,
) async {
try {
await mapsLauncher.openDestination(destination);
} on MapsLauncherException catch (exception) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(exception.message)));
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Lokasi belum bisa dibuka. Silakan coba lagi.'),
),
);
}
}
}
class _Header extends StatelessWidget {
const _Header({required this.destination});
final Destination destination;
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 390,
pinned: true,
backgroundColor: const Color(0xFF083A63),
leading: Padding(
padding: const EdgeInsets.only(left: 12),
child: IconButton.filledTonal(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_rounded),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
Hero(
tag: 'destination-${destination.id}',
child: DestinationImage(
path: destination.imagePath,
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x11000000),
Color(0xEE083A63)
],
),
),
),
Positioned(
left: 20,
bottom: 28,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(999),
border:
Border.all(color: Colors.white.withValues(alpha: 0.32)),
),
child: Row(
children: [
const Icon(Icons.star_rounded,
color: Color(0xFFFFD36A), size: 18),
const SizedBox(width: 6),
Text(
destination.rating.toStringAsFixed(1),
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.w800),
),
],
),
),
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w900),
);
}
}
class _DetailInfoCard extends StatelessWidget {
const _DetailInfoCard({
required this.icon,
required this.label,
required this.value,
this.accentColor,
});
final IconData icon;
final String label;
final String value;
final Color? accentColor;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(22),
border:
Border.all(color: colors.outlineVariant.withValues(alpha: 0.45)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: accentColor ?? colors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF0B1F33),
fontWeight: FontWeight.w800,
height: 1.45,
),
),
],
),
),
],
),
);
}
}
class _MapPreview extends StatelessWidget {
const _MapPreview({required this.destination});
final Destination destination;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(26),
child: Container(
height: 170,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFFD9EEF8), Color(0xFFB7D8EA)],
),
),
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(painter: _MapPatternPainter()),
),
Center(
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.86),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.14),
blurRadius: 24,
offset: const Offset(0, 14),
),
],
),
child: const Icon(Icons.location_on_rounded,
color: Color(0xFFE04F3F), size: 36),
),
),
Positioned(
left: 18,
bottom: 16,
child: Text(
destination.displayLocation,
style: const TextStyle(
fontWeight: FontWeight.w900, color: Color(0xFF0B1F33)),
),
),
],
),
),
);
}
}
class _MapPatternPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final pathPaint = Paint()
..color = Colors.white.withValues(alpha: 0.42)
..style = PaintingStyle.stroke
..strokeWidth = 6
..strokeCap = StrokeCap.round;
final route = Path()
..moveTo(-20, size.height * 0.72)
..quadraticBezierTo(size.width * 0.24, size.height * 0.32,
size.width * 0.46, size.height * 0.56)
..quadraticBezierTo(size.width * 0.68, size.height * 0.8, size.width + 24,
size.height * 0.28);
canvas.drawPath(route, pathPaint);
final contourPaint = Paint()
..color = const Color(0xFF2B6E99).withValues(alpha: 0.28)
..style = PaintingStyle.stroke
..strokeWidth = 2;
for (var i = 0; i < 5; i++) {
canvas.drawOval(
Rect.fromCenter(
center: Offset(size.width * (0.18 + i * 0.18),
size.height * (0.25 + (i.isEven ? 0.08 : 0.2))),
width: 120 - i * 8,
height: 52 + i * 8,
),
contourPaint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}