Files
Eventify-frontend/lib/screens/search_screen.dart
Sicherhaven a32ead31c2 fix: LOC — location filter never applied to event API calls
Root cause: SearchScreen popped with a plain city label string; the
pincode was available in search results but discarded. home_screen only
saved the display label to prefs and never updated the 'pincode' key,
so every API call always sent {pincode:'all'} regardless of selection.

GPS path had the same issue — lat/lng were obtained but thrown away
after reverse-geocoding; only the label was passed back.

Fix:
- SearchScreen now pops with Map<String,dynamic> {label, pincode,
  lat?, lng?} instead of a plain String
- Pincode results return their pincode; GPS returns actual coordinates;
  popular city chips look up the first matching pincode from the
  Kerala pincodes DB (fallback 'all' if not found)
- home_screen._openLocationSearch() saves pincode + lat/lng to prefs
  and updates _pincode/_userLat/_userLng in state
- home_screen._loadUserDataAndEvents() prefers getEventsByLocation
  (haversine) when GPS coords are saved, falls back to getEventsByPincode
- EventsService gains getEventsByLocation(lat, lng) which sends
  latitude/longitude/radius_km to the existing Django haversine endpoint
  and auto-expands radius 10→25→50→100 km until ≥ 6 events found
2026-04-04 18:43:02 +05:30

