641 lines
21 KiB
Dart
641 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:location/location.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:lba/res/colors.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:lba/gen/assets.gen.dart';
|
|
import 'package:lba/widgets/app_snackbar.dart';
|
|
import 'dart:math' as math;
|
|
|
|
class LocationRadiusScreen extends StatefulWidget {
|
|
const LocationRadiusScreen({super.key});
|
|
|
|
@override
|
|
State<LocationRadiusScreen> createState() => _LocationRadiusScreenState();
|
|
}
|
|
|
|
class _LocationRadiusScreenState extends State<LocationRadiusScreen>
|
|
with TickerProviderStateMixin {
|
|
final MapController _mapController = MapController();
|
|
LatLng _currentCenter = const LatLng(25.1972, 55.2744); // Default: Dubai
|
|
double _radiusInKm = 1.0;
|
|
bool _isCustomRadius = false;
|
|
bool _isLoadingLocation = true;
|
|
bool _mapLoadError = false;
|
|
int _currentTileProvider = 0;
|
|
|
|
Location location = Location();
|
|
late bool _serviceEnabled;
|
|
late PermissionStatus _permissionGranted;
|
|
late LocationData _locationData;
|
|
|
|
late AnimationController _animationController;
|
|
late List<Animation<double>> _animations;
|
|
|
|
final List<Map<String, dynamic>> _tileProviders = [
|
|
{
|
|
'name': 'Stamen Terrain',
|
|
'lightUrl':
|
|
'https://stamen-tiles-{s}.a.ssl.fastly.net/terrain/{z}/{x}/{y}{r}.png',
|
|
'darkUrl':
|
|
'https://stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}{r}.png',
|
|
'subdomains': ['a', 'b', 'c', 'd'],
|
|
},
|
|
{
|
|
'name': 'CartoDB',
|
|
'lightUrl':
|
|
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
|
'darkUrl':
|
|
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
'subdomains': ['a', 'b', 'c', 'd'],
|
|
},
|
|
{
|
|
'name': 'Google Maps',
|
|
'lightUrl': 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
|
|
'darkUrl': 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
|
'subdomains': ['mt0', 'mt1', 'mt2', 'mt3'],
|
|
},
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1200),
|
|
);
|
|
|
|
_animations = List.generate(6, (index) {
|
|
final double startTime = (index / 6) * 0.5;
|
|
final double endTime = (startTime + 0.6).clamp(0.0, 1.0);
|
|
return Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Interval(startTime, endTime, curve: Curves.easeOutCubic),
|
|
),
|
|
);
|
|
});
|
|
|
|
_currentTileProvider =
|
|
_currentTileProvider.clamp(0, _tileProviders.length - 1);
|
|
|
|
_determinePosition();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _determinePosition() async {
|
|
setState(() => _isLoadingLocation = true);
|
|
|
|
try {
|
|
_serviceEnabled = await location.serviceEnabled();
|
|
if (!_serviceEnabled) {
|
|
_serviceEnabled = await location.requestService();
|
|
if (!_serviceEnabled) {
|
|
_showLocationMessage(
|
|
'Location services are disabled. Please enable it in settings.',
|
|
type: SnackBarType.warning);
|
|
setState(() => _isLoadingLocation = false);
|
|
_animationController.forward();
|
|
return;
|
|
}
|
|
}
|
|
|
|
_permissionGranted = await location.hasPermission();
|
|
if (_permissionGranted == PermissionStatus.denied) {
|
|
_permissionGranted = await location.requestPermission();
|
|
if (_permissionGranted != PermissionStatus.granted) {
|
|
_showLocationMessage('Location permissions are denied.',
|
|
type: SnackBarType.error);
|
|
setState(() => _isLoadingLocation = false);
|
|
_animationController.forward();
|
|
return;
|
|
}
|
|
}
|
|
|
|
_locationData = await location.getLocation();
|
|
|
|
setState(() {
|
|
_currentCenter =
|
|
LatLng(_locationData.latitude!, _locationData.longitude!);
|
|
_isLoadingLocation = false;
|
|
});
|
|
|
|
_showLocationMessage('Location found successfully!',
|
|
type: SnackBarType.success);
|
|
|
|
_mapController.move(_currentCenter, 14.0);
|
|
} catch (e) {
|
|
_showLocationMessage('Unable to get your location. Using default location.',
|
|
type: SnackBarType.info);
|
|
setState(() {
|
|
_isLoadingLocation = false;
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
_animationController.forward();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showLocationMessage(String message,
|
|
{SnackBarType type = SnackBarType.error}) {
|
|
if (mounted) {
|
|
AppSnackBar.show(
|
|
context: context,
|
|
message: message,
|
|
type: type,
|
|
duration: const Duration(seconds: 3),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildAnimatedWidget(Widget child, int index) {
|
|
return FadeTransition(
|
|
opacity: _animations[index],
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0.0, 0.5),
|
|
end: Offset.zero,
|
|
).animate(_animations[index]),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppColors.scaffoldBackground,
|
|
appBar: AppBar(
|
|
title: Text(
|
|
"Choose a location to see what's available",
|
|
style: TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontWeight: FontWeight.normal,
|
|
fontSize: 15),
|
|
),
|
|
backgroundColor: AppColors.surface,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: SvgPicture.asset(
|
|
Assets.icons.arrowLeft.path,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
_mapLoadError
|
|
? _buildMapErrorFallback()
|
|
: Container(
|
|
padding: const EdgeInsets.only(bottom: 240),
|
|
child: FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: _currentCenter,
|
|
initialZoom: 14.0,
|
|
onPositionChanged: (position, hasGesture) {
|
|
if (hasGesture) {
|
|
setState(() {
|
|
_currentCenter = position.center;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate:
|
|
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
|
userAgentPackageName: 'com.example.lba',
|
|
subdomains: const ['a', 'b', 'c', 'd'],
|
|
additionalOptions: const {
|
|
'attribution':
|
|
'© CARTO © OpenStreetMap contributors',
|
|
},
|
|
),
|
|
CircleLayer(
|
|
circles: [
|
|
CircleMarker(
|
|
point: _currentCenter,
|
|
radius: _radiusInKm * 1000,
|
|
useRadiusInMeter: true,
|
|
color: _isCustomRadius
|
|
? AppColors.primary.withOpacity(0.15)
|
|
: AppColors.offerTimer.withOpacity(0.15),
|
|
borderColor: Colors.transparent,
|
|
borderStrokeWidth: 0,
|
|
),
|
|
],
|
|
),
|
|
MarkerLayer(
|
|
markers: [
|
|
Marker(
|
|
width: _calculateCirclePixelSize(),
|
|
height: _calculateCirclePixelSize(),
|
|
point: _currentCenter,
|
|
child: CustomPaint(
|
|
painter: DashedCirclePainter(
|
|
color: _isCustomRadius
|
|
? AppColors.primary
|
|
: AppColors.offerTimer,
|
|
strokeWidth: 3.0,
|
|
dashLength: 9.0,
|
|
gapLength: 5.0,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_isLoadingLocation)
|
|
Container(
|
|
color: AppColors.scaffoldBackground.withOpacity(0.8),
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
|
),
|
|
),
|
|
),
|
|
_buildControlsPanel(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMapErrorFallback() {
|
|
return Container(
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.cardBackground,
|
|
border: Border.all(color: AppColors.divider),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 40),
|
|
Container(
|
|
width: 120,
|
|
height: 120,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color:
|
|
_isCustomRadius ? AppColors.primary : AppColors.offerTimer,
|
|
width: 4,
|
|
),
|
|
color: (_isCustomRadius
|
|
? AppColors.primary
|
|
: AppColors.offerTimer)
|
|
.withOpacity(0.08),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.shadowColor,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: _isCustomRadius
|
|
? AppColors.primary
|
|
: AppColors.offerTimer,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.shadowColor,
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
Icons.location_on,
|
|
color: AppColors.surface,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${_radiusInKm.toStringAsFixed(1)} km',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
Text(
|
|
'radius',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildControlsPanel() {
|
|
return Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0, 1),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.easeOutCubic,
|
|
)),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16.0),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.shadowColor,
|
|
blurRadius: 10,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildAnimatedWidget(
|
|
Text(
|
|
"Preset Radius Cards",
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: AppColors.nearbyPopuphint,
|
|
),
|
|
),
|
|
0,
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (_mapLoadError)
|
|
_buildAnimatedWidget(
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.errorColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: AppColors.errorColor.withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.warning_amber_rounded,
|
|
color: AppColors.errorColor,
|
|
size: 16,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Map tiles blocked. Using fallback display.',
|
|
style: TextStyle(
|
|
color: AppColors.errorColor,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
1,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildAnimatedWidget(
|
|
Row(
|
|
children: [
|
|
_buildRadiusToggleChip(
|
|
"Nearby (1 km)", !_isCustomRadius, () {
|
|
setState(() {
|
|
_isCustomRadius = false;
|
|
_radiusInKm = 1.0;
|
|
});
|
|
}),
|
|
const SizedBox(width: 8),
|
|
_buildRadiusToggleChip("Custom Radius", _isCustomRadius,
|
|
() {
|
|
setState(() {
|
|
_isCustomRadius = true;
|
|
if (_radiusInKm == 1.0) _radiusInKm = 5.0;
|
|
});
|
|
}),
|
|
],
|
|
),
|
|
2,
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildAnimatedWidget(
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Slider(
|
|
value: _radiusInKm,
|
|
min: 1.0,
|
|
max: 25.0,
|
|
divisions: 24,
|
|
activeColor: _isCustomRadius
|
|
? AppColors.primary
|
|
: AppColors.divider,
|
|
inactiveColor: AppColors.divider.withOpacity(0.5),
|
|
onChanged: _isCustomRadius
|
|
? (value) {
|
|
setState(() {
|
|
_radiusInKm = value;
|
|
});
|
|
}
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Text(
|
|
"${_radiusInKm.toStringAsFixed(1)} km",
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
3,
|
|
),
|
|
_buildAnimatedWidget(
|
|
Center(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
TextButton.icon(
|
|
onPressed: _determinePosition,
|
|
icon: SvgPicture.asset(
|
|
Assets.icons.currentLoc.path,
|
|
color: AppColors.primary,
|
|
),
|
|
label: Text(
|
|
"Use my current location",
|
|
style: TextStyle(
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
4,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildAnimatedWidget(
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.confirmButton,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(27),
|
|
),
|
|
),
|
|
child: Text(
|
|
'Apply',
|
|
style: TextStyle(
|
|
color: AppColors.surface,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
5,
|
|
),
|
|
const SizedBox(
|
|
height: 30,
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRadiusToggleChip(
|
|
String label, bool isSelected, VoidCallback onTap) {
|
|
return ChoiceChip(
|
|
label: Text(label),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
if (selected) onTap();
|
|
},
|
|
selectedColor: isSelected && !_isCustomRadius
|
|
? AppColors.primary
|
|
: AppColors.primary,
|
|
backgroundColor: AppColors.cardBackground,
|
|
labelStyle: TextStyle(
|
|
color: isSelected ? AppColors.nearbyPopup : AppColors.textSecondary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(
|
|
color: isSelected ? Colors.transparent : AppColors.divider,
|
|
),
|
|
),
|
|
showCheckmark: false,
|
|
);
|
|
}
|
|
|
|
double _calculateCirclePixelSize() {
|
|
try {
|
|
final double currentZoom = _mapController.camera.zoom;
|
|
final double pixelsPerMeter = 256 *
|
|
math.pow(2, currentZoom) /
|
|
(2 *
|
|
math.pi *
|
|
6378137 *
|
|
math.cos(_currentCenter.latitude * math.pi / 180));
|
|
final double radiusInMeters = _radiusInKm * 1000;
|
|
final double radiusInPixels = radiusInMeters * pixelsPerMeter;
|
|
return radiusInPixels * 2;
|
|
} catch (e) {
|
|
const double pixelsPerKmAtZoom14 = 100.0;
|
|
return _radiusInKm * pixelsPerKmAtZoom14 * 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
class DashedCirclePainter extends CustomPainter {
|
|
final Color color;
|
|
final double strokeWidth;
|
|
final double dashLength;
|
|
final double gapLength;
|
|
|
|
DashedCirclePainter({
|
|
required this.color,
|
|
required this.strokeWidth,
|
|
required this.dashLength,
|
|
required this.gapLength,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Paint paint = Paint()
|
|
..color = color
|
|
..strokeWidth = strokeWidth
|
|
..style = PaintingStyle.stroke;
|
|
|
|
final double radius = (size.width - strokeWidth) / 2;
|
|
final Offset center = Offset(size.width / 2, size.height / 2);
|
|
|
|
final double circumference = 2 * math.pi * radius;
|
|
final double totalDashLength = dashLength + gapLength;
|
|
final int dashCount = (circumference / totalDashLength).floor();
|
|
|
|
for (int i = 0; i < dashCount; i++) {
|
|
final double startAngle = (i * totalDashLength / radius);
|
|
final double sweepAngle = dashLength / radius;
|
|
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: radius),
|
|
startAngle,
|
|
sweepAngle,
|
|
false,
|
|
paint,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
|
} |