/
opt
/
canhelp
/
apps
/
mobile
/
lib
/
screens
/
map
/
/opt/canhelp/apps/mobile/lib/screens/map
mkdir
upload
Name
Size
Mode
Actions
map_screen.dart
24570
0644
edit
dl
rm
Edit:
/opt/canhelp/apps/mobile/lib/screens/map/map_screen.dart
(24570B)
import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../../models/task.dart'; import '../../providers/locale_provider.dart'; import '../../providers/locations_provider.dart'; import '../../services/api_client.dart'; import '../../theme/app_theme.dart'; // ── Static coordinates for Greek cities/districts ────────────────────────── const _cityCoords = <String, LatLng>{ // Major cities 'athens': LatLng(37.9838, 23.7275), 'thessaloniki': LatLng(40.6401, 22.9444), 'patras': LatLng(38.2466, 21.7346), 'heraklion': LatLng(35.3387, 25.1442), 'larissa': LatLng(39.6386, 22.4191), 'volos': LatLng(39.3666, 22.9427), 'ioannina': LatLng(39.6650, 20.8537), 'chania': LatLng(35.5138, 24.0180), 'rhodes': LatLng(36.4341, 28.2176), 'kavala': LatLng(40.9396, 24.4040), // Athens districts 'athens-center': LatLng(37.9838, 23.7275), 'kolonaki': LatLng(37.9784, 23.7447), 'exarchia': LatLng(37.9869, 23.7339), 'monastiraki': LatLng(37.9755, 23.7235), 'piraeus': LatLng(37.9497, 23.6458), 'glyfada': LatLng(37.8636, 23.7523), 'kifissia': LatLng(38.0736, 23.8139), 'marousi': LatLng(38.0500, 23.8063), 'chalandri': LatLng(38.0215, 23.8008), 'holargos': LatLng(37.9980, 23.7946), 'nea-smyrni': LatLng(37.9458, 23.7176), 'kallithea': LatLng(37.9568, 23.7063), // Thessaloniki districts 'thessaloniki-center': LatLng(40.6401, 22.9444), 'kalamaria': LatLng(40.5832, 22.9622), 'stavroupoli': LatLng(40.6703, 22.9180), }; const _defaultCenter = LatLng(38.0, 23.7); LatLng _coordsForSlug(String? slug) { if (slug == null) return _defaultCenter; if (_cityCoords.containsKey(slug)) return _cityCoords[slug]!; // Fuzzy match — find city that starts with or contains the slug prefix for (final entry in _cityCoords.entries) { if (entry.key.startsWith(slug) || slug.startsWith(entry.key)) { return entry.value; } } return _defaultCenter; } LatLng _scatterCoords(LatLng base, int index) { // Distribute multiple pins around city center with radius ~400m final rng = math.Random(index * 31337); final dLat = (rng.nextDouble() - 0.5) * 0.006; final dLng = (rng.nextDouble() - 0.5) * 0.008; return LatLng(base.latitude + dLat, base.longitude + dLng); } // ── MapScreen ────────────────────────────────────────────────────────────── class MapScreen extends StatefulWidget { const MapScreen({super.key}); @override State<MapScreen> createState() => _MapScreenState(); } class _MapScreenState extends State<MapScreen> { final _mapController = MapController(); List<Task> _tasks = []; bool _loading = true; Task? _selectedTask; String? _filterCategory; @override void initState() { super.initState(); _loadTasks(); } @override void dispose() { _mapController.dispose(); super.dispose(); } Future<void> _loadTasks() async { setState(() => _loading = true); try { final result = await ApiClient().getTasks( status: 'open', limit: 50, category: _filterCategory, ); if (mounted) setState(() => _tasks = result.data); } catch (_) { } finally { if (mounted) setState(() => _loading = false); } } void _showFilterSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => _FilterSheet( selectedCategory: _filterCategory, onSelect: (cat) { setState(() => _filterCategory = cat); _loadTasks(); }, ), ); } @override Widget build(BuildContext context) { final t = context.watch<LocaleProvider>().t; final locProvider = context.watch<LocationsProvider>(); final locale = context.watch<LocaleProvider>().locale; // Build markers — group tasks by location for scattering final locationIndices = <String, int>{}; final markers = _tasks.map((task) { final slug = task.location; final idx = locationIndices[slug ?? ''] ?? 0; locationIndices[slug ?? ''] = idx + 1; final base = _coordsForSlug(slug); final coords = idx == 0 ? base : _scatterCoords(base, idx); final isSelected = _selectedTask?.id == task.id; return Marker( width: isSelected ? 44 : 36, height: isSelected ? 44 : 36, point: coords, child: GestureDetector( onTap: () => setState(() => _selectedTask = task), child: _PinMarker(task: task, selected: isSelected), ), ); }).toList(); return Scaffold( backgroundColor: AppTheme.background, body: Stack( children: [ // ── Map ────────────────────────────────────────────────── FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: _defaultCenter, initialZoom: 6.5, onTap: (_, __) => setState(() => _selectedTask = null), ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.canhelp.app', maxZoom: 19, ), MarkerClusterLayerWidget( options: MarkerClusterLayerOptions( maxClusterRadius: 60, disableClusteringAtZoom: 13, size: const Size(44, 44), alignment: Alignment.center, padding: const EdgeInsets.all(50), markers: markers, builder: (context, clusterMarkers) { return Container( decoration: BoxDecoration( color: AppTheme.primary, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(80), blurRadius: 8, offset: const Offset(0, 3), ), ], ), child: Center( child: Text( '${clusterMarkers.length}', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14, ), ), ), ); }, ), ), ], ), // ── Top bar ────────────────────────────────────────────── SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Row( children: [ // Back button _MapButton( onTap: () => context.pop(), child: const Icon( Icons.arrow_back_rounded, size: 20, color: AppTheme.textPrimary, ), ), const SizedBox(width: 10), // Title pill Expanded( child: Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: AppTheme.shadowColor.withAlpha(30), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( children: [ const Icon( Icons.map_rounded, size: 16, color: AppTheme.primary, ), const SizedBox(width: 8), Text( _loading ? t('common.loading') : '${_tasks.length} ${t('tasks.requests')}', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), ), ], ), ), ), const SizedBox(width: 10), // Filter button _MapButton( onTap: _showFilterSheet, highlight: _filterCategory != null, child: Icon( Icons.tune_rounded, size: 20, color: _filterCategory != null ? AppTheme.primary : AppTheme.textPrimary, ), ), ], ), ), ), // ── Loading overlay ───────────────────────────────────── if (_loading) const Positioned( top: 80, left: 0, right: 0, child: Center( child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.primary, ), ), ), // ── Task preview card ──────────────────────────────────── if (_selectedTask != null) Positioned( left: 16, right: 16, bottom: MediaQuery.of(context).padding.bottom + 20, child: _TaskPreviewCard( task: _selectedTask!, locProvider: locProvider, locale: locale, onClose: () => setState(() => _selectedTask = null), onTap: () => context.push('/tasks/${_selectedTask!.id}'), ), ), // ── My location button ─────────────────────────────────── Positioned( right: 16, bottom: _selectedTask != null ? MediaQuery.of(context).padding.bottom + 160 : MediaQuery.of(context).padding.bottom + 24, child: _MapButton( onTap: () { // Center on Athens as default (no geolocator yet) _mapController.move(const LatLng(37.9838, 23.7275), 12); }, child: const Icon( Icons.my_location_rounded, size: 20, color: AppTheme.textPrimary, ), ), ), ], ), ); } } // ── Pin marker ───────────────────────────────────────────────────────────── class _PinMarker extends StatelessWidget { final Task task; final bool selected; const _PinMarker({required this.task, required this.selected}); @override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: selected ? AppTheme.primary : Colors.white, borderRadius: BorderRadius.circular(selected ? 12 : 18), border: Border.all(color: AppTheme.primary, width: selected ? 0 : 2), boxShadow: [ BoxShadow( color: AppTheme.shadowColor.withAlpha(selected ? 60 : 30), blurRadius: selected ? 12 : 6, offset: const Offset(0, 3), ), ], ), child: Center( child: task.budget != null ? Text( '€${task.budget!.toStringAsFixed(0)}', style: TextStyle( fontSize: selected ? 11 : 10, fontWeight: FontWeight.w800, color: selected ? Colors.white : AppTheme.primary, ), ) : Icon( Icons.handshake_outlined, size: selected ? 18 : 15, color: selected ? Colors.white : AppTheme.primary, ), ), ); } } // ── Task preview card ────────────────────────────────────────────────────── class _TaskPreviewCard extends StatelessWidget { final Task task; final LocationsProvider locProvider; final String locale; final VoidCallback onClose; final VoidCallback onTap; const _TaskPreviewCard({ required this.task, required this.locProvider, required this.locale, required this.onClose, required this.onTap, }); @override Widget build(BuildContext context) { final locationLabel = task.location != null ? locProvider.nameFor(task.location, locale) : null; return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.cardRadius), boxShadow: [ BoxShadow( color: AppTheme.shadowColor.withAlpha(40), blurRadius: 16, offset: const Offset(0, 4), ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Category color block Container( width: 48, height: 48, decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(AppTheme.smallRadius), ), child: const Icon( Icons.task_alt_rounded, color: AppTheme.primary, size: 24, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( task.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, height: 1.3, ), ), const SizedBox(height: 6), Row( children: [ if (locationLabel != null) ...[ const Icon( Icons.location_on_rounded, size: 13, color: AppTheme.textHint, ), const SizedBox(width: 3), Text( locationLabel, style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), ), const SizedBox(width: 10), ], if (task.budget != null) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(20), ), child: Text( '€${task.budget!.toStringAsFixed(0)}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: AppTheme.primary, ), ), ), ], ), ], ), ), const SizedBox(width: 8), // Close + arrow Column( children: [ GestureDetector( onTap: onClose, child: const Icon( Icons.close_rounded, size: 18, color: AppTheme.textHint, ), ), const SizedBox(height: 16), const Icon( Icons.arrow_forward_ios_rounded, size: 14, color: AppTheme.textHint, ), ], ), ], ), ), ); } } // ── Filter bottom sheet ──────────────────────────────────────────────────── class _FilterSheet extends StatelessWidget { final String? selectedCategory; final ValueChanged<String?> onSelect; const _FilterSheet({required this.selectedCategory, required this.onSelect}); static const _categories = [ ('cleaning', 'Cleaning', Icons.cleaning_services_rounded), ('moving', 'Moving', Icons.local_shipping_rounded), ('electrician', 'Electrician', Icons.electrical_services_rounded), ('plumbing', 'Plumbing', Icons.plumbing_rounded), ('painting', 'Painting', Icons.format_paint_rounded), ('gardening', 'Gardening', Icons.grass_rounded), ('assembly', 'Assembly', Icons.build_rounded), ('repair', 'Repair', Icons.handyman_rounded), ('it', 'IT / Tech', Icons.computer_rounded), ('tutoring', 'Tutoring', Icons.school_rounded), ]; @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( margin: const EdgeInsets.only(top: 12, bottom: 4), width: 40, height: 4, decoration: BoxDecoration( color: AppTheme.border, borderRadius: BorderRadius.circular(2), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 16, 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( context.read<LocaleProvider>().t('map.filterByCategory'), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), if (selectedCategory != null) TextButton( onPressed: () { onSelect(null); Navigator.pop(context); }, child: Text( context.read<LocaleProvider>().t('map.clear'), style: const TextStyle(color: AppTheme.primary), ), ), ], ), ), const Divider(height: 1, color: AppTheme.border), ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.7, ), child: ListView( shrinkWrap: true, children: [ ..._categories.map((cat) { final isSelected = selectedCategory == cat.$1; return Material( color: isSelected ? AppTheme.primaryLight : Colors.transparent, child: InkWell( onTap: () { onSelect(cat.$1); Navigator.pop(context); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 14, ), child: Row( children: [ Icon( cat.$3, size: 20, color: isSelected ? AppTheme.primary : AppTheme.textHint, ), const SizedBox(width: 14), Expanded( child: Text( cat.$2, style: TextStyle( fontSize: 15, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? AppTheme.primary : AppTheme.textPrimary, ), ), ), if (isSelected) const Icon( Icons.check_rounded, size: 18, color: AppTheme.primary, ), ], ), ), ), ); }), ], ), ), SizedBox(height: MediaQuery.of(context).padding.bottom + 8), ], ), ); } } // ── Small round button ───────────────────────────────────────────────────── class _MapButton extends StatelessWidget { final VoidCallback onTap; final Widget child; final bool highlight; const _MapButton({ required this.onTap, required this.child, this.highlight = false, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 40, height: 40, decoration: BoxDecoration( color: highlight ? AppTheme.primaryLight : Colors.white, borderRadius: BorderRadius.circular(12), border: highlight ? Border.all(color: AppTheme.primary, width: 1.5) : null, boxShadow: [ BoxShadow( color: AppTheme.shadowColor.withAlpha(30), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Center(child: child), ), ); } }
Save
cmd:
run