- Desktop sidebar (262px, blue gradient, white pill nav), topbar (search + bell + avatar), responsive shell rewritten - Desktop homepage: immersive hero with Ken Burns animation, pill category chips, date badge cards matching mvnew.eventifyplus.com/home - Desktop calendar: 60/40 two-column layout with white background - Desktop profile: full-width banner + 3-column event grids - Desktop learn more: hero image + about/venue columns + gallery strip - Desktop settings/contribute: polished to match design system - Mobile hero slider: RepaintBoundary, animated dots with 44px tap targets, 5s auto-scroll, 8s post-swipe delay, shimmer loading, dynamic event type badge, human-readable dates - Guest access: requiresAuth false on read endpoints - Location fix: show place names instead of lat/lng coordinates - Version 1.6.1+17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
366 lines
15 KiB
Dart
366 lines
15 KiB
Dart
// lib/screens/search_screen.dart
|
||
import 'dart:ui';
|
||
import 'package:flutter/material.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 – 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'),
|
||
];
|
||
|
||
List<_LocationItem> _searchResults = [];
|
||
bool _showSearchResults = false;
|
||
bool _loadingLocation = false;
|
||
|
||
@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();
|
||
}
|
||
});
|
||
}
|
||
|
||
void _selectAndClose(String location) {
|
||
Navigator.of(context).pop(location);
|
||
}
|
||
|
||
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('Current Location');
|
||
}
|
||
return;
|
||
}
|
||
|
||
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
||
|
||
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!);
|
||
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
|
||
if (mounted) Navigator.of(context).pop(label);
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
|
||
if (mounted) Navigator.of(context).pop('Current Location');
|
||
} catch (e) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
@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: () => _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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|