335 lines
9.6 KiB
Dart
335 lines
9.6 KiB
Dart
import 'dart:async';
|
|
|
|
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/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_node.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:vector_math/vector_math_64.dart' as vector;
|
|
|
|
import '../services/ar_model_asset_service.dart';
|
|
|
|
class ARPage extends StatefulWidget {
|
|
const ARPage({
|
|
super.key,
|
|
required this.namaWisata,
|
|
required this.modelPath,
|
|
});
|
|
|
|
final String namaWisata;
|
|
final String modelPath;
|
|
|
|
@override
|
|
State<ARPage> createState() => _ARPageState();
|
|
}
|
|
|
|
class _ARPageState extends State<ARPage> {
|
|
final ArModelAssetService _modelAssetService = const ArModelAssetService();
|
|
ARSessionManager? _arSessionManager;
|
|
ARObjectManager? _arObjectManager;
|
|
ARNode? _modelNode;
|
|
|
|
bool _sedangMemuat = true;
|
|
bool _sedangMenambahkanModel = false;
|
|
bool _modelSudahTampil = false;
|
|
String? _pesanError;
|
|
|
|
double _skalaModel = 0.28;
|
|
double _rotasiY = 0;
|
|
vector.Vector3 _posisiModel = vector.Vector3(0, -0.18, -1.25);
|
|
|
|
double _skalaAwalGestur = 0.28;
|
|
double _rotasiAwalGestur = 0;
|
|
Offset? _titikGeserTerakhir;
|
|
|
|
@override
|
|
void dispose() {
|
|
final node = _modelNode;
|
|
final objectManager = _arObjectManager;
|
|
if (node != null && objectManager != null) {
|
|
unawaited(objectManager.removeNode(node));
|
|
}
|
|
_arSessionManager?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Stack(
|
|
children: [
|
|
ARView(
|
|
onARViewCreated: _onARViewCreated,
|
|
planeDetectionConfig: PlaneDetectionConfig.horizontal,
|
|
permissionPromptDescription:
|
|
'Izin kamera diperlukan untuk menampilkan wisata dalam AR.',
|
|
permissionPromptButtonText: 'Izinkan Kamera',
|
|
),
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onScaleStart: _mulaiGestur,
|
|
onScaleUpdate: _ubahGestur,
|
|
),
|
|
),
|
|
_barAtas(),
|
|
if (_sedangMemuat) _indikatorMemuat(),
|
|
if (_pesanError != null) _panelError(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onARViewCreated(
|
|
ARSessionManager sessionManager,
|
|
ARObjectManager objectManager,
|
|
ARAnchorManager anchorManager,
|
|
ARLocationManager locationManager,
|
|
) {
|
|
_arSessionManager = sessionManager;
|
|
_arObjectManager = objectManager;
|
|
|
|
_arSessionManager!.onInitialize(
|
|
showAnimatedGuide: false,
|
|
showFeaturePoints: false,
|
|
showPlanes: false,
|
|
showWorldOrigin: false,
|
|
handleTaps: false,
|
|
handlePans: false,
|
|
handleRotation: false,
|
|
);
|
|
_arObjectManager!.onInitialize();
|
|
_arSessionManager!.onError = _handleArError;
|
|
|
|
unawaited(_tampilkanModelDiDepanKamera());
|
|
}
|
|
|
|
Future<void> _tampilkanModelDiDepanKamera() async {
|
|
if (_sedangMenambahkanModel) return;
|
|
_sedangMenambahkanModel = true;
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 1500));
|
|
if (!mounted || _modelSudahTampil || _arObjectManager == null) {
|
|
_sedangMenambahkanModel = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final localModelPath =
|
|
await _modelAssetService.prepareGlbForAr(widget.modelPath);
|
|
if (!mounted) return;
|
|
|
|
final node = ARNode(
|
|
type: NodeType.fileSystemAppFolderGLB,
|
|
uri: localModelPath,
|
|
position: _posisiModel,
|
|
scale: vector.Vector3.all(_skalaModel),
|
|
eulerAngles: vector.Vector3(0, _rotasiY, 0),
|
|
);
|
|
|
|
final berhasil = await _arObjectManager!.addNode(node) ?? false;
|
|
if (!mounted) return;
|
|
|
|
if (berhasil) {
|
|
setState(() {
|
|
_modelNode = node;
|
|
_modelSudahTampil = true;
|
|
_sedangMemuat = false;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_sedangMemuat = false;
|
|
_pesanError =
|
|
'Model 3D belum bisa dimuat. Pastikan jalur aset sudah benar: ${widget.modelPath}';
|
|
});
|
|
}
|
|
} on ArModelAssetException catch (exception) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_sedangMemuat = false;
|
|
_pesanError = exception.message;
|
|
});
|
|
} catch (error) {
|
|
debugPrint('[AR Model] Gagal memuat ${widget.modelPath}: $error');
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_sedangMemuat = false;
|
|
_pesanError =
|
|
'Model 3D belum bisa dimuat. Pastikan jalur aset sudah benar: ${widget.modelPath}';
|
|
});
|
|
} finally {
|
|
_sedangMenambahkanModel = false;
|
|
}
|
|
}
|
|
|
|
void _handleArError(String error) {
|
|
debugPrint('[AR Model] Native AR error: $error');
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_sedangMemuat = false;
|
|
_pesanError = error;
|
|
});
|
|
}
|
|
|
|
void _mulaiGestur(ScaleStartDetails detail) {
|
|
_skalaAwalGestur = _skalaModel;
|
|
_rotasiAwalGestur = _rotasiY;
|
|
_titikGeserTerakhir = detail.focalPoint;
|
|
}
|
|
|
|
void _ubahGestur(ScaleUpdateDetails detail) {
|
|
final node = _modelNode;
|
|
if (node == null) return;
|
|
|
|
if (detail.pointerCount >= 2) {
|
|
_skalaModel =
|
|
(_skalaAwalGestur * detail.scale).clamp(0.08, 1.25).toDouble();
|
|
_rotasiY = _rotasiAwalGestur + detail.rotation;
|
|
node.scale = vector.Vector3.all(_skalaModel);
|
|
node.eulerAngles = vector.Vector3(0, _rotasiY, 0);
|
|
return;
|
|
}
|
|
|
|
final titikTerakhir = _titikGeserTerakhir;
|
|
if (titikTerakhir == null) {
|
|
_titikGeserTerakhir = detail.focalPoint;
|
|
return;
|
|
}
|
|
|
|
final delta = detail.focalPoint - titikTerakhir;
|
|
_titikGeserTerakhir = detail.focalPoint;
|
|
|
|
// Geser tipis di sumbu X dan Y agar objek bisa dipindahkan tanpa meloncat.
|
|
_posisiModel = vector.Vector3(
|
|
(_posisiModel.x + delta.dx * 0.0012).clamp(-1.2, 1.2).toDouble(),
|
|
(_posisiModel.y - delta.dy * 0.0012).clamp(-0.8, 0.8).toDouble(),
|
|
_posisiModel.z,
|
|
);
|
|
node.position = _posisiModel;
|
|
}
|
|
|
|
Widget _barAtas() {
|
|
return Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
|
child: Row(
|
|
children: [
|
|
IconButton.filledTonal(
|
|
onPressed: () => Navigator.pop(context),
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
widget.namaWisata,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
shadows: [
|
|
Shadow(
|
|
blurRadius: 8,
|
|
color: Colors.black87,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
IconButton.filledTonal(
|
|
tooltip: 'Atur Ulang Model',
|
|
onPressed: _resetModel,
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _indikatorMemuat() {
|
|
return Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: Center(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.62),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
|
|
),
|
|
child: const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(color: Colors.white),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Memuat model AR...',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _panelError() {
|
|
return Positioned(
|
|
left: 20,
|
|
right: 20,
|
|
bottom: 28,
|
|
child: SafeArea(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.80),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
_pesanError!,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _resetModel() {
|
|
final node = _modelNode;
|
|
if (node == null) return;
|
|
|
|
setState(() {
|
|
_skalaModel = 0.28;
|
|
_rotasiY = 0;
|
|
_posisiModel = vector.Vector3(0, -0.18, -1.25);
|
|
node.position = _posisiModel;
|
|
node.scale = vector.Vector3.all(_skalaModel);
|
|
node.eulerAngles = vector.Vector3(0, _rotasiY, 0);
|
|
});
|
|
}
|
|
}
|