/opt/canhelp/apps/mobile/lib/screens/home
NameSizeModeActions
become_specialist_screen.dart131970644editdlrm
home_screen.dart597720644editdlrm
main_shell.dart81140644editdlrm
Edit: /opt/canhelp/apps/mobile/lib/screens/home/home_screen.dart (59772B)
import 'dart:math'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../models/location.dart'; import '../../models/task.dart'; import '../../models/user.dart'; import '../../providers/auth_provider.dart'; import '../../providers/favorites_provider.dart'; import '../../providers/locale_provider.dart'; import '../../providers/locations_provider.dart'; import '../../providers/notifications_provider.dart'; import '../../services/api_client.dart'; import '../../services/storage_service.dart'; import '../../theme/app_theme.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { List _nearbyTasks = []; bool _loading = true; String? _selectedLocationSlug; bool _usingGps = false; bool _manualLocationSelected = false; final StorageService _storage = StorageService(); List _specialists = []; bool _loadingSpecialists = false; // ── Greek city centres (slug → lat/lng) ────────────────────── static const Map> _cityCentres = { 'athens': [37.9838, 23.7275], 'thessaloniki': [40.6401, 22.9444], 'patras': [38.2466, 21.7346], 'heraklion': [35.3387, 25.1442], 'larissa': [39.6390, 22.4191], 'volos': [39.3667, 22.9333], 'ioannina': [39.6650, 20.8537], 'chania': [35.5138, 24.0180], 'rhodes': [36.4341, 28.2176], 'kavala': [40.9396, 24.4023], }; @override void initState() { super.initState(); _bootstrapLocation(); } Future _bootstrapLocation() async { final savedLocation = await _storage.getSelectedLocation(); if (!mounted) return; if (savedLocation != null) { setState(() { _selectedLocationSlug = savedLocation; _usingGps = false; }); await _loadTasks(location: savedLocation); await _loadSpecialists(location: savedLocation); return; } await _loadTasks(); await _loadSpecialists(); await _detectCityFromGps(); } Future _loadTasks({String? location}) async { setState(() => _loading = true); try { final result = await ApiClient().getTasks( status: 'open', limit: 12, location: location ?? _selectedLocationSlug, ); if (mounted) setState(() => _nearbyTasks = result.data); } catch (_) { } finally { if (mounted) setState(() => _loading = false); } } Future _loadSpecialists({String? location}) async { setState(() => _loadingSpecialists = true); try { final locale = context.read().locale; final result = await ApiClient().getSpecialists( locale: locale, location: location ?? _selectedLocationSlug, limit: 10, ); if (mounted) setState(() => _specialists = result.data); } catch (_) { } finally { if (mounted) setState(() => _loadingSpecialists = false); } } Future _detectCityFromGps() async { try { var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { return; } final pos = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.low, timeLimit: Duration(seconds: 8), ), ); // Find nearest city String? nearest; double minDist = double.infinity; for (final entry in _cityCentres.entries) { final d = _haversine( pos.latitude, pos.longitude, entry.value[0], entry.value[1], ); if (d < minDist) { minDist = d; nearest = entry.key; } } if (nearest != null && mounted) { if (_manualLocationSelected) return; setState(() { _selectedLocationSlug = nearest; _usingGps = true; }); await _storage.saveSelectedLocation(nearest); _loadTasks(location: nearest); _loadSpecialists(location: nearest); } } catch (_) { // Silently ignore — city picker still works manually } } static double _haversine(double lat1, double lng1, double lat2, double lng2) { const r = 6371.0; final dLat = (lat2 - lat1) * pi / 180; final dLng = (lng2 - lng1) * pi / 180; final a = sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * sin(dLng / 2) * sin(dLng / 2); return r * 2 * atan2(sqrt(a), sqrt(1 - a)); } void _showCityPicker() { final locProvider = context.read(); final locale = context.read().locale; final t = context.read().t; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => _CityPickerSheet( locations: locProvider.locations, selectedSlug: _selectedLocationSlug, locale: locale, anyLabel: t('tasks.anyLocation'), onSelect: (slug) async { setState(() { _selectedLocationSlug = slug; _manualLocationSelected = true; }); _usingGps = false; await _storage.saveSelectedLocation(slug); _loadTasks(location: slug); _loadSpecialists(location: slug); }, ), ); } @override Widget build(BuildContext context) { final locale = context.watch(); final t = locale.t; final user = context.watch().user; final favorites = context.watch().favorites; final screenWidth = MediaQuery.sizeOf(context).width; final isTablet = screenWidth >= 700; final visibleTaskCount = isTablet ? 8 : 6; final firstName = user?.firstName ?? ''; final locProvider = context.watch(); final cityLabel = _selectedLocationSlug != null ? locProvider.nameFor(_selectedLocationSlug, locale.locale) : t('tasks.anyLocation'); final unreadNotifs = context.watch().unreadCount; return Scaffold( backgroundColor: AppTheme.background, body: SafeArea( child: CustomScrollView( slivers: [ // ── Header ──────────────────────────────────────────── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: _showCityPicker, child: Row( children: [ Icon( _usingGps ? Icons.my_location_rounded : Icons.location_on_rounded, size: 16, color: AppTheme.primary, ), const SizedBox(width: 4), Text( cityLabel, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppTheme.textSecondary, ), ), const Icon( Icons.keyboard_arrow_down_rounded, size: 16, color: AppTheme.textSecondary, ), ], ), ), const SizedBox(height: 4), Text( firstName.isNotEmpty ? t( 'home.greeting', ).replaceAll('{name}', firstName) : t('home.greetingAnon'), style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w800, color: AppTheme.textPrimary, height: 1.2, ), ), ], ), ), // Notification bell Stack( clipBehavior: Clip.none, children: [ GestureDetector( onTap: () => context.push('/notifications'), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppTheme.border), ), child: const Icon( Icons.notifications_none_rounded, size: 22, color: AppTheme.textPrimary, ), ), ), if (unreadNotifs > 0) Positioned( right: 0, top: 0, child: Container( width: 16, height: 16, decoration: const BoxDecoration( color: AppTheme.danger, shape: BoxShape.circle, ), child: Center( child: Text( unreadNotifs > 9 ? '9+' : '$unreadNotifs', style: const TextStyle( color: Colors.white, fontSize: 9, fontWeight: FontWeight.w700, ), ), ), ), ), ], ), ], ), ), ), // ── Search bar (read-only, taps to search tab) ──────── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: GestureDetector( onTap: () => context.go('/search'), child: Container( height: 50, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.inputRadius), border: Border.all(color: AppTheme.border), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Row( children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 14), child: Icon( Icons.search_rounded, color: AppTheme.textHint, size: 22, ), ), Text( t('home.searchPlaceholder'), style: const TextStyle( fontSize: 15, color: AppTheme.textHint, ), ), ], ), ), ), ), ), // ── Post a request button ───────────────────────────── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 54, child: ElevatedButton.icon( onPressed: () => context.push('/tasks/new'), icon: const Icon(Icons.add_rounded, size: 22), label: Text( t('tasks.createTask'), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primary, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), ), ), if (user != null) ...[ const SizedBox(height: 8), SizedBox( height: 48, child: OutlinedButton.icon( onPressed: () => context.push('/dashboard'), icon: const Icon(Icons.assignment_outlined, size: 20), label: Text( t('dashboard.myTasks'), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), style: OutlinedButton.styleFrom( foregroundColor: AppTheme.primary, side: const BorderSide(color: AppTheme.primary), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), ), ), ], ], ), ), ), // ── Near you (horizontal scroll) ────────────────────── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 28, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( t('home.nearYou'), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w800, color: AppTheme.textPrimary, ), ), GestureDetector( onTap: () => context.go('/search'), child: Text( t('home.seeAll'), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primary, ), ), ), const SizedBox(width: 8), GestureDetector( onTap: () => context.push('/map'), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(20), ), child: Row( children: [ const Icon( Icons.map_rounded, size: 13, color: AppTheme.primary, ), const SizedBox(width: 4), Text( t('home.map'), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.primary, ), ), ], ), ), ), ], ), ), ), if (_loading) const SliverToBoxAdapter( child: SizedBox( height: 140, child: Center( child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.primary, ), ), ), ) else if (_nearbyTasks.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular( AppTheme.cardRadius, ), border: Border.all(color: AppTheme.border), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular( AppTheme.smallRadius, ), ), child: const Icon( Icons.explore_outlined, color: AppTheme.primary, size: 20, ), ), const SizedBox(width: 10), Expanded( child: Text( t('home.noTasksNearbyTitle'), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), ), ], ), const SizedBox(height: 10), Text( t('tasks.emptyMessage'), style: const TextStyle( color: AppTheme.textSecondary, fontSize: 14, height: 1.35, ), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => context.push('/map'), icon: const Icon(Icons.map_rounded, size: 18), label: Text( t('home.openMapCta'), maxLines: 1, overflow: TextOverflow.ellipsis, ), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(44), foregroundColor: AppTheme.primary, side: const BorderSide( color: AppTheme.primary, ), textStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), ), ), ], ), ), const SizedBox(height: 12), if (user == null || !user.isSpecialist) Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular( AppTheme.cardRadius, ), border: Border.all( color: AppTheme.primary.withValues(alpha: 0.28), ), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular( AppTheme.smallRadius, ), ), child: const Icon( Icons.workspace_premium_outlined, color: AppTheme.primary, size: 20, ), ), const SizedBox(width: 10), Expanded( child: Text( t('home.becomeSpecialistTitle'), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), ), ], ), const SizedBox(height: 10), Text( t('home.becomeSpecialistSubtitle'), style: const TextStyle( color: AppTheme.textSecondary, fontSize: 14, height: 1.35, ), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => context.push('/become-specialist'), icon: const Icon( Icons.trending_up_rounded, size: 18, ), label: Text(t('home.becomeSpecialistCta')), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(44), foregroundColor: AppTheme.primary, side: const BorderSide( color: AppTheme.primary, ), textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), ), ), ], ), ), ], ), ), ) else SliverToBoxAdapter( child: SizedBox( height: 140, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _nearbyTasks.take(visibleTaskCount).length, separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, i) { return _TaskCard( task: _nearbyTasks[i], onTap: () => context.push('/tasks/${_nearbyTasks[i].id}'), ); }, ), ), ), // ── Recent requests (vertical list) ─────────────────── if (_nearbyTasks.length > visibleTaskCount) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 28, 16, 8), child: Text( t('home.recentRequests'), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w800, color: AppTheme.textPrimary, ), ), ), ), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, i) { final task = _nearbyTasks[i + visibleTaskCount]; return Padding( padding: const EdgeInsets.only(bottom: 12), child: _TaskListItem( task: task, onTap: () => context.push('/tasks/${task.id}'), ), ); }, childCount: _nearbyTasks.skip(visibleTaskCount).length), ), ), ], // ── Specialists / Offers ────────────────────────────── if (_loadingSpecialists || _specialists.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 28, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( t('nav.specialists'), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w800, color: AppTheme.textPrimary, ), ), GestureDetector( onTap: () => context.go('/search?mode=offers'), child: Text( t('home.seeAll'), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primary, ), ), ), ], ), ), ), if (_loadingSpecialists) const SliverToBoxAdapter( child: SizedBox( height: 90, child: Center( child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.primary, ), ), ), ) else SliverToBoxAdapter( child: SizedBox( height: isTablet ? 156 : 140, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _specialists.length, separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, i) { final s = _specialists[i]; final initials = '${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}' .toUpperCase(); final categorySlug = s.specialistCategories != null && s.specialistCategories!.isNotEmpty ? s.specialistCategories!.first : null; final categoryLabel = categorySlug != null ? t('category.$categorySlug') == 'category.$categorySlug' ? categorySlug : t('category.$categorySlug') : null; final summary = (s.bio != null && s.bio!.trim().isNotEmpty) ? s.bio!.trim() : categoryLabel; final ratingLabel = s.rating > 0 ? s.rating.toStringAsFixed(1) : null; return GestureDetector( onTap: () => context.push('/specialists/${s.id}'), child: Container( width: isTablet ? 136 : 110, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular( AppTheme.cardRadius, ), border: Border.all(color: AppTheme.border), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Stack( children: [ CircleAvatar( radius: isTablet ? 30 : 27, backgroundImage: s.avatar != null ? NetworkImage(s.avatar!) : null, backgroundColor: AppTheme.primaryContainer, child: s.avatar == null ? Text( initials.isNotEmpty ? initials : '?', style: const TextStyle( color: AppTheme.primary, fontWeight: FontWeight.bold, fontSize: 14, ), ) : null, ), if (s.isOnline) Positioned( bottom: 0, right: 0, child: Container( width: 11, height: 11, decoration: BoxDecoration( color: AppTheme.success, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), ), ), ), ], ), const SizedBox(height: 8), Text( s.firstName.isNotEmpty ? s.firstName : s.fullName.split(' ').first, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), if (summary != null) ...[ const SizedBox(height: 4), Text( summary, style: TextStyle( fontSize: isTablet ? 11.5 : 11, fontWeight: FontWeight.w500, color: AppTheme.textSecondary, ), maxLines: isTablet ? 2 : 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ], if (ratingLabel != null) ...[ const SizedBox(height: 5), Text( '★ $ratingLabel', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: AppTheme.amber, ), ), ], ], ), ), ); }, ), ), ), ], // ── Favourites ──────────────────────────────────────── if (user != null && favorites.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 28, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( t('nav.favorites'), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w800, color: AppTheme.textPrimary, ), ), GestureDetector( onTap: () => context.push('/favorites'), child: Text( t('home.seeAll'), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primary, ), ), ), ], ), ), ), SliverToBoxAdapter( child: SizedBox( height: 90, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: favorites.length, separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, i) { final s = favorites[i]; final initials = '${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}' .toUpperCase(); return GestureDetector( onTap: () => context.push('/specialists/${s.id}'), child: SizedBox( width: 70, child: Column( children: [ Stack( children: [ CircleAvatar( radius: 26, backgroundImage: s.avatar != null ? NetworkImage(s.avatar!) : null, backgroundColor: AppTheme.primaryContainer, child: s.avatar == null ? Text( initials.isNotEmpty ? initials : '?', style: const TextStyle( color: AppTheme.primary, fontWeight: FontWeight.bold, fontSize: 14, ), ) : null, ), Positioned( bottom: 0, right: 0, child: Container( width: 14, height: 14, decoration: BoxDecoration( color: AppTheme.amber, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), ), child: const Icon( Icons.favorite_rounded, size: 7, color: Colors.white, ), ), ), ], ), const SizedBox(height: 5), Text( s.firstName.isNotEmpty ? s.firstName : s.fullName.split(' ').first, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ], ), ), ); }, ), ), ), ], const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), ), ); } } // ─── Task card (horizontal scroll) ───────────────────────────────────────── class _TaskCard extends StatelessWidget { final Task task; final VoidCallback onTap; const _TaskCard({required this.task, required this.onTap}); @override Widget build(BuildContext context) { final color = AppTheme.categoryColor(task.category); final t = context.watch().t; return GestureDetector( onTap: onTap, child: Container( width: 180, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.cardRadius), border: Border.all(color: AppTheme.border), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 10, offset: Offset(0, 3), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Category icon Container( width: 38, height: 38, decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppTheme.smallRadius), ), child: Icon( AppTheme.categoryIcon(task.category), color: color, size: 20, ), ), const Spacer(), // Title Text( task.title, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, height: 1.3, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), // Price if (task.budget != null) Text( '€${task.budget!.toStringAsFixed(0)}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w800, color: AppTheme.primary, ), ) else Text( t('tasks.negotiable'), style: TextStyle(fontSize: 13, color: AppTheme.textSecondary), ), ], ), ), ); } } // ─── Task list item (vertical list) ───────────────────────────────────────── class _TaskListItem extends StatelessWidget { final Task task; final VoidCallback onTap; const _TaskListItem({required this.task, required this.onTap}); @override Widget build(BuildContext context) { final color = AppTheme.categoryColor(task.category); final locale = context.watch().locale; final locationLabel = context.watch().nameFor( task.location, locale, ); return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.cardRadius), border: Border.all(color: AppTheme.border), boxShadow: const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(AppTheme.smallRadius), ), child: Icon( AppTheme.categoryIcon(task.category), color: color, size: 22, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( task.title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), if (task.location != null) Row( children: [ const Icon( Icons.location_on_rounded, size: 13, color: AppTheme.textHint, ), const SizedBox(width: 2), Text( locationLabel, style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary, ), ), ], ), ], ), ), const SizedBox(width: 8), if (task.budget != null) Text( '€${task.budget!.toStringAsFixed(0)}', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: AppTheme.primary, ), ) else const Icon( Icons.handshake_outlined, size: 18, color: AppTheme.textHint, ), ], ), ), ); } } // ─── City picker bottom sheet ─────────────────────────────────────────────── class _CityPickerSheet extends StatefulWidget { final List locations; final String? selectedSlug; final String locale; final String anyLabel; final ValueChanged onSelect; const _CityPickerSheet({ required this.locations, required this.selectedSlug, required this.locale, required this.anyLabel, required this.onSelect, }); @override State<_CityPickerSheet> createState() => _CityPickerSheetState(); } class _CityPickerSheetState extends State<_CityPickerSheet> { String? _expandedCity; @override void initState() { super.initState(); // Auto-expand city of selected district if (widget.selectedSlug != null) { for (final city in widget.locations) { for (final d in city.children) { if (d.slug == widget.selectedSlug) { _expandedCity = city.slug; break; } } } } } @override Widget build(BuildContext context) { final maxH = MediaQuery.of(context).size.height * 0.75; return Container( constraints: BoxConstraints(maxHeight: maxH), decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Handle Container( margin: const EdgeInsets.only(top: 12, bottom: 4), width: 40, height: 4, decoration: BoxDecoration( color: AppTheme.border, borderRadius: BorderRadius.circular(2), ), ), // Title Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( context.read().t('home.selectCity'), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), IconButton( icon: const Icon(Icons.close_rounded, size: 22), color: AppTheme.textSecondary, onPressed: () => Navigator.pop(context), ), ], ), ), const Divider(height: 1, color: AppTheme.border), // List Flexible( child: ListView( shrinkWrap: true, children: [ // "Any location" row _LocationRow( label: widget.anyLabel, icon: Icons.public_rounded, selected: widget.selectedSlug == null, onTap: () { widget.onSelect(null); Navigator.pop(context); }, ), const Divider(height: 1, color: AppTheme.border), // Cities ...widget.locations.map((city) { final cityName = city.nameFor(widget.locale); final hasDistricts = city.children.isNotEmpty; final isExpanded = _expandedCity == city.slug; final isCitySelected = widget.selectedSlug == city.slug; return Column( mainAxisSize: MainAxisSize.min, children: [ _LocationRow( label: cityName, icon: Icons.location_city_rounded, selected: isCitySelected, trailing: hasDistricts ? Icon( isExpanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, size: 20, color: AppTheme.textHint, ) : null, onTap: () { if (hasDistricts) { setState( () => _expandedCity = isExpanded ? null : city.slug, ); } else { widget.onSelect(city.slug); Navigator.pop(context); } }, ), // Districts if (hasDistricts && isExpanded) Column( mainAxisSize: MainAxisSize.min, children: [ // "Whole city" option _LocationRow( label: '$cityName — all', icon: Icons.circle, iconSize: 6, selected: isCitySelected, indent: 32, onTap: () { widget.onSelect(city.slug); Navigator.pop(context); }, ), ...city.children.map( (d) => _LocationRow( label: d.nameFor(widget.locale), icon: Icons.circle, iconSize: 6, selected: widget.selectedSlug == d.slug, indent: 32, onTap: () { widget.onSelect(d.slug); Navigator.pop(context); }, ), ), ], ), const Divider(height: 1, color: AppTheme.border), ], ); }), ], ), ), SizedBox(height: MediaQuery.of(context).padding.bottom + 8), ], ), ); } } class _LocationRow extends StatelessWidget { final String label; final IconData icon; final double iconSize; final bool selected; final double indent; final Widget? trailing; final VoidCallback onTap; const _LocationRow({ required this.label, required this.icon, this.iconSize = 20, required this.selected, this.indent = 0, this.trailing, required this.onTap, }); @override Widget build(BuildContext context) { return Material( color: selected ? AppTheme.primaryLight : Colors.transparent, child: InkWell( onTap: onTap, child: Padding( padding: EdgeInsets.fromLTRB(20 + indent, 14, 16, 14), child: Row( children: [ Icon( icon, size: iconSize, color: selected ? AppTheme.primary : AppTheme.textHint, ), const SizedBox(width: 12), Expanded( child: Text( label, style: TextStyle( fontSize: 15, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? AppTheme.primary : AppTheme.textPrimary, ), ), ), if (selected) const Icon( Icons.check_rounded, size: 18, color: AppTheme.primary, ), if (trailing != null && !selected) trailing!, ], ), ), ), ); } }