import 'dart:async'; import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:geolocator/geolocator.dart'; import 'package:lba/gen/assets.gen.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; final Duration? remainingTime; final String? cardTitle; const HintCameraWidget({ super.key, required this.targetLatitude, required this.targetLongitude, required this.hintDescription, required this.onHintFound, required this.onClose, this.remainingTime, this.cardTitle, }); @override State createState() => _HintCameraWidgetState(); } class _HintCameraWidgetState extends State with TickerProviderStateMixin { MobileScannerController? _controller; StreamSubscription? _compassSubscription; Timer? _locationTimer; Timer? _countdownTimer; final MapController _miniMapController = MapController(); double _deviceHeading = 0.0; double _bearingToTarget = 0.0; double _distanceToTarget = 0.0; Position? _currentPosition; bool _isNearTarget = false; bool _isMapVisible = false; Duration _currentRemainingTime = Duration.zero; late AnimationController _rotationController; late AnimationController _scanController; late Animation _rotationAnimation; late Animation _scanAnimation; final double _cameraHorizontalFov = 60.0; @override void initState() { super.initState(); _initializeCamera(); _setupAnimations(); _startCompassListening(); _startLocationUpdates(); _initializeTimer(); } void _initializeTimer() { if (widget.remainingTime != null) { _currentRemainingTime = widget.remainingTime!.inSeconds == 0 ? const Duration(hours: 12) : widget.remainingTime!; _startCountdownTimer(); } } void _startCountdownTimer() { _countdownTimer?.cancel(); _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (mounted) { setState(() { if (_currentRemainingTime.inSeconds > 0) { _currentRemainingTime = Duration(seconds: _currentRemainingTime.inSeconds - 1); } else { timer.cancel(); } }); } }); } void _initializeCamera() { _controller = MobileScannerController( detectionSpeed: DetectionSpeed.noDuplicates, facing: CameraFacing.back, ); } void _setupAnimations() { _rotationController = AnimationController( duration: const Duration(seconds: 8), vsync: this, )..repeat(); _scanController = AnimationController( duration: const Duration(milliseconds: 3500), vsync: this, )..repeat(); _rotationAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _rotationController, curve: Curves.linear), ); _scanAnimation = Tween(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), ), ); } } String _formatDistance(double distance) { if (distance >= 100) { return '${distance.round()}m'; } else { return '${distance.toStringAsFixed(1)}m'; } } double _getDistanceFontSize(double distance) { if (distance >= 10000) { return 20; } else if (distance >= 1000) { return 26; } else { return 32; } } void _toggleMapVisibility() { setState(() { _isMapVisible = !_isMapVisible; if (_isMapVisible) { Future.delayed(const Duration(milliseconds: 100), () { _updateMiniMapCamera(); }); } }); } @override void dispose() { _compassSubscription?.cancel(); _locationTimer?.cancel(); _countdownTimer?.cancel(); _controller?.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/rastertiles/voyager/{z}/{x}/{y}{r}.png', subdomains: const ['a', 'b', 'c', 'd'], ), MarkerLayer( markers: [ Marker( point: userLocation, width: 40, height: 40, child: PulsingMarker(isUser: true), ), Marker( point: targetLocation, width: 40, height: 40, child: PulsingMarker(isUser: false), ), ], ), ], ), ), ), ), ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, extendBodyBehindAppBar: true, appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight + 10), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withOpacity(0.8), Colors.black.withOpacity(0.4), Colors.transparent, ], ), ), child: ClipRRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: AppBar( backgroundColor: Colors.transparent, elevation: 0, toolbarHeight: kToolbarHeight + 10, leading: Container( margin: const EdgeInsets.all(8), decoration: BoxDecoration( gradient: LinearGradient( colors: [ Colors.white.withOpacity(0.15), Colors.white.withOpacity(0.05), ], ), shape: BoxShape.circle, border: Border.all( color: Colors.white.withOpacity(0.3), width: 1.5, ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 8, spreadRadius: 1, ), ], ), child: IconButton( onPressed: widget.onClose, icon: SvgPicture.asset(Assets.icons.back.path, color: Colors.white,), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ), title: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration( gradient: LinearGradient( colors: [ AppColors.primary.withOpacity(0.3), AppColors.primary.withOpacity(0.1), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(25), border: Border.all( color: AppColors.primary.withOpacity(0.4), width: 1.5, ), boxShadow: [ BoxShadow( color: AppColors.primary.withOpacity(0.2), blurRadius: 15, spreadRadius: 2, ), BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 8, offset: const Offset(0, 4), ), ], ), child: const Text( 'AR Hint', style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700, letterSpacing: 1.2, fontFamily: 'monospace' ), ), ), centerTitle: true, actions: [ Container( margin: const EdgeInsets.all(10), decoration: BoxDecoration( gradient: LinearGradient( colors: [ Colors.white.withOpacity(0.15), Colors.white.withOpacity(0.05), ], ), shape: BoxShape.circle, border: Border.all( color: Colors.white.withOpacity(0.3), width: 1.5, ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.3), blurRadius: 8, spreadRadius: 1, ), ], ), child: IconButton( onPressed: () { }, icon: SvgPicture.asset(Assets.icons.infoCircle.path, color: Colors.white,), ), ), ], ), ), ), ), ), 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)), ), if (widget.remainingTime != null) _buildTimerWidget(), _buildAROverlay(), _buildMiniMap(), ], ), floatingActionButton: _buildMapFAB(), ); } Widget _buildTimerWidget() { if (widget.remainingTime == null) return const SizedBox.shrink(); final Duration timeToShow = _currentRemainingTime.inSeconds <= 0 ? (widget.remainingTime!.inSeconds == 0 ? const Duration(hours: 12) : Duration.zero) : _currentRemainingTime; final hours = timeToShow.inHours; final minutes = timeToShow.inMinutes % 60; final seconds = timeToShow.inSeconds % 60; final isUrgent = timeToShow.inMinutes < 5 && timeToShow.inHours == 0; final isExpired = _currentRemainingTime.inSeconds <= 0 && widget.remainingTime!.inSeconds > 0; return Positioned( top: kToolbarHeight + 60, right: 16, child: ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.transparent, borderRadius: BorderRadius.circular(20), border: Border.all( color: Colors.white.withOpacity(0.2), width: 0.5, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( isExpired ? 'Expired' : hours > 0 ? '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}' : '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}', style: TextStyle( color: isExpired ? const Color(0xFFFF3B30) : Colors.white, fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), ], ), ), ), ), ); } 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(_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: 1.0), 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: 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([_rotationController, _scanAnimation]), builder: (context, child) { return SizedBox( width: 200, height: 200, child: CustomPaint( painter: AdvancedTargetPainter( isNear: _isNearTarget, distance: distance, pulseValue: 1.0, 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( _formatDistance(distance), style: TextStyle( color: Colors.white, fontWeight: FontWeight.w800, fontSize: _getDistanceFontSize(distance), 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 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(begin: 1.0, end: 0.3).animate(_animationController), child: ScaleTransition( scale: Tween(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: Padding( padding: const EdgeInsets.all(8.0), child: SvgPicture.asset( widget.isUser ? Assets.icons.gps.path : Assets.icons.locationCross.path, color: Colors.black, width: 10, ), ), ), ), ); } } 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))); paint ..color = primaryColor.withOpacity(0.1 * pulseValue) ..strokeWidth = 20.0 ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10); canvas.drawCircle(center, baseRadius, paint); 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); paint ..color = primaryColor.withOpacity(0.4) ..strokeWidth = 1.5; canvas.drawCircle(center, baseRadius - 15, paint); 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, ); } 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; }