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

642 lines
20 KiB
Dart

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<ArViewScreen> createState() => _ArViewScreenState();
}
class _ArViewScreenState extends State<ArViewScreen> {
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<void> _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<void> _markSessionReadyAfterWarmUp() async {
await Future<void>.delayed(const Duration(milliseconds: 1600));
if (!mounted) return;
setState(() {
_isInitializing = false;
_isSessionReadyForPlacement = true;
});
}
Future<void> _onPlaneOrPointTapped(
List<ARHitTestResult> 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<void>.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<ARHitTestResult> 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'),
),
],
),
),
),
),
);
}
}