308 lines
12 KiB
Dart
308 lines
12 KiB
Dart
|
|
// lib/screens/search_screen.dart
|
||
|
|
import 'dart:ui';
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
|
||
|
|
// Location packages (add to pubspec.yaml)
|
||
|
|
// geolocator -> for permission & coordinates
|
||
|
|
// geocoding -> for reverse geocoding coordinates to a placemark
|
||
|
|
import 'package:geolocator/geolocator.dart';
|
||
|
|
import 'package:geocoding/geocoding.dart';
|
||
|
|
|
||
|
|
class SearchScreen extends StatefulWidget {
|
||
|
|
const SearchScreen({Key? key}) : super(key: key);
|
||
|
|
|
||
|
|
/// Returns a String to the caller via Navigator.pop(string).
|
||
|
|
/// Could be:
|
||
|
|
/// - a city name (e.g. "Bengaluru")
|
||
|
|
/// - 'Current Location' or a resolved locality like "Whitefield, Bengaluru"
|
||
|
|
@override
|
||
|
|
State<SearchScreen> createState() => _SearchScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SearchScreenState extends State<SearchScreen> {
|
||
|
|
final TextEditingController _ctrl = TextEditingController();
|
||
|
|
final List<String> _popularCities = const [
|
||
|
|
'Delhi NCR',
|
||
|
|
'Mumbai',
|
||
|
|
'Kolkata',
|
||
|
|
'Bengaluru',
|
||
|
|
'Hyderabad',
|
||
|
|
'Chandigarh',
|
||
|
|
'Pune',
|
||
|
|
'Chennai',
|
||
|
|
'Ahmedabad',
|
||
|
|
'Jaipur',
|
||
|
|
];
|
||
|
|
|
||
|
|
List<String> _filtered = [];
|
||
|
|
bool _loadingLocation = false;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_filtered = List.from(_popularCities);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_ctrl.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onQueryChanged(String q) {
|
||
|
|
final ql = q.trim().toLowerCase();
|
||
|
|
setState(() {
|
||
|
|
if (ql.isEmpty) {
|
||
|
|
_filtered = List.from(_popularCities);
|
||
|
|
} else {
|
||
|
|
_filtered = _popularCities.where((c) => c.toLowerCase().contains(ql)).toList();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
void _selectAndClose(String city) {
|
||
|
|
Navigator.of(context).pop(city);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _useCurrentLocation() async {
|
||
|
|
setState(() => _loadingLocation = true);
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Check / request permission
|
||
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
||
|
|
if (permission == LocationPermission.denied) {
|
||
|
|
permission = await Geolocator.requestPermission();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
|
||
|
|
// Can't get permission — inform user and return a fallback label
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Location permission denied')));
|
||
|
|
Navigator.of(context).pop('Current Location');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get current position
|
||
|
|
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
||
|
|
|
||
|
|
// Try reverse geocoding to get a readable place name
|
||
|
|
try {
|
||
|
|
final placemarks = await placemarkFromCoordinates(pos.latitude, pos.longitude);
|
||
|
|
if (placemarks.isNotEmpty) {
|
||
|
|
final p = placemarks.first;
|
||
|
|
final parts = <String>[];
|
||
|
|
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
|
||
|
|
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
||
|
|
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
|
||
|
|
if ((p.administrativeArea ?? '').isNotEmpty) parts.add(p.administrativeArea!);
|
||
|
|
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
|
||
|
|
Navigator.of(context).pop(label);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
// ignore reverse geocode failures and fallback to coordinates or simple label
|
||
|
|
}
|
||
|
|
|
||
|
|
// fallback: return lat,lng string or simple label
|
||
|
|
Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}');
|
||
|
|
} catch (e) {
|
||
|
|
// If any error, fallback to simple label
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
|
||
|
|
Navigator.of(context).pop('Current Location');
|
||
|
|
} finally {
|
||
|
|
if (mounted) setState(() => _loadingLocation = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
// Full-screen transparent Scaffold so the BackdropFilter can blur underlying UI.
|
||
|
|
return Scaffold(
|
||
|
|
backgroundColor: Colors.transparent,
|
||
|
|
body: GestureDetector(
|
||
|
|
// Tap outside sheet to dismiss
|
||
|
|
onTap: () => Navigator.of(context).pop(),
|
||
|
|
behavior: HitTestBehavior.opaque,
|
||
|
|
child: Stack(
|
||
|
|
children: [
|
||
|
|
// BackdropFilter + dim overlay
|
||
|
|
BackdropFilter(
|
||
|
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||
|
|
child: Container(color: Colors.black.withOpacity(0.16)),
|
||
|
|
),
|
||
|
|
|
||
|
|
// Align bottom: the sheet content
|
||
|
|
Align(
|
||
|
|
alignment: Alignment.bottomCenter,
|
||
|
|
child: _SearchBottomSheet(
|
||
|
|
controller: _ctrl,
|
||
|
|
filteredCities: _filtered,
|
||
|
|
onCityTap: (city) => _selectAndClose(city),
|
||
|
|
onQueryChanged: _onQueryChanged,
|
||
|
|
onUseCurrentLocation: _useCurrentLocation,
|
||
|
|
loadingLocation: _loadingLocation,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SearchBottomSheet extends StatelessWidget {
|
||
|
|
final TextEditingController controller;
|
||
|
|
final List<String> filteredCities;
|
||
|
|
final void Function(String) onCityTap;
|
||
|
|
final void Function(String) onQueryChanged;
|
||
|
|
final Future<void> Function() onUseCurrentLocation;
|
||
|
|
final bool loadingLocation;
|
||
|
|
|
||
|
|
const _SearchBottomSheet({
|
||
|
|
Key? key,
|
||
|
|
required this.controller,
|
||
|
|
required this.filteredCities,
|
||
|
|
required this.onCityTap,
|
||
|
|
required this.onQueryChanged,
|
||
|
|
required this.onUseCurrentLocation,
|
||
|
|
required this.loadingLocation,
|
||
|
|
}) : super(key: key);
|
||
|
|
|
||
|
|
Widget _cityChip(String name, BuildContext context, void Function() onTap) {
|
||
|
|
return InkWell(
|
||
|
|
onTap: onTap,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
child: Container(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
|
|
decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
|
||
|
|
child: Text(name, style: const TextStyle(color: Colors.black87)),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
// The bottom sheet container
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.all(0),
|
||
|
|
child: Container(
|
||
|
|
// limit height so it looks like a sheet
|
||
|
|
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.78, minHeight: 240),
|
||
|
|
decoration: const BoxDecoration(
|
||
|
|
color: Colors.white,
|
||
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||
|
|
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 12)],
|
||
|
|
),
|
||
|
|
child: SafeArea(
|
||
|
|
top: false,
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
// center drag handle
|
||
|
|
Center(
|
||
|
|
child: Container(width: 48, height: 6, decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(6))),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
|
||
|
|
// Header
|
||
|
|
Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
const Text('Set Your Location', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||
|
|
// Close button (inside sheet)
|
||
|
|
InkWell(
|
||
|
|
onTap: () => Navigator.of(context).pop(),
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
child: Container(width: 40, height: 40, decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(10)), child: const Icon(Icons.close, color: Colors.black54)),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
|
||
|
|
// Search field (now functional)
|
||
|
|
Container(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
|
|
decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
const Icon(Icons.search, color: Colors.black38),
|
||
|
|
const SizedBox(width: 10),
|
||
|
|
Expanded(
|
||
|
|
child: TextField(
|
||
|
|
controller: controller,
|
||
|
|
decoration: const InputDecoration(hintText: 'Search city, area or locality', border: InputBorder.none),
|
||
|
|
textInputAction: TextInputAction.search,
|
||
|
|
onChanged: onQueryChanged,
|
||
|
|
onSubmitted: (v) {
|
||
|
|
final q = v.trim();
|
||
|
|
if (q.isEmpty) return;
|
||
|
|
// If there's an exact/first match in filteredCities, pick it; otherwise pass the raw query.
|
||
|
|
final match = filteredCities.isNotEmpty ? filteredCities.first : null;
|
||
|
|
Navigator.of(context).pop(match ?? q);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (controller.text.isNotEmpty)
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.clear),
|
||
|
|
onPressed: () {
|
||
|
|
controller.clear();
|
||
|
|
onQueryChanged('');
|
||
|
|
},
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 14),
|
||
|
|
|
||
|
|
// Use current location button
|
||
|
|
ElevatedButton(
|
||
|
|
onPressed: loadingLocation ? null : () => onUseCurrentLocation(),
|
||
|
|
style: ElevatedButton.styleFrom(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||
|
|
backgroundColor: const Color(0xFF0B63D6),
|
||
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
const Icon(Icons.my_location, color: Colors.white),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(child: Text(loadingLocation ? 'Detecting location...' : 'Use Current Location', style: const TextStyle(color: Colors.white))),
|
||
|
|
if (loadingLocation)
|
||
|
|
const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||
|
|
else
|
||
|
|
const Icon(Icons.chevron_right, color: Colors.white),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 18),
|
||
|
|
|
||
|
|
// Popular cities
|
||
|
|
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold)),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
|
||
|
|
Wrap(
|
||
|
|
spacing: 12,
|
||
|
|
runSpacing: 12,
|
||
|
|
children: [
|
||
|
|
for (final city in filteredCities.take(8)) _cityChip(city, context, () => onCityTap(city)),
|
||
|
|
// if filteredCities is empty show empty state
|
||
|
|
if (filteredCities.isEmpty)
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
|
|
child: Text('No suggestions', style: TextStyle(color: Colors.grey[600])),
|
||
|
|
)
|
||
|
|
],
|
||
|
|
),
|
||
|
|
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|