import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'package:ar_flutter_plugin_updated/ar_flutter_plugin.dart'; import 'package:ar_flutter_plugin_updated/datatypes/config_planedetection.dart'; import 'package:ar_flutter_plugin_updated/datatypes/hittest_result_types.dart'; import 'package:ar_flutter_plugin_updated/datatypes/node_types.dart'; import 'package:ar_flutter_plugin_updated/managers/ar_anchor_manager.dart'; import 'package:ar_flutter_plugin_updated/managers/ar_location_manager.dart'; import 'package:ar_flutter_plugin_updated/managers/ar_object_manager.dart'; import 'package:ar_flutter_plugin_updated/managers/ar_session_manager.dart'; import 'package:ar_flutter_plugin_updated/models/ar_anchor.dart'; import 'package:ar_flutter_plugin_updated/models/ar_hittest_result.dart'; import 'package:ar_flutter_plugin_updated/models/ar_node.dart'; import 'package:flutter/material.dart'; import 'package:wisata_app/models/destination.dart'; import 'package:vector_math/vector_math_64.dart' as vector; import '../features/ar/domain/entities/ar_experience.dart'; import '../features/ar/presentation/controllers/ar_scene_controller.dart'; import '../services/ar_model_asset_service.dart'; import '../services/destination_service.dart'; class ArViewScreen extends StatefulWidget { const ArViewScreen({super.key}); static const routeName = '/ar'; @override State createState() => _ArViewScreenState(); } class _ArViewScreenState extends State { final ArModelAssetService _modelAssetService = const ArModelAssetService(); final DestinationService _destinationService = const DestinationService(); ARSessionManager? _sessionManager; ARObjectManager? _objectManager; ARAnchorManager? _anchorManager; ARNode? _placedNode; ARPlaneAnchor? _placedAnchor; ArExperience? _experience; ArSceneController? _sceneController; bool _isInitializing = true; bool _isPlacingModel = false; bool _isSessionReadyForPlacement = false; double _gestureStartScale = 0; double _gestureStartYaw = 0; Offset? _lastGestureFocalPoint; DateTime? _lastPlacementTapAt; String? _errorMessage; bool _didResolveRoute = false; bool get _hasPlacedObject => _sceneController?.hasPlacedObject ?? false; bool get _waitingForSurfaceTap => _sceneController?.waitingForSurfaceTap ?? true; @override void didChangeDependencies() { super.didChangeDependencies(); if (_didResolveRoute) return; _didResolveRoute = true; final routeArgument = ModalRoute.of(context)?.settings.arguments; unawaited(_resolveExperience(routeArgument)); } Future _resolveExperience(Object? routeArgument) async { try { if (routeArgument is Destination) { _setExperience(_experienceFromDestination(routeArgument)); } else if (routeArgument is String) { final destination = await _destinationService.getDestinationDetail(routeArgument); if (destination == null) { throw StateError( 'Destinasi dengan ID $routeArgument tidak ditemukan.', ); } _setExperience(_experienceFromDestination(destination)); } else { _errorMessage = 'Belum ada destinasi yang dipilih untuk pratinjau AR.'; } } on StateError catch (error) { _errorMessage = error.message; } on Exception catch (error) { _errorMessage = error.toString(); } if (!Platform.isAndroid) { _errorMessage = 'ARCore tanpa marker hanya didukung pada perangkat Android.'; } if (mounted) { setState(() {}); } } void _setExperience(ArExperience experience) { _experience = experience; _sceneController = ArSceneController(experience); } ArExperience _experienceFromDestination(Destination destination) { if (destination.modelPath.isEmpty) { throw StateError('${destination.title} belum memiliki model AR.'); } return ArExperience( destinationId: destination.id, destinationTitle: destination.title, modelPath: destination.modelPath, description: destination.arDescription, ); } @override void dispose() { final node = _placedNode; final anchor = _placedAnchor; final objectManager = _objectManager; final anchorManager = _anchorManager; if (anchor != null && anchorManager != null) { unawaited(anchorManager.removeAnchor(anchor)); } else if (node != null && objectManager != null) { unawaited(objectManager.removeNode(node)); } _sessionManager?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final experience = _experience; if (_errorMessage != null) { return _ArErrorView( message: _errorMessage ?? 'Mode AR belum bisa dibuka.', ); } if (experience == null) { return const Scaffold( backgroundColor: Colors.black, body: Center( child: CircularProgressIndicator(color: Color(0xFFB7D05A)), ), ); } return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ ARView( onARViewCreated: _onARViewCreated, planeDetectionConfig: PlaneDetectionConfig.horizontal, ), if (_hasPlacedObject) _buildGestureLayer(), _buildTopBar(experience), _buildInstructionOverlay(), if (_isInitializing || _isPlacingModel) _buildLoadingOverlay(), _buildControlDock(), ], ), ); } void _onARViewCreated( ARSessionManager sessionManager, ARObjectManager objectManager, ARAnchorManager anchorManager, ARLocationManager locationManager, ) { _sessionManager = sessionManager; _objectManager = objectManager; _anchorManager = anchorManager; _sessionManager!.onInitialize( showAnimatedGuide: true, showFeaturePoints: false, showPlanes: true, showWorldOrigin: false, handleTaps: true, handlePans: false, handleRotation: false, ); _objectManager!.onInitialize(); _sessionManager!.onPlaneOrPointTap = _onPlaneOrPointTapped; _sessionManager!.onError = _handleArError; unawaited(_markSessionReadyAfterWarmUp()); } Future _markSessionReadyAfterWarmUp() async { await Future.delayed(const Duration(milliseconds: 1600)); if (!mounted) return; setState(() { _isInitializing = false; _isSessionReadyForPlacement = true; }); } Future _onPlaneOrPointTapped( List hitTestResults) async { final experience = _experience; final sceneController = _sceneController; if (_hasPlacedObject || _isPlacingModel || !_isSessionReadyForPlacement || experience == null || sceneController == null) { return; } final now = DateTime.now(); final lastTap = _lastPlacementTapAt; if (lastTap != null && now.difference(lastTap) < const Duration(milliseconds: 900)) { return; } _lastPlacementTapAt = now; final planeHit = _firstPlaneHit(hitTestResults); if (planeHit == null) { _showMessage( 'Gerakkan ponsel perlahan sampai permukaan datar terdeteksi.'); return; } setState(() { _isPlacingModel = true; }); final anchor = ARPlaneAnchor(transformation: planeHit.worldTransform); try { final didAddAnchor = await _anchorManager?.addAnchor(anchor) ?? false; if (!didAddAnchor) { _finishPlacementWithError('Model belum bisa dikunci ke permukaan ini.'); return; } await Future.delayed(const Duration(milliseconds: 650)); if (!mounted || !_isPlacingModel) { _anchorManager?.removeAnchor(anchor); return; } final localModelPath = await _modelAssetService.prepareGlbForAr(experience.modelPath); if (!mounted || !_isPlacingModel) { _anchorManager?.removeAnchor(anchor); return; } final node = ARNode( type: NodeType.fileSystemAppFolderGLB, uri: localModelPath, scale: vector.Vector3.all(sceneController.scale), position: vector.Vector3.zero(), eulerAngles: vector.Vector3(0, sceneController.yaw, 0), ); final didAddNode = await _objectManager?.addNode(node, planeAnchor: anchor) ?? false; if (!didAddNode) { _anchorManager?.removeAnchor(anchor); _finishPlacementWithError('Model 3D lokal belum bisa dimuat.'); return; } if (!mounted) return; setState(() { _placedAnchor = anchor; _placedNode = node; sceneController.markObjectPlaced(); _isPlacingModel = false; }); } on ArModelAssetException catch (exception) { _anchorManager?.removeAnchor(anchor); _finishPlacementWithError(exception.message); } catch (error) { debugPrint('[AR Model] Gagal menaruh ${experience.modelPath}: $error'); _anchorManager?.removeAnchor(anchor); _finishPlacementWithError( 'Sesi AR belum stabil. Coba pindai lantai lagi.'); } } void _handleArError(String error) { debugPrint('[AR Model] Native AR error: $error'); _finishPlacementWithError(error); } ARHitTestResult? _firstPlaneHit(List hits) { for (final hit in hits) { if (hit.type == ARHitTestResultType.plane) { return hit; } } return null; } void _finishPlacementWithError(String message) { if (!mounted) return; setState(() { _isPlacingModel = false; }); _showMessage(message); } Widget _buildGestureLayer() { return Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onScaleStart: (details) { final sceneController = _sceneController; if (sceneController == null) return; _gestureStartScale = sceneController.scale; _gestureStartYaw = sceneController.yaw; _lastGestureFocalPoint = details.focalPoint; }, onScaleUpdate: (details) { final node = _placedNode; if (node == null) return; if (details.pointerCount >= 2) { _setScale( _gestureStartScale * details.scale, ); _setYaw(_gestureStartYaw + details.rotation, absolute: true); return; } final previousPoint = _lastGestureFocalPoint; if (previousPoint == null) return; final delta = details.focalPoint - previousPoint; final position = node.position; node.position = vector.Vector3( position.x + delta.dx * 0.001, position.y, position.z + delta.dy * 0.001, ); _lastGestureFocalPoint = details.focalPoint; }, ), ); } Widget _buildTopBar(ArExperience experience) { return Positioned( left: 16, right: 16, top: 0, child: SafeArea( child: Padding( padding: const EdgeInsets.only(top: 12), child: Row( children: [ IconButton.filledTonal( tooltip: 'Kembali', onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back_rounded), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( 'Explore Lumajang', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w900, ), ), Text( experience.destinationTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white.withValues(alpha: 0.78), fontWeight: FontWeight.w600, ), ), ], ), ), _StatusPill(hasObject: _hasPlacedObject), ], ), ), ), ); } Widget _buildInstructionOverlay() { return Positioned( left: 18, right: 18, bottom: 142, child: AnimatedSwitcher( duration: const Duration(milliseconds: 220), child: Container( key: ValueKey('${_waitingForSurfaceTap}_$_hasPlacedObject'), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.58), borderRadius: BorderRadius.circular(18), border: Border.all(color: Colors.white.withValues(alpha: 0.16)), ), child: Row( children: [ Icon( _hasPlacedObject ? Icons.open_with_rounded : Icons.grid_4x4_rounded, color: const Color(0xFFB7D05A), ), const SizedBox(width: 12), Expanded( child: Text( _hasPlacedObject ? 'Geser untuk memindahkan, putar dua jari untuk rotasi, cubit untuk memperbesar atau memperkecil.' : 'Gerakkan ponsel perlahan untuk memindai permukaan, lalu ketuk bidang datar.', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w700, height: 1.35, ), ), ), ], ), ), ), ); } Widget _buildLoadingOverlay() { return Positioned.fill( child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.18)), child: const Center( child: CircularProgressIndicator(color: Color(0xFFB7D05A)), ), ), ), ); } Widget _buildControlDock() { return Positioned( right: 16, bottom: 24, child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton.small( heroTag: 'ar-reset', tooltip: 'Reset AR', onPressed: _resetScene, child: const Icon(Icons.restart_alt_rounded), ), const SizedBox(height: 12), FloatingActionButton.small( heroTag: 'ar-remove', tooltip: 'Hapus objek', onPressed: _hasPlacedObject ? _removeObject : null, child: const Icon(Icons.delete_outline_rounded), ), const SizedBox(height: 12), FloatingActionButton.small( heroTag: 'ar-rotate', tooltip: 'Putar objek', onPressed: _hasPlacedObject ? () => _rotateObject(math.pi / 8) : null, child: const Icon(Icons.rotate_90_degrees_ccw_rounded), ), const SizedBox(height: 12), FloatingActionButton.small( heroTag: 'ar-zoom-in', tooltip: 'Perbesar', onPressed: _hasPlacedObject ? () => _setScale((_sceneController?.scale ?? 0) + 0.04) : null, child: const Icon(Icons.add_rounded), ), const SizedBox(height: 12), FloatingActionButton.small( heroTag: 'ar-zoom-out', tooltip: 'Perkecil', onPressed: _hasPlacedObject ? () => _setScale((_sceneController?.scale ?? 0) - 0.04) : null, child: const Icon(Icons.remove_rounded), ), ], ), ), ); } void _rotateObject(double radians) { final node = _placedNode; if (node == null) return; _setYaw(radians); } void _setYaw(double radians, {bool absolute = false}) { final node = _placedNode; final sceneController = _sceneController; if (node == null || sceneController == null) return; final yaw = absolute ? sceneController.rotate(radians - sceneController.yaw) : sceneController.rotate(radians); node.eulerAngles = vector.Vector3(0, yaw, 0); setState(() {}); } void _setScale(double nextScale) { final node = _placedNode; final sceneController = _sceneController; if (node == null || sceneController == null) return; final scale = sceneController.setScale(nextScale); node.scale = vector.Vector3.all(scale); setState(() {}); } void _removeObject() { final anchor = _placedAnchor; if (anchor != null) { _anchorManager?.removeAnchor(anchor); } else if (_placedNode != null) { _objectManager?.removeNode(_placedNode!); } if (!mounted) return; setState(() { _placedAnchor = null; _placedNode = null; _sceneController?.markObjectRemoved(); }); } void _resetScene() { _removeObject(); _sceneController?.reset(); _showMessage( 'Tampilan AR direset. Ketuk permukaan terdeteksi untuk mulai lagi.'); } void _showMessage(String message) { if (!mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); } } class _StatusPill extends StatelessWidget { const _StatusPill({required this.hasObject}); final bool hasObject; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: hasObject ? const Color(0xFFB7D05A) : Colors.white.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(999), border: Border.all(color: Colors.white.withValues(alpha: 0.18)), ), child: Text( hasObject ? 'Tampil' : 'Memindai', style: TextStyle( color: hasObject ? const Color(0xFF0B1F33) : Colors.white, fontWeight: FontWeight.w900, fontSize: 12, ), ), ); } } class _ArErrorView extends StatelessWidget { const _ArErrorView({required this.message}); final String message; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF7F4EA), body: SafeArea( child: Center( child: Padding( padding: const EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.phonelink_erase_rounded, size: 64, color: Color(0xFFE04F3F)), const SizedBox(height: 18), Text( 'AR tidak tersedia', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, color: const Color(0xFF0B1F33), ), ), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: const Color(0xFF405466), height: 1.45, ), ), const SizedBox(height: 24), FilledButton.icon( onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back_rounded), label: const Text('Kembali ke destinasi'), ), ], ), ), ), ), ); } }