688 lines
24 KiB
Dart
688 lines
24 KiB
Dart
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<BeaconScreen> createState() => _BeaconScreenState();
|
|
}
|
|
|
|
class _BeaconScreenState extends ConsumerState<BeaconScreen> {
|
|
final MapController _mapController = MapController();
|
|
final DraggableScrollableController _sheetController = DraggableScrollableController();
|
|
|
|
List<Post> _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<void> _checkLocationPermission() async {
|
|
final status = await Permission.location.status;
|
|
if (mounted) {
|
|
setState(() => _locationPermissionGranted = status.isGranted);
|
|
if (status.isGranted) {
|
|
await _getCurrentLocation(forceCenter: !_suppressAutoCenterOnUser);
|
|
await _loadBeacons();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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<void> _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<void> _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<Color>(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<double> _pulseAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 1200),
|
|
vsync: this,
|
|
);
|
|
|
|
if (widget.isRecent) {
|
|
_pulseAnimation = Tween<double>(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<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 1500),
|
|
vsync: this,
|
|
)..repeat(reverse: false);
|
|
_animation = Tween<double>(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)],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|