import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/api_provider.dart'; import '../../models/post.dart'; import 'beacon_detail_screen.dart'; import 'create_beacon_sheet.dart'; import '../../theme/tokens.dart'; class BeaconScreen extends ConsumerStatefulWidget { final LatLng? initialMapCenter; const BeaconScreen({super.key, this.initialMapCenter}); @override ConsumerState createState() => _BeaconScreenState(); } class _BeaconScreenState extends ConsumerState { final MapController _mapController = MapController(); final DraggableScrollableController _sheetController = DraggableScrollableController(); List _beacons = []; bool _isLoading = false; bool _isLoadingLocation = false; late LatLng _mapCenter; LatLng? _userLocation; bool _locationPermissionGranted = false; double _currentZoom = 14.0; bool _suppressAutoCenterOnUser = false; @override void initState() { super.initState(); _mapCenter = widget.initialMapCenter ?? const LatLng(37.7749, -122.4194); _suppressAutoCenterOnUser = widget.initialMapCenter != null; if (widget.initialMapCenter != null) { _loadBeacons(center: widget.initialMapCenter); } _checkLocationPermission(); } Future _checkLocationPermission() async { final status = await Permission.location.status; if (mounted) { setState(() => _locationPermissionGranted = status.isGranted); if (status.isGranted) { await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser); await _loadBeacons(); } } } Future _requestLocationPermission() async { setState(() => _isLoadingLocation = true); try { final status = await Permission.location.request(); setState(() => _locationPermissionGranted = status.isGranted); if (status.isGranted) { await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser); await _loadBeacons(); } else { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Location access is required to show nearby incidents.')), ); } } finally { if (mounted) setState(() => _isLoadingLocation = false); } } Future _getCurrentLocation({bool forceCenter = false}) async { if (!_locationPermissionGranted) return; setState(() => _isLoadingLocation = true); try { final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); if (mounted) { setState(() { _userLocation = LatLng(position.latitude, position.longitude); if (forceCenter || !_suppressAutoCenterOnUser) { _mapController.move(_userLocation!, _currentZoom); _suppressAutoCenterOnUser = false; } }); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not get location: $e'))); } } finally { if (mounted) setState(() => _isLoadingLocation = false); } } Future _loadBeacons({LatLng? center}) async { final target = center ?? _userLocation ?? _mapCenter; setState(() => _isLoading = true); try { final apiService = ref.read(apiServiceProvider); final beacons = await apiService.fetchNearbyBeacons( lat: target.latitude, long: target.longitude, radius: 16000, ); if (mounted) setState(() { _beacons = beacons; _isLoading = false; }); } catch (e) { if (mounted) setState(() => _isLoading = false); } } void _onMapPositionChanged(MapCamera camera, bool hasGesture) { _mapCenter = camera.center; _currentZoom = camera.zoom; if (hasGesture) _loadBeacons(center: _mapCenter); } void _onMarkerTap(Post post) { Navigator.of(context).push(MaterialPageRoute( builder: (context) => BeaconDetailScreen(beaconPost: post), )); } void _onCreateBeacon() { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (context) => CreateBeaconSheet( centerLat: _mapCenter.latitude, centerLong: _mapCenter.longitude, onBeaconCreated: (post) { setState(() => _beacons.add(post)); _loadBeacons(); }, ), ); } // ─── Severity summary helpers ───────────────── int get _criticalCount => _beacons.where((b) => b.severity == 'critical').length; int get _highCount => _beacons.where((b) => b.severity == 'high').length; int get _mediumCount => _beacons.where((b) => b.severity == 'medium').length; String get _safetyStatusText { final total = _beacons.length; if (total == 0) return 'No incidents nearby'; final critical = _criticalCount; final high = _highCount; if (critical > 0) return '$critical critical incident${critical > 1 ? 's' : ''} nearby'; if (high > 0) return '$high danger alert${high > 1 ? 's' : ''} nearby'; return '$total incident${total > 1 ? 's' : ''} nearby'; } Color get _safetyStatusColor { if (_criticalCount > 0) return SojornColors.destructive; if (_highCount > 0) return const Color(0xFFFF5722); if (_mediumCount > 0) return SojornColors.nsfwWarningIcon; return const Color(0xFF4CAF50); } @override Widget build(BuildContext context) { if (!_locationPermissionGranted) { return Scaffold(body: _buildLocationPermissionOverlay(context)); } return Scaffold( body: Stack( children: [ // Full-screen dark map _buildMap(), // Top bar overlay _buildTopBar(context), // Draggable bottom sheet — the "Radar" overlay DraggableScrollableSheet( controller: _sheetController, initialChildSize: 0.12, minChildSize: 0.12, maxChildSize: 0.75, snap: true, snapSizes: const [0.12, 0.45, 0.75], builder: (context, scrollController) { return Container( decoration: BoxDecoration( color: const Color(0xFF1A1A2E), borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), boxShadow: [ BoxShadow( color: SojornColors.overlayDark, blurRadius: 20, offset: const Offset(0, -4), ), ], ), child: CustomScrollView( controller: scrollController, slivers: [ // Handle + safety status (collapsed view) SliverToBoxAdapter(child: _buildSheetHeader()), // Incident list (expanded view) if (_beacons.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(32), child: Center( child: Column( children: [ Icon(Icons.shield, color: const Color(0xFF4CAF50).withValues(alpha: 0.5), size: 48), const SizedBox(height: 12), Text('All clear in your area', style: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.6), fontSize: 14)), ], ), ), ), ) else SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildIncidentCard(_beacons[index]), childCount: _beacons.length, ), ), // Bottom padding const SliverToBoxAdapter(child: SizedBox(height: 40)), ], ), ); }, ), // Safety FAB — high contrast report button Positioned( bottom: 100, right: 16, child: _buildSafetyFab(), ), ], ), ); } Widget _buildSheetHeader() { return GestureDetector( onTap: () { final currentSize = _sheetController.size; if (currentSize < 0.3) { _sheetController.animateTo(0.45, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } else { _sheetController.animateTo(0.12, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } }, child: Container( padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), child: Column( children: [ // Drag handle Container( width: 40, height: 4, decoration: BoxDecoration( color: SojornColors.basicWhite.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 12), // Safety status row Row( children: [ Container( width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, color: _safetyStatusColor, boxShadow: [BoxShadow(color: _safetyStatusColor.withValues(alpha: 0.5), blurRadius: 6)], ), ), const SizedBox(width: 10), Expanded( child: Text(_safetyStatusText, style: const TextStyle(color: SojornColors.basicWhite, fontSize: 15, fontWeight: FontWeight.w600)), ), if (_isLoading) SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite.withValues(alpha: 0.54))), const SizedBox(width: 8), Text('${_beacons.length}', style: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.5), fontSize: 13)), Icon(Icons.keyboard_arrow_up, color: SojornColors.basicWhite.withValues(alpha: 0.4), size: 20), ], ), ], ), ), ); } Widget _buildIncidentCard(Post post) { final beacon = post.toBeacon(); final severityColor = beacon.pinColor; final isRecent = beacon.isRecent; return GestureDetector( onTap: () => _onMarkerTap(post), child: Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFF16213E), borderRadius: BorderRadius.circular(12), border: Border.all( color: isRecent ? severityColor.withValues(alpha: 0.6) : SojornColors.basicWhite.withValues(alpha: 0.08), ), ), child: Row( children: [ // Severity indicator Container( width: 40, height: 40, decoration: BoxDecoration( color: severityColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), child: Icon(beacon.beaconType.icon, color: severityColor, size: 22), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text(beacon.beaconType.displayName, style: const TextStyle(color: SojornColors.basicWhite, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis), ), if (isRecent) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: SojornColors.destructive.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: const Text('LIVE', style: TextStyle(color: SojornColors.destructive, fontSize: 9, fontWeight: FontWeight.bold)), ), ], ), const SizedBox(height: 3), Text(beacon.body, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.6), fontSize: 12)), const SizedBox(height: 4), Row( children: [ Icon(Icons.schedule, size: 11, color: SojornColors.basicWhite.withValues(alpha: 0.4)), const SizedBox(width: 3), Text(beacon.getTimeAgo(), style: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.4), fontSize: 11)), const SizedBox(width: 10), Icon(Icons.location_on, size: 11, color: SojornColors.basicWhite.withValues(alpha: 0.4)), const SizedBox(width: 3), Text(beacon.getFormattedDistance(), style: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.4), fontSize: 11)), const Spacer(), Icon(Icons.visibility, size: 11, color: SojornColors.basicWhite.withValues(alpha: 0.4)), const SizedBox(width: 3), Text('${beacon.verificationCount}', style: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.4), fontSize: 11)), ], ), ], ), ), ], ), ), ); } Widget _buildMap() { return FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: _mapCenter, initialZoom: _currentZoom, onPositionChanged: _onMapPositionChanged, minZoom: 3.0, maxZoom: 19.0, ), children: [ // Dark mode tiles for tactical feel TileLayer( urlTemplate: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', subdomains: const ['a', 'b', 'c', 'd'], userAgentPackageName: 'com.sojorn.app', retinaMode: RetinaMode.isHighDensity(context), ), MarkerLayer( markers: [ ..._beacons.map((beacon) => _createMarker(beacon)), if (_locationPermissionGranted && _userLocation != null) _createUserLocationMarker(), ], ), ], ); } Widget _buildTopBar(BuildContext context) { return Positioned( top: 0, left: 0, right: 0, child: SafeArea( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [const Color(0xCC000000), SojornColors.transparent], ), ), child: Row( children: [ IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.arrow_back, color: SojornColors.basicWhite), style: IconButton.styleFrom(backgroundColor: const Color(0x42000000)), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Beacon Network', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: SojornColors.basicWhite, fontWeight: FontWeight.bold)), Text('${_beacons.length} incident${_beacons.length != 1 ? 's' : ''} nearby', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: SojornColors.basicWhite.withValues(alpha: 0.7))), ], ), ), if (_isLoading || _isLoadingLocation) const Padding( padding: EdgeInsets.only(right: 8), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(SojornColors.basicWhite))), ), IconButton( onPressed: _isLoadingLocation ? null : () => _getCurrentLocation(forceCenter: true), icon: const Icon(Icons.my_location, color: SojornColors.basicWhite), style: IconButton.styleFrom(backgroundColor: const Color(0x42000000)), ), IconButton( onPressed: () => _loadBeacons(), icon: const Icon(Icons.refresh, color: SojornColors.basicWhite), style: IconButton.styleFrom(backgroundColor: const Color(0x42000000)), ), ], ), ), ), ); } Widget _buildSafetyFab() { return FloatingActionButton( heroTag: 'safety_report', onPressed: _onCreateBeacon, backgroundColor: SojornColors.destructive, foregroundColor: SojornColors.basicWhite, elevation: 8, child: const Icon(Icons.warning_rounded, size: 28), ); } Widget _buildLocationPermissionOverlay(BuildContext context) { return Container( color: const Color(0xFF0F0F23), child: Center( child: Container( margin: const EdgeInsets.all(32), padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: const Color(0xFF1A1A2E), borderRadius: BorderRadius.circular(16), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.shield, size: 64, color: SojornColors.destructive), const SizedBox(height: 24), Text('Enable Location', style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: SojornColors.basicWhite), textAlign: TextAlign.center), const SizedBox(height: 16), Text('Location access is required to show safety alerts in your area.', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: SojornColors.basicWhite.withValues(alpha: 0.7)), textAlign: TextAlign.center), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _isLoadingLocation ? null : _requestLocationPermission, icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_on), label: const Text('Allow Location'), style: ElevatedButton.styleFrom(backgroundColor: SojornColors.destructive, foregroundColor: SojornColors.basicWhite), ), ], ), ), ), ); } Marker _createMarker(Post post) { final beacon = post.toBeacon(); final severityColor = beacon.pinColor; final typeIcon = beacon.beaconType.icon; final isRecent = beacon.isRecent; final fallbackBase = _userLocation ?? _mapCenter; final markerPosition = (post.latitude != null && post.longitude != null) ? LatLng(post.latitude!, post.longitude!) : LatLng( fallbackBase.latitude + ((post.distanceMeters ?? 0) / 111000), fallbackBase.longitude + ((post.distanceMeters ?? 0) / 111000), ); return Marker( point: markerPosition, width: 48, height: 48, child: GestureDetector( onTap: () => _onMarkerTap(post), child: _SeverityMarker( color: severityColor, icon: typeIcon, isRecent: isRecent, ), ), ); } Marker _createUserLocationMarker() { return Marker( point: _userLocation!, width: 40, height: 40, child: _PulsingLocationIndicator(), ); } } // ─── Severity-colored Marker with pulse for recent ───────────── class _SeverityMarker extends StatefulWidget { final Color color; final IconData icon; final bool isRecent; const _SeverityMarker({ required this.color, required this.icon, required this.isRecent, }); @override State<_SeverityMarker> createState() => _SeverityMarkerState(); } class _SeverityMarkerState extends State<_SeverityMarker> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _pulseAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1200), vsync: this, ); if (widget.isRecent) { _pulseAnimation = Tween(begin: 0.85, end: 1.15).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); _controller.repeat(reverse: true); } else { _pulseAnimation = const AlwaysStoppedAnimation(1.0); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _pulseAnimation.value, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: widget.color, boxShadow: [ BoxShadow( color: widget.color.withValues(alpha: widget.isRecent ? 0.6 : 0.3), blurRadius: widget.isRecent ? 12 : 6, spreadRadius: widget.isRecent ? 3 : 0, ), ], ), child: Icon(widget.icon, color: Colors.white, size: 26), ), ); }, ); } } // ─── Pulsing user location dot ───────────── class _PulsingLocationIndicator extends StatefulWidget { @override State<_PulsingLocationIndicator> createState() => _PulsingLocationIndicatorState(); } class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, )..repeat(reverse: false); _animation = Tween(begin: 0.4, end: 1.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return Stack( alignment: Alignment.center, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue.withValues(alpha: 0.3 * (1 - _animation.value)), border: Border.all(color: Colors.blue.withValues(alpha: 0.5 * (1 - _animation.value)), width: 2), ), ), Container( width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue, border: Border.all(color: Colors.white, width: 3), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, spreadRadius: 1)], ), ), ], ); }, ); } }