616 lines
20 KiB
Dart
616 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
import 'package:flutter_compass/flutter_compass.dart';
|
|
import 'package:lba/res/colors.dart';
|
|
import '../services/location_service.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
class HintCameraWidget extends StatefulWidget {
|
|
final double targetLatitude;
|
|
final double targetLongitude;
|
|
final String hintDescription;
|
|
final VoidCallback onHintFound;
|
|
final VoidCallback onClose;
|
|
|
|
const HintCameraWidget({
|
|
super.key,
|
|
required this.targetLatitude,
|
|
required this.targetLongitude,
|
|
required this.hintDescription,
|
|
required this.onHintFound,
|
|
required this.onClose,
|
|
});
|
|
|
|
@override
|
|
State<HintCameraWidget> createState() => _HintCameraWidgetState();
|
|
}
|
|
|
|
class _HintCameraWidgetState extends State<HintCameraWidget>
|
|
with TickerProviderStateMixin {
|
|
MobileScannerController? _controller;
|
|
StreamSubscription<CompassEvent>? _compassSubscription;
|
|
Timer? _locationTimer;
|
|
|
|
final MapController _miniMapController = MapController();
|
|
|
|
double _deviceHeading = 0.0;
|
|
double _bearingToTarget = 0.0;
|
|
double _distanceToTarget = 0.0;
|
|
Position? _currentPosition;
|
|
bool _isNearTarget = false;
|
|
bool _isMapVisible = false;
|
|
|
|
late AnimationController _pulseController;
|
|
late AnimationController _rotationController;
|
|
late AnimationController _scanController;
|
|
late Animation<double> _pulseAnimation;
|
|
late Animation<double> _rotationAnimation;
|
|
late Animation<double> _scanAnimation;
|
|
|
|
final double _cameraHorizontalFov = 60.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeCamera();
|
|
_setupAnimations();
|
|
_startCompassListening();
|
|
_startLocationUpdates();
|
|
}
|
|
|
|
void _initializeCamera() {
|
|
_controller = MobileScannerController(
|
|
detectionSpeed: DetectionSpeed.noDuplicates,
|
|
facing: CameraFacing.back,
|
|
);
|
|
}
|
|
|
|
void _setupAnimations() {
|
|
_pulseController = AnimationController(
|
|
duration: const Duration(milliseconds: 2000),
|
|
vsync: this,
|
|
)..repeat(reverse: true);
|
|
_rotationController = AnimationController(
|
|
duration: const Duration(seconds: 8),
|
|
vsync: this,
|
|
)..repeat();
|
|
_scanController = AnimationController(
|
|
duration: const Duration(milliseconds: 3500),
|
|
vsync: this,
|
|
)..repeat();
|
|
_pulseAnimation = Tween<double>(begin: 0.95, end: 1.05).animate(
|
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
|
);
|
|
_rotationAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(parent: _rotationController, curve: Curves.linear),
|
|
);
|
|
_scanAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(parent: _scanController, curve: Curves.easeInOutSine),
|
|
);
|
|
}
|
|
|
|
void _startCompassListening() {
|
|
_compassSubscription = FlutterCompass.events?.listen((CompassEvent event) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_deviceHeading = event.heading ?? 0.0;
|
|
});
|
|
});
|
|
}
|
|
|
|
void _startLocationUpdates() {
|
|
_locationTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
|
if (!mounted) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
final position = await LocationService.getCurrentPosition();
|
|
if (position != null && mounted) {
|
|
final bearing = LocationService.getBearing(
|
|
position.latitude,
|
|
position.longitude,
|
|
widget.targetLatitude,
|
|
widget.targetLongitude,
|
|
);
|
|
final distance = LocationService.calculateDistance(
|
|
position.latitude,
|
|
position.longitude,
|
|
widget.targetLatitude,
|
|
widget.targetLongitude,
|
|
);
|
|
final isNear = distance <= 10.0;
|
|
setState(() {
|
|
_currentPosition = position;
|
|
_bearingToTarget = bearing;
|
|
_distanceToTarget = distance;
|
|
_isNearTarget = isNear;
|
|
});
|
|
if (_isMapVisible) {
|
|
_updateMiniMapCamera();
|
|
}
|
|
if (isNear) {
|
|
widget.onHintFound();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void _updateMiniMapCamera() {
|
|
if (_currentPosition != null) {
|
|
final userLocation = LatLng(_currentPosition!.latitude, _currentPosition!.longitude);
|
|
final targetLocation = LatLng(widget.targetLatitude, widget.targetLongitude);
|
|
_miniMapController.fitCamera(
|
|
CameraFit.bounds(
|
|
bounds: LatLngBounds(userLocation, targetLocation),
|
|
padding: const EdgeInsets.all(60.0),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _toggleMapVisibility() {
|
|
setState(() {
|
|
_isMapVisible = !_isMapVisible;
|
|
if (_isMapVisible) {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
_updateMiniMapCamera();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_compassSubscription?.cancel();
|
|
_locationTimer?.cancel();
|
|
_controller?.dispose();
|
|
_pulseController.dispose();
|
|
_rotationController.dispose();
|
|
_scanController.dispose();
|
|
_miniMapController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Widget _buildMiniMap() {
|
|
if (_currentPosition == null) return const SizedBox.shrink();
|
|
final userLocation = LatLng(_currentPosition!.latitude, _currentPosition!.longitude);
|
|
final targetLocation = LatLng(widget.targetLatitude, widget.targetLongitude);
|
|
return AnimatedPositioned(
|
|
duration: const Duration(milliseconds: 500),
|
|
curve: Curves.easeInOutCubic,
|
|
bottom: _isMapVisible ? 100 : -220,
|
|
left: 20,
|
|
right: 20,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(25),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
|
child: Container(
|
|
height: 200,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(25),
|
|
border: Border.all(color: Colors.white.withOpacity(0.2)),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: FlutterMap(
|
|
mapController: _miniMapController,
|
|
options: MapOptions(
|
|
initialCenter: userLocation,
|
|
initialZoom: 15,
|
|
interactionOptions: const InteractionOptions(flags: InteractiveFlag.none),
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
subdomains: const ['a', 'b', 'c', 'd'],
|
|
),
|
|
MarkerLayer(
|
|
markers: [
|
|
Marker(
|
|
point: userLocation,
|
|
width: 80,
|
|
height: 80,
|
|
child: PulsingMarker(isUser: true),
|
|
),
|
|
Marker(
|
|
point: targetLocation,
|
|
width: 80,
|
|
height: 80,
|
|
child: PulsingMarker(isUser: false),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
onPressed: widget.onClose,
|
|
icon: const Icon(Icons.close_rounded, color: Colors.white, size: 28),
|
|
),
|
|
title: const Text(
|
|
'AR HINT',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 2,
|
|
fontFamily: 'monospace'),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
if (_controller != null)
|
|
MobileScanner(controller: _controller!)
|
|
else
|
|
Container(
|
|
color: Colors.black,
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
),
|
|
),
|
|
BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 0.5, sigmaY: 0.5),
|
|
child: Container(color: Colors.black.withOpacity(0.1)),
|
|
),
|
|
_buildAROverlay(),
|
|
_buildMiniMap(),
|
|
],
|
|
),
|
|
floatingActionButton: _buildMapFAB(),
|
|
);
|
|
}
|
|
|
|
Widget _buildMapFAB() {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
decoration: BoxDecoration(
|
|
color: _isMapVisible
|
|
? AppColors.primary.withOpacity(0.5)
|
|
: Colors.white.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(color: Colors.white.withOpacity(0.2)),
|
|
),
|
|
child: FloatingActionButton(
|
|
onPressed: _toggleMapVisibility,
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
highlightElevation: 0,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 300),
|
|
transitionBuilder: (child, animation) {
|
|
return ScaleTransition(scale: animation, child: child);
|
|
},
|
|
child: Icon(
|
|
_isMapVisible ? Icons.map_outlined : Icons.map,
|
|
key: ValueKey<bool>(_isMapVisible),
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAROverlay() {
|
|
if (_currentPosition == null) {
|
|
return const Center(
|
|
child: Text(
|
|
'ACQUIRING GPS SIGNAL...',
|
|
style: TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 1.5,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double normalizeAngle(double angle) => (angle + 180) % 360 - 180;
|
|
double deviceHeadingNormalized = normalizeAngle(_deviceHeading);
|
|
double bearingToTargetNormalized = normalizeAngle(_bearingToTarget);
|
|
double angleDifference = bearingToTargetNormalized - deviceHeadingNormalized;
|
|
|
|
if (angleDifference > 180) angleDifference -= 360;
|
|
if (angleDifference < -180) angleDifference += 360;
|
|
|
|
bool isVisible = angleDifference.abs() < (_cameraHorizontalFov / 2);
|
|
|
|
return Stack(
|
|
children: [
|
|
CustomPaint(
|
|
painter: HudPainter(animationValue: _pulseAnimation.value),
|
|
size: Size.infinite,
|
|
),
|
|
if (!isVisible)
|
|
_buildOffScreenIndicator(angleDifference)
|
|
else
|
|
_buildOnScreenHint(angleDifference),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildOffScreenIndicator(double angleDifference) {
|
|
final isLeft = angleDifference < 0;
|
|
return Align(
|
|
alignment: isLeft ? Alignment.centerLeft : Alignment.centerRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
|
child: ScaleTransition(
|
|
scale: _pulseAnimation,
|
|
child: Icon(
|
|
isLeft ? Icons.arrow_back_ios_new_rounded : Icons.arrow_forward_ios_rounded,
|
|
color: AppColors.primary.withOpacity(0.8),
|
|
size: 48,
|
|
shadows: [
|
|
Shadow(color: AppColors.primary.withOpacity(0.5), blurRadius: 15),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOnScreenHint(double angleDifference) {
|
|
final double screenX = angleDifference / (_cameraHorizontalFov / 2);
|
|
final double distance = _distanceToTarget;
|
|
|
|
return AnimatedPositioned(
|
|
duration: const Duration(milliseconds: 100),
|
|
curve: Curves.easeOut,
|
|
top: 0,
|
|
bottom: 0,
|
|
left: (1 + screenX) * (MediaQuery.of(context).size.width / 2) - 100,
|
|
child: AnimatedBuilder(
|
|
animation: Listenable.merge([_pulseAnimation, _rotationController, _scanAnimation]),
|
|
builder: (context, child) {
|
|
return SizedBox(
|
|
width: 200,
|
|
height: 200,
|
|
child: CustomPaint(
|
|
painter: AdvancedTargetPainter(
|
|
isNear: _isNearTarget,
|
|
distance: distance,
|
|
pulseValue: _pulseAnimation.value,
|
|
rotationValue: _rotationController.value,
|
|
scanValue: _scanAnimation.value,
|
|
),
|
|
child: Center(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(100),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
child: Container(
|
|
width: 140,
|
|
height: 140,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.05),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: (_isNearTarget
|
|
? const Color(0xFF00E676)
|
|
: const Color(0xFF2196F3))
|
|
.withOpacity(0.4),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
_isNearTarget ? "TARGET ACQUIRED" : "SCANNING",
|
|
style: TextStyle(
|
|
color: _isNearTarget
|
|
? const Color(0xFF00E676)
|
|
: const Color(0xFF2196F3),
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
letterSpacing: 1.5,
|
|
fontFamily: 'monospace'
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'${distance.toStringAsFixed(1)}m',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 32,
|
|
letterSpacing: 0.5,
|
|
fontFamily: 'monospace'
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PulsingMarker extends StatefulWidget {
|
|
final bool isUser;
|
|
const PulsingMarker({super.key, required this.isUser});
|
|
|
|
@override
|
|
_PulsingMarkerState createState() => _PulsingMarkerState();
|
|
}
|
|
|
|
class _PulsingMarkerState extends State<PulsingMarker>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 2),
|
|
)..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = widget.isUser ? Colors.blue.shade400 : Colors.yellow.shade700;
|
|
return FadeTransition(
|
|
opacity: Tween<double>(begin: 1.0, end: 0.3).animate(_animationController),
|
|
child: ScaleTransition(
|
|
scale: Tween<double>(begin: 0.5, end: 1.0).animate(_animationController),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: color.withOpacity(0.5),
|
|
border: Border.all(color: color, width: 2),
|
|
),
|
|
child: Icon(
|
|
widget.isUser ? Icons.my_location : Icons.star,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class HudPainter extends CustomPainter {
|
|
final double animationValue;
|
|
HudPainter({required this.animationValue});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = AppColors.primary.withOpacity(0.3 + (animationValue * 0.2))
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2.0;
|
|
|
|
const bracketSize = 30.0;
|
|
const margin = 20.0;
|
|
|
|
canvas.drawLine(Offset(margin, margin), Offset(margin + bracketSize, margin), paint);
|
|
canvas.drawLine(Offset(margin, margin), Offset(margin, margin + bracketSize), paint);
|
|
canvas.drawLine(Offset(size.width - margin, margin), Offset(size.width - margin - bracketSize, margin), paint);
|
|
canvas.drawLine(Offset(size.width - margin, margin), Offset(size.width - margin, margin + bracketSize), paint);
|
|
canvas.drawLine(Offset(margin, size.height - margin), Offset(margin + bracketSize, size.height - margin), paint);
|
|
canvas.drawLine(Offset(margin, size.height - margin), Offset(margin, size.height - margin - bracketSize), paint);
|
|
canvas.drawLine(Offset(size.width - margin, size.height - margin), Offset(size.width - margin - bracketSize, size.height - margin), paint);
|
|
canvas.drawLine(Offset(size.width - margin, size.height - margin), Offset(size.width - margin, size.height - margin - bracketSize), paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant HudPainter oldDelegate) => oldDelegate.animationValue != animationValue;
|
|
}
|
|
|
|
class AdvancedTargetPainter extends CustomPainter {
|
|
final bool isNear;
|
|
final double distance;
|
|
final double pulseValue;
|
|
final double rotationValue;
|
|
final double scanValue;
|
|
|
|
AdvancedTargetPainter({
|
|
required this.isNear,
|
|
required this.distance,
|
|
required this.pulseValue,
|
|
required this.rotationValue,
|
|
required this.scanValue,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final paint = Paint()..style = PaintingStyle.stroke;
|
|
final primaryColor = isNear ? const Color(0xFF00E676) : const Color(0xFF2196F3);
|
|
final baseRadius = math.max(60.0, math.min(85.0, 100 - (distance / 2)));
|
|
|
|
// Outer glow
|
|
paint
|
|
..color = primaryColor.withOpacity(0.1 * pulseValue)
|
|
..strokeWidth = 20.0
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10);
|
|
canvas.drawCircle(center, baseRadius, paint);
|
|
|
|
// Main rotating rings
|
|
paint
|
|
..color = primaryColor.withOpacity(0.8)
|
|
..strokeWidth = 2.5
|
|
..maskFilter = null;
|
|
|
|
canvas.drawArc(Rect.fromCircle(center: center, radius: baseRadius),
|
|
rotationValue * 2 * math.pi, 2.5, false, paint);
|
|
canvas.drawArc(Rect.fromCircle(center: center, radius: baseRadius),
|
|
rotationValue * 2 * math.pi + 3, 2.5, false, paint);
|
|
|
|
// Inner static ring
|
|
paint
|
|
..color = primaryColor.withOpacity(0.4)
|
|
..strokeWidth = 1.5;
|
|
canvas.drawCircle(center, baseRadius - 15, paint);
|
|
|
|
// Scanning arc
|
|
if (!isNear) {
|
|
final sweepAngle = scanValue * 2 * math.pi;
|
|
paint
|
|
..color = primaryColor
|
|
..strokeWidth = 4.0;
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: baseRadius - 8),
|
|
sweepAngle,
|
|
math.pi / 4,
|
|
false,
|
|
paint,
|
|
);
|
|
}
|
|
|
|
// Success animation
|
|
if (isNear) {
|
|
paint
|
|
..color = primaryColor.withOpacity(1.0 - pulseValue)
|
|
..strokeWidth = 4.0
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawCircle(center, baseRadius + (pulseValue * 15), paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
|
} |