642 lines
20 KiB
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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|