384 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/screens/search_screen.dart
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/utils/error_utils.dart';
// Location packages
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';
/// Data model for a location suggestion (city + optional pincode).
class _LocationItem {
final String city;
final String? district;
final String? pincode;
const _LocationItem({required this.city, this.district, this.pincode});
String get displayTitle => district != null && district!.isNotEmpty ? '$city, $district' : city;
String get displaySubtitle => pincode ?? '';
/// What gets returned to the caller (city name + optional district for display in pill).
String get returnValue => displayTitle;
}
class SearchScreen extends StatefulWidget {
const SearchScreen({Key? key}) : super(key: key);
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final TextEditingController _ctrl = TextEditingController();
/// Popular Kerala cities shown as chips.
static const List<String> _popularCities = [
'Thiruvananthapuram',
'Kochi',
'Kozhikode',
'Kollam',
'Thrissur',
'Kannur',
'Alappuzha',
'Palakkad',
'Malappuram',
'Kottayam',
];
/// Searchable location database loaded from assets/data/kerala_pincodes.json.
List<_LocationItem> _locationDb = [];
bool _pinsLoaded = false;
List<_LocationItem> _searchResults = [];
bool _showSearchResults = false;
bool _loadingLocation = false;
@override
void initState() {
super.initState();
_loadKeralaData();
}
Future<void> _loadKeralaData() async {
if (_pinsLoaded) return;
try {
final jsonStr = await rootBundle.loadString('assets/data/kerala_pincodes.json');
final List<dynamic> list = jsonDecode(jsonStr);
final loaded = list.map((e) => _LocationItem(
city: e['city'] as String,
district: e['district'] as String?,
pincode: e['pincode'] as String?,
)).toList();
if (mounted) {
setState(() {
_locationDb = loaded;
_pinsLoaded = true;
});
}
} catch (_) {
// fallback: keep empty list, search won't crash
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onQueryChanged(String q) {
final ql = q.trim().toLowerCase();
setState(() {
if (ql.isEmpty) {
_showSearchResults = false;
_searchResults = [];
} else {
_showSearchResults = true;
_searchResults = _locationDb.where((loc) {
return loc.city.toLowerCase().contains(ql) ||
(loc.district?.toLowerCase().contains(ql) ?? false) ||
(loc.pincode?.contains(ql) ?? false);
}).toList();
}
});
}
/// Pop with a structured result so home_screen can update both the display
/// label AND the pincode / GPS coordinates used for API filtering.
void _selectWithPincode(String label, {String? pincode}) {
Navigator.of(context).pop(<String, dynamic>{
'label': label,
'pincode': pincode ?? 'all',
});
}
void _selectAndClose(String location) {
// Legacy path for plain strings — wrap in structured map.
// Tries to look up a pincode from the database for the given city name.
final match = _locationDb.cast<_LocationItem?>().firstWhere(
(loc) => loc!.city.toLowerCase() == location.toLowerCase() ||
loc.displayTitle.toLowerCase() == location.toLowerCase(),
orElse: () => null,
);
_selectWithPincode(location, pincode: match?.pincode);
}
Future<void> _useCurrentLocation() async {
setState(() => _loadingLocation = true);
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Location permission denied')));
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
}
return;
}
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
String label = 'Current Location';
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 (parts.isNotEmpty) label = parts.join(', ');
}
} catch (_) {}
if (mounted) {
// Return lat/lng so home_screen can use haversine filtering
Navigator.of(context).pop(<String, dynamic>{
'label': label,
'pincode': 'all',
'lat': pos.latitude,
'lng': pos.longitude,
});
}
return;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
}
} finally {
if (mounted) setState(() => _loadingLocation = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: GestureDetector(
onTap: () => Navigator.of(context).pop(),
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
RepaintBoundary(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(color: Colors.black.withOpacity(0.16)),
),
),
Align(
alignment: Alignment.bottomCenter,
child: GestureDetector(
onTap: () {}, // prevent taps on sheet from closing
child: _buildSheet(context),
),
),
],
),
),
);
}
Widget _buildSheet(BuildContext context) {
return Container(
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: [
// Header row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Set Your Location', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E))),
InkWell(
onTap: () => Navigator.of(context).pop(),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: const Color(0xFF1A1A2E), borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.close, color: Colors.white, size: 20),
),
),
],
),
const SizedBox(height: 16),
// Search field
Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(14),
border: _ctrl.text.isNotEmpty ? Border.all(color: const Color(0xFF2563EB).withOpacity(0.5), width: 1.5) : null,
),
child: Row(
children: [
Icon(Icons.search, color: Colors.grey[500]),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _ctrl,
decoration: const InputDecoration(
hintText: 'Search city, area or locality',
hintStyle: TextStyle(color: Color(0xFF9CA3AF)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
textInputAction: TextInputAction.search,
onChanged: _onQueryChanged,
onSubmitted: (v) {
final q = v.trim();
if (q.isEmpty) return;
if (_searchResults.isNotEmpty) {
_selectAndClose(_searchResults.first.returnValue);
} else {
_selectAndClose(q);
}
},
),
),
if (_ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_ctrl.clear();
_onQueryChanged('');
},
),
],
),
),
const SizedBox(height: 16),
// Use current location
Material(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: _loadingLocation ? null : () => _useCurrentLocation(),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: Row(
children: [
const Icon(Icons.my_location, color: Colors.white, size: 22),
const SizedBox(width: 12),
Expanded(
child: Text(
_loadingLocation ? 'Detecting location...' : 'Use Current Location',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 15),
),
),
if (_loadingLocation)
const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
else
const Icon(Icons.chevron_right, color: Colors.white),
],
),
),
),
),
const SizedBox(height: 20),
// Search results or Popular Cities
if (_showSearchResults) ...[
if (_searchResults.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(child: Text('No results found', style: TextStyle(color: Colors.grey[500]))),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: ListView.separated(
shrinkWrap: false,
physics: const ClampingScrollPhysics(),
itemCount: _searchResults.length,
separatorBuilder: (_, __) => Divider(color: Colors.grey[200], height: 1),
itemBuilder: (ctx, idx) {
final loc = _searchResults[idx];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
leading: Icon(Icons.location_on_outlined, color: Colors.grey[400], size: 24),
title: Text(
loc.displayTitle,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15, color: Color(0xFF1A1A2E)),
),
subtitle: loc.pincode != null && loc.pincode!.isNotEmpty
? Text(loc.pincode!, style: TextStyle(color: Colors.grey[500], fontSize: 13))
: null,
onTap: () => _selectWithPincode(loc.displayTitle, pincode: loc.pincode),
);
},
),
),
] else ...[
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF1A1A2E))),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final city in _popularCities)
InkWell(
onTap: () => _selectAndClose(city),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(color: const Color(0xFFF3F4F6), borderRadius: BorderRadius.circular(12)),
child: Text(
city.length > 16 ? '${city.substring(0, 14)}...' : city,
style: const TextStyle(color: Color(0xFF374151), fontWeight: FontWeight.w500, fontSize: 14),
),
),
),
],
),
],
const SizedBox(height: 8),
],
),
),
),
);
}
}