feat: responsive layout, date filtering, location search, calendar fix

- Make web responsive layout use width-based mobile detection (820px)
- Add date filter chips that actually filter events by date ranges
- Custom calendar dialog with event dots on Date chip tap
- Update location search with Kerala cities and pincode display
- Fix calendar screen overflow errors and broken event indicators
- Replace thumbnail indicators with clean colored dots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 08:55:21 +05:30
parent d536d287cd
commit 4acf75902c
4 changed files with 942 additions and 579 deletions

View File

@@ -2,46 +2,92 @@
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
// 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);
/// 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',
/// Popular Kerala cities shown as chips.
static const List<String> _popularCities = [
'Thiruvananthapuram',
'Kochi',
'Kozhikode',
'Kollam',
'Thrissur',
'Kannur',
'Alappuzha',
'Palakkad',
'Malappuram',
'Kottayam',
];
List<String> _filtered = [];
bool _loadingLocation = false;
/// Searchable location database Kerala towns/cities with pincodes.
static const List<_LocationItem> _locationDb = [
_LocationItem(city: 'Thiruvananthapuram', district: 'Thiruvananthapuram', pincode: '695001'),
_LocationItem(city: 'Kazhakoottam', district: 'Thiruvananthapuram', pincode: '695582'),
_LocationItem(city: 'Neyyattinkara', district: 'Thiruvananthapuram', pincode: '695121'),
_LocationItem(city: 'Attingal', district: 'Thiruvananthapuram', pincode: '695101'),
_LocationItem(city: 'Kochi', district: 'Ernakulam', pincode: '682001'),
_LocationItem(city: 'Ernakulam', district: 'Ernakulam', pincode: '682011'),
_LocationItem(city: 'Aluva', district: 'Ernakulam', pincode: '683101'),
_LocationItem(city: 'Kakkanad', district: 'Ernakulam', pincode: '682030'),
_LocationItem(city: 'Fort Kochi', district: 'Ernakulam', pincode: '682001'),
_LocationItem(city: 'Kozhikode', district: 'Kozhikode', pincode: '673001'),
_LocationItem(city: 'Feroke', district: 'Kozhikode', pincode: '673631'),
_LocationItem(city: 'Kollam', district: 'Kollam', pincode: '691001'),
_LocationItem(city: 'Karunagappally', district: 'Kollam', pincode: '690518'),
_LocationItem(city: 'Thrissur', district: 'Thrissur', pincode: '680001'),
_LocationItem(city: 'Chavakkad', district: 'Thrissur', pincode: '680506'),
_LocationItem(city: 'Guruvayoor', district: 'Thrissur', pincode: '680101'),
_LocationItem(city: 'Irinjalakuda', district: 'Thrissur', pincode: '680121'),
_LocationItem(city: 'Kannur', district: 'Kannur', pincode: '670001'),
_LocationItem(city: 'Thalassery', district: 'Kannur', pincode: '670101'),
_LocationItem(city: 'Alappuzha', district: 'Alappuzha', pincode: '688001'),
_LocationItem(city: 'Cherthala', district: 'Alappuzha', pincode: '688524'),
_LocationItem(city: 'Palakkad', district: 'Palakkad', pincode: '678001'),
_LocationItem(city: 'Ottapalam', district: 'Palakkad', pincode: '679101'),
_LocationItem(city: 'Malappuram', district: 'Malappuram', pincode: '676505'),
_LocationItem(city: 'Manjeri', district: 'Malappuram', pincode: '676121'),
_LocationItem(city: 'Tirur', district: 'Malappuram', pincode: '676101'),
_LocationItem(city: 'Kottayam', district: 'Kottayam', pincode: '686001'),
_LocationItem(city: 'Pala', district: 'Kottayam', pincode: '686575'),
_LocationItem(city: 'Pathanamthitta', district: 'Pathanamthitta', pincode: '689645'),
_LocationItem(city: 'Idukki', district: 'Idukki', pincode: '685602'),
_LocationItem(city: 'Munnar', district: 'Idukki', pincode: '685612'),
_LocationItem(city: 'Wayanad', district: 'Wayanad', pincode: '673121'),
_LocationItem(city: 'Kalpetta', district: 'Wayanad', pincode: '673121'),
_LocationItem(city: 'Kasaragod', district: 'Kasaragod', pincode: '671121'),
_LocationItem(city: 'Whitefield', district: 'Bengaluru', pincode: '560066'),
_LocationItem(city: 'Bengaluru', district: 'Karnataka', pincode: '560001'),
];
@override
void initState() {
super.initState();
_filtered = List.from(_popularCities);
}
List<_LocationItem> _searchResults = [];
bool _showSearchResults = false;
bool _loadingLocation = false;
@override
void dispose() {
@@ -53,38 +99,42 @@ class _SearchScreenState extends State<SearchScreen> {
final ql = q.trim().toLowerCase();
setState(() {
if (ql.isEmpty) {
_filtered = List.from(_popularCities);
_showSearchResults = false;
_searchResults = [];
} else {
_filtered = _popularCities.where((c) => c.toLowerCase().contains(ql)).toList();
_showSearchResults = true;
_searchResults = _locationDb.where((loc) {
return loc.city.toLowerCase().contains(ql) ||
(loc.district?.toLowerCase().contains(ql) ?? false) ||
(loc.pincode?.contains(ql) ?? false);
}).toList();
}
});
}
void _selectAndClose(String city) {
Navigator.of(context).pop(city);
void _selectAndClose(String location) {
Navigator.of(context).pop(location);
}
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');
if (mounted) {
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) {
@@ -93,21 +143,18 @@ class _SearchScreenState extends State<SearchScreen> {
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);
if (mounted) Navigator.of(context).pop(label);
return;
}
} catch (_) {
// ignore reverse geocode failures and fallback to coordinates or simple label
}
} catch (_) {}
// fallback: return lat,lng string or simple label
Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}');
if (mounted) 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');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
Navigator.of(context).pop('Current Location');
}
} finally {
if (mounted) setState(() => _loadingLocation = false);
}
@@ -115,31 +162,22 @@ class _SearchScreenState extends State<SearchScreen> {
@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,
child: GestureDetector(
onTap: () {}, // prevent taps on sheet from closing
child: _buildSheet(context),
),
),
],
@@ -147,158 +185,173 @@ class _SearchScreenState extends State<SearchScreen> {
),
);
}
}
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)),
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)],
),
);
}
@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)),
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: 14),
),
const SizedBox(height: 16),
// 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);
},
// 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
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
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: () => _selectAndClose(loc.returnValue),
);
},
),
] 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),
),
),
),
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),
],
),
const SizedBox(height: 8),
],
),
),
),