527 lines
17 KiB
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;
|
|
}
|