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:
@@ -1,5 +1,4 @@
|
||||
// lib/screens/calendar_screen.dart
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../features/events/services/events_service.dart';
|
||||
@@ -25,8 +24,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
|
||||
final Set<String> _markedDates = {};
|
||||
final Map<String, int> _dateCounts = {};
|
||||
final Map<String, List<String>> _dateThumbnails = {};
|
||||
|
||||
List<EventModel> _eventsOfDay = [];
|
||||
|
||||
// Scroll controller for the calendar grid
|
||||
@@ -67,7 +64,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
_loadingMonth = true;
|
||||
_markedDates.clear();
|
||||
_dateCounts.clear();
|
||||
_dateThumbnails.clear();
|
||||
_eventsOfDay = [];
|
||||
});
|
||||
|
||||
@@ -95,9 +91,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (_markedDates.isNotEmpty) {
|
||||
await _fetchThumbnailsForDates(_markedDates.toList());
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
} finally {
|
||||
@@ -105,34 +98,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchThumbnailsForDates(List<String> dates) async {
|
||||
for (final date in dates) {
|
||||
try {
|
||||
final events = await _service.getEventsForDate(date);
|
||||
final thumbs = <String>[];
|
||||
for (final e in events) {
|
||||
String? url;
|
||||
if (e.thumbImg != null && e.thumbImg!.trim().isNotEmpty) {
|
||||
url = e.thumbImg!.trim();
|
||||
} else if (e.images.isNotEmpty && e.images.first.image.trim().isNotEmpty) {
|
||||
url = e.images.first.image.trim();
|
||||
}
|
||||
if (url != null && url.isNotEmpty) thumbs.add(url);
|
||||
if (thumbs.length >= 3) break;
|
||||
}
|
||||
if (thumbs.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() => _dateThumbnails[date] = thumbs);
|
||||
} else {
|
||||
_dateThumbnails[date] = thumbs;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore per-date errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSelectDate(String yyyyMMdd) async {
|
||||
setState(() {
|
||||
_loadingDay = true;
|
||||
@@ -381,9 +346,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
itemCount: totalItems,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final cellDate = firstCellDate.add(Duration(days: index));
|
||||
@@ -391,8 +356,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
final dayIndex = cellDate.day;
|
||||
final key = _ymKey(cellDate);
|
||||
final hasEvents = _markedDates.contains(key);
|
||||
final thumbnails = _dateThumbnails[key] ?? [];
|
||||
final eventCount = _dateCounts[key] ?? 0;
|
||||
final isSelected = selectedDate.year == cellDate.year && selectedDate.month == cellDate.month && selectedDate.day == cellDate.day;
|
||||
final isToday = cellDate.year == DateTime.now().year && cellDate.month == DateTime.now().month && cellDate.day == DateTime.now().day;
|
||||
|
||||
final dayTextColor = inCurrentMonth
|
||||
? (Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black87)
|
||||
@@ -408,52 +374,57 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// rounded date cell
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? primaryColor.withOpacity(0.14) : Colors.transparent,
|
||||
color: isSelected
|
||||
? primaryColor
|
||||
: isToday
|
||||
? primaryColor.withOpacity(0.12)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$dayIndex',
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
||||
color: isSelected ? primaryColor : dayTextColor,
|
||||
fontSize: 14,
|
||||
fontWeight: (isSelected || isToday) ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: isToday
|
||||
? primaryColor
|
||||
: dayTextColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// small event indicators (thumbnail overlap or dot)
|
||||
if (hasEvents && thumbnails.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 14,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: thumbnails.take(2).toList().asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final url = entry.value;
|
||||
return Transform.translate(
|
||||
offset: Offset(i * -6.0, 0),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: Theme.of(context).cardColor, width: 1.0)),
|
||||
child: ClipOval(child: Image.network(url, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: Theme.of(context).dividerColor))),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
const SizedBox(height: 3),
|
||||
// event indicator dots
|
||||
if (hasEvents && inCurrentMonth)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
eventCount.clamp(1, 3),
|
||||
(i) => Container(
|
||||
width: 5,
|
||||
height: 5,
|
||||
margin: EdgeInsets.only(left: i > 0 ? 2 : 0),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? primaryColor
|
||||
: const Color(0xFFEF4444),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (hasEvents)
|
||||
Container(width: 18, height: 6, decoration: BoxDecoration(color: primaryColor, borderRadius: BorderRadius.circular(6)))
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
const SizedBox(height: 5),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ class ResponsiveLayout extends StatelessWidget {
|
||||
Key? key,
|
||||
required this.mobile,
|
||||
required this.desktop,
|
||||
this.mobileBreakpoint = 700, // tune this value if you prefer different breakpoint
|
||||
this.mobileBreakpoint = 820, // consistent with MyApp.desktopBreakpoint
|
||||
}) : assert(mobileBreakpoint > 0),
|
||||
super(key: key);
|
||||
|
||||
@@ -35,12 +35,18 @@ class ResponsiveLayout extends StatelessWidget {
|
||||
bool _chooseMobile(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
|
||||
// On web, use width to determine mobile vs desktop so narrow browser
|
||||
// windows (or mobile-sized preview) get the mobile UI.
|
||||
if (kIsWeb) {
|
||||
return width < mobileBreakpoint;
|
||||
}
|
||||
|
||||
// If running on Android/iOS, allow width to determine mobile vs desktop.
|
||||
if (_isMobilePlatform()) {
|
||||
return width < mobileBreakpoint;
|
||||
}
|
||||
|
||||
// On desktop platforms (Windows/macOS/Linux) and on web, always use desktop UI.
|
||||
// On native desktop platforms (Windows/macOS/Linux) always use desktop UI.
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user