/opt/canhelp/apps/mobile/lib/screens/search
NameSizeModeActions
categories_screen.dart120360644editdlrm
search_screen.dart676010644editdlrm
Edit: /opt/canhelp/apps/mobile/lib/screens/search/search_screen.dart (67601B)
import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../../models/category.dart'; import '../../models/task.dart'; import '../../models/user.dart'; import '../../providers/locale_provider.dart'; import '../../services/api_client.dart'; import '../../theme/app_theme.dart'; /// Search screen — unified search: popular queries + categories grid + live results. class SearchScreen extends StatefulWidget { final String? initialQuery; final String? initialCategory; final String? initialMode; const SearchScreen({ super.key, this.initialQuery, this.initialCategory, this.initialMode, }); @override State createState() => _SearchScreenState(); } class _SearchScreenState extends State { final _searchCtrl = TextEditingController(); final _focusNode = FocusNode(); List _categories = []; List _results = []; bool _searching = false; Timer? _debounce; String? _activeCategory; // slug of currently selected category filter String? _activeCategoryLabel; // display name for active category // Mode: 'tasks' or 'offers' String _mode = 'tasks'; List _specialists = []; bool _searchingSpecialists = false; String? _offersCategory; // slug of selected category in offers mode // Popular category slugs (from seed data) static const _popularCategorySlugs = [ 'moving', 'cleaning', 'repairs', 'tutoring', 'it', 'beauty', 'events', ]; @override void initState() { super.initState(); if (widget.initialMode == 'offers') { _mode = 'offers'; } if (widget.initialQuery != null) { _searchCtrl.text = widget.initialQuery!; _runSearch(widget.initialQuery!); } if (widget.initialCategory != null && widget.initialQuery == null) { _activeCategory = widget.initialCategory; WidgetsBinding.instance.addPostFrameCallback((_) { _runSearch('', category: _activeCategory); }); } _searchCtrl.addListener(_onSearchChanged); _loadCategories(); if (_mode == 'offers') { WidgetsBinding.instance.addPostFrameCallback( (_) => _runSpecialistSearch(''), ); } } @override void dispose() { _debounce?.cancel(); _searchCtrl.removeListener(_onSearchChanged); _searchCtrl.dispose(); _focusNode.dispose(); super.dispose(); } Future _openAllCategories() async { final selectedSlug = await context.push('/categories'); if (!mounted || selectedSlug == null || selectedSlug.isEmpty) return; setState(() { _activeCategory = selectedSlug; _activeCategoryLabel = _categories .cast() .firstWhere((c) => c?.slug == selectedSlug, orElse: () => null) ?.nameFor(context.read().locale); }); await _runSearch(_searchCtrl.text.trim(), category: selectedSlug); } void _onSearchChanged() { _debounce?.cancel(); final q = _searchCtrl.text.trim(); if (_mode == 'offers') { _debounce = Timer( const Duration(milliseconds: 400), () => _runSpecialistSearch(q), ); return; } if (q.isEmpty && _activeCategory == null) { setState(() => _results = []); return; } _debounce = Timer(const Duration(milliseconds: 400), () => _runSearch(q)); } Future _runSearch(String query, {String? category}) async { final cat = category ?? _activeCategory; if (query.isEmpty && cat == null) { setState(() => _results = []); return; } setState(() => _searching = true); try { final res = await ApiClient().searchTasks( query: query.isEmpty ? null : query, category: cat, limit: 20, status: 'open', ); if (mounted) setState(() => _results = res.data); } catch (_) { } finally { if (mounted) setState(() => _searching = false); } } Future _loadCategories() async { try { final cats = await ApiClient().getCategories(); if (mounted && cats.isNotEmpty) { setState(() => _categories = cats); } } catch (_) {} } Future _runSpecialistSearch(String query, {String? category}) async { setState(() => _searchingSpecialists = true); try { final locale = context.read().locale; final res = await ApiClient().getSpecialists( query: query.isEmpty ? null : query, locale: locale, category: category ?? _offersCategory, limit: 30, ); if (mounted) setState(() => _specialists = res.data); } catch (_) { } finally { if (mounted) setState(() => _searchingSpecialists = false); } } IconData _iconForSlug(String slug) { switch (slug) { case 'cleaning': return Icons.cleaning_services_rounded; case 'plumbing': return Icons.plumbing_rounded; case 'electrical': return Icons.electrical_services_rounded; case 'moving': return Icons.local_shipping_rounded; case 'tutoring': return Icons.school_rounded; case 'beauty': return Icons.face_retouching_natural_rounded; case 'it_support': return Icons.computer_rounded; case 'repairs': return Icons.build_rounded; default: return Icons.category_rounded; } } Color _colorForSlug(String slug) { switch (slug) { case 'cleaning': return const Color(0xFF22C55E); case 'plumbing': return const Color(0xFF3B82F6); case 'electrical': return const Color(0xFFF59E0B); case 'moving': return const Color(0xFF8B5CF6); case 'tutoring': return const Color(0xFF06B6D4); case 'beauty': return const Color(0xFFEC4899); case 'it_support': return const Color(0xFF6366F1); case 'repairs': return const Color(0xFFEF4444); default: return AppTheme.textSecondary; } } void _onSearch(String query) { if (query.trim().isEmpty) return; _runSearch(query.trim()); } void _onCategoryTap(Category cat, String locale) { if (cat.children.isNotEmpty) { _showSubcategorySheet(cat, locale); } else { final newSlug = _activeCategory == cat.slug ? null : cat.slug; setState(() { _activeCategory = newSlug; _activeCategoryLabel = newSlug != null ? cat.nameFor(locale) : null; }); _runSearch(_searchCtrl.text.trim()); } } void _showSubcategorySheet(Category parent, String locale) { final t = context.read().t; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => _SubcategorySheet( parent: parent, locale: locale, selectedSlug: _activeCategory, allLabel: t('search.allInCategory'), onSelect: (slug, label) { setState(() { _activeCategory = slug; _activeCategoryLabel = label; }); _runSearch(_searchCtrl.text.trim()); }, ), ); } @override Widget build(BuildContext context) { final localeP = context.watch(); final t = localeP.t; final locale = localeP.locale; final screenWidth = MediaQuery.sizeOf(context).width; final categoryColumns = screenWidth >= 1180 ? 6 : screenWidth >= 920 ? 5 : screenWidth >= 700 ? 4 : 3; final categoryAspectRatio = screenWidth >= 920 ? 1.18 : screenWidth >= 700 ? 1.1 : 0.9; final hasQuery = _searchCtrl.text.trim().isNotEmpty || _activeCategory != null; final displayCategories = _categories.isNotEmpty ? _categories : []; return Scaffold( backgroundColor: AppTheme.background, body: SafeArea( child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 700; final modeToggle = Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( color: AppTheme.surfaceVariant, borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), child: Row( children: [ _ModeTab( label: t('search.modeTasks'), active: _mode == 'tasks', onTap: () { if (_mode != 'tasks') { setState(() => _mode = 'tasks'); } }, ), const SizedBox(width: 3), _ModeTab( label: t('search.modeOffers'), active: _mode == 'offers', onTap: () { if (_mode != 'offers') { setState(() => _mode = 'offers'); _runSpecialistSearch(_searchCtrl.text.trim()); } }, ), ], ), ); final searchField = TextField( controller: _searchCtrl, focusNode: _focusNode, autofocus: false, textInputAction: TextInputAction.search, onSubmitted: _onSearch, decoration: InputDecoration( hintText: t('home.searchPlaceholder'), prefixIcon: const Icon( Icons.search_rounded, color: AppTheme.textHint, size: 22, ), suffixIcon: ValueListenableBuilder( valueListenable: _searchCtrl, builder: (_, value, __) => value.text.isEmpty ? const SizedBox.shrink() : IconButton( icon: const Icon( Icons.close_rounded, size: 20, ), onPressed: () { _searchCtrl.clear(); setState(() { _results = []; _activeCategory = null; }); }, ), ), filled: true, fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), borderSide: const BorderSide(color: AppTheme.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), borderSide: const BorderSide(color: AppTheme.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), borderSide: const BorderSide( color: AppTheme.primary, width: 2, ), ), ), ); if (isWide) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(width: 260, child: modeToggle), const SizedBox(width: 12), Expanded(child: searchField), ], ); } return Column( children: [ modeToggle, const SizedBox(height: 10), searchField, ], ); }, ), ), ), // ── Specialists results ──────────────────────────────── if (_mode == 'offers') ...[ // ── Category filter chips ──────────────────────── if (_categories.isNotEmpty) SliverToBoxAdapter( child: SizedBox( height: 38, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), itemCount: _categories.where((c) => c.parentId == null).length + 1, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, i) { final rootCats = _categories .where((c) => c.parentId == null) .toList(); final isAll = i == 0; final slug = isAll ? null : rootCats[i - 1].slug; final label = isAll ? t('search.allInCategory') : rootCats[i - 1].nameFor(locale); final isSelected = _offersCategory == slug; return GestureDetector( onTap: () { if (_offersCategory != slug) { setState(() => _offersCategory = slug); _runSpecialistSearch( _searchCtrl.text.trim(), category: slug, ); } }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 7, ), decoration: BoxDecoration( color: isSelected ? AppTheme.primary : Colors.white, borderRadius: BorderRadius.circular( AppTheme.chipRadius, ), border: Border.all( color: isSelected ? AppTheme.primary : AppTheme.border, ), ), child: Text( label, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: isSelected ? Colors.white : AppTheme.textPrimary, ), ), ), ); }, ), ), ), const SliverToBoxAdapter(child: SizedBox(height: 12)), if (_searchingSpecialists) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 32), child: Center( child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.primary, ), ), ), ) else if (_specialists.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 32, 16, 0), child: Column( children: [ const Icon( Icons.person_search_rounded, size: 48, color: AppTheme.textHint, ), const SizedBox(height: 12), Text( t('specialists.empty'), style: const TextStyle( fontSize: 14, color: AppTheme.textSecondary, ), textAlign: TextAlign.center, ), ], ), ), ) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Text( '${_specialists.length} ${t('search.results')}', style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, fontWeight: FontWeight.w500, ), ), ), ), if (screenWidth >= 920) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), child: LayoutBuilder( builder: (context, constraints) { const columns = 3; const spacing = 10.0; final itemWidth = (constraints.maxWidth - (spacing * (columns - 1))) / columns; return Wrap( spacing: spacing, runSpacing: spacing, children: _specialists .map( (user) => SizedBox( width: itemWidth, child: _SpecialistResultTile( user: user, onTap: () => context.push( '/specialists/${user.id}', ), ), ), ) .toList(), ); }, ), ), ) else SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, i) => Padding( padding: const EdgeInsets.only(bottom: 10), child: _SpecialistResultTile( user: _specialists[i], onTap: () => context.push( '/specialists/${_specialists[i].id}', ), ), ), childCount: _specialists.length, ), ), ), ], ] else if (hasQuery) ...[ // ── Task search results (when query active) ──────────── // Active category chip if (_activeCategory != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row( children: [ GestureDetector( onTap: () { setState(() { _activeCategory = null; _activeCategoryLabel = null; _results = []; }); }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular( AppTheme.chipRadius, ), border: Border.all(color: AppTheme.primary), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _activeCategoryLabel ?? _activeCategory ?? '', style: const TextStyle( fontSize: 13, color: AppTheme.primary, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 6), const Icon( Icons.close_rounded, size: 14, color: AppTheme.primary, ), ], ), ), ), ], ), ), ), if (_searching) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 32), child: Center( child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.primary, ), ), ), ) else if (_results.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 32, 16, 0), child: Column( children: [ const Icon( Icons.search_off_rounded, size: 48, color: AppTheme.textHint, ), const SizedBox(height: 12), Text( t('tasks.emptyMessage'), style: const TextStyle( fontSize: 14, color: AppTheme.textSecondary, ), textAlign: TextAlign.center, ), ], ), ), ) else ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Text( '${_results.length} ${t('search.results')}', style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, fontWeight: FontWeight.w500, ), ), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 32), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, i) { final task = _results[i]; return _SearchResultTile( task: task, onTap: () => context.push('/tasks/${task.id}'), ); }, childCount: _results.length), ), ), ], ] else ...[ // ── Popular searches ──────────────────────────────── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Text( t('search.popularSearches'), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), ), ), SliverToBoxAdapter( child: SizedBox( height: 44, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), itemCount: _popularCategorySlugs.length, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, i) { final slug = _popularCategorySlugs[i]; final cat = _categories.isEmpty ? null : _categories.cast().firstWhere( (c) => c?.slug == slug, orElse: () => null, ); final label = cat?.nameFor(locale) ?? t('category.$slug'); return GestureDetector( onTap: () { if (cat != null) { _onCategoryTap(cat, locale); } else { setState(() { _activeCategory = slug; _activeCategoryLabel = label; }); _runSearch(''); } }, child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), decoration: BoxDecoration( color: _activeCategory == slug ? AppTheme.primaryLight : Colors.white, borderRadius: BorderRadius.circular( AppTheme.chipRadius, ), border: Border.all( color: _activeCategory == slug ? AppTheme.primary : AppTheme.border, ), ), child: Text( label, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: _activeCategory == slug ? AppTheme.primary : AppTheme.textPrimary, ), ), ), ); }, ), ), ), // ── Categories ────────────────────────────────────── SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( t('home.popularCategories'), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), GestureDetector( onTap: _openAllCategories, child: Text( t('search.seeAll'), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppTheme.primary, ), ), ), ], ), ), ), if (displayCategories.isEmpty) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: categoryColumns, crossAxisSpacing: screenWidth >= 700 ? 10 : 12, mainAxisSpacing: screenWidth >= 700 ? 10 : 12, childAspectRatio: categoryAspectRatio, ), delegate: SliverChildBuilderDelegate((context, i) { final slugs = [ 'cleaning', 'plumbing', 'electrical', 'moving', 'tutoring', 'beauty', 'it_support', 'repairs', 'other', ]; final slug = slugs[i]; return _CategoryTile( icon: _iconForSlug(slug), color: _colorForSlug(slug), label: t('category.$slug'), selected: _activeCategory == slug, onTap: () { setState(() { _activeCategory = _activeCategory == slug ? null : slug; }); _runSearch(_searchCtrl.text.trim()); }, ); }, childCount: 9), ), ) else SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: categoryColumns, crossAxisSpacing: screenWidth >= 700 ? 10 : 12, mainAxisSpacing: screenWidth >= 700 ? 10 : 12, childAspectRatio: categoryAspectRatio, ), delegate: SliverChildBuilderDelegate((context, i) { final cat = displayCategories[i]; final hasEmoji = cat.icon.isNotEmpty && cat.icon.runes.first > 0xFF; return _CategoryTile( icon: hasEmoji ? null : _iconForSlug(cat.slug), emoji: hasEmoji ? cat.icon : null, color: _colorForSlug(cat.slug), label: cat.nameFor(locale), selected: _activeCategory == cat.slug || cat.children.any((c) => c.slug == _activeCategory), hasChildren: cat.children.isNotEmpty, onTap: () => _onCategoryTap(cat, locale), ); }, childCount: displayCategories.length), ), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ], ), ), ); } } // ─── Mode tab button ─────────────────────────────────────────────────────── class _ModeTab extends StatelessWidget { final String label; final bool active; final VoidCallback onTap; const _ModeTab({ required this.label, required this.active, required this.onTap, }); @override Widget build(BuildContext context) { return Expanded( child: GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 9), decoration: BoxDecoration( color: active ? AppTheme.primary : Colors.transparent, borderRadius: BorderRadius.circular(AppTheme.buttonRadius - 2), boxShadow: active ? [ BoxShadow( color: AppTheme.primary.withValues(alpha: 0.25), blurRadius: 6, offset: const Offset(0, 2), ), ] : null, ), child: Text( label, textAlign: TextAlign.center, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: active ? Colors.white : AppTheme.textSecondary, ), ), ), ), ); } } // ─── Specialist result tile ──────────────────────────────────────────────── class _SpecialistResultTile extends StatelessWidget { final User user; final VoidCallback onTap; const _SpecialistResultTile({required this.user, required this.onTap}); List _cleanStrings(List? raw) { if (raw == null) return const []; return raw .map((e) => e.toString().trim()) .where((e) => e.isNotEmpty) .toList(); } String? _subtitle(BuildContext context, User user) { final t = context.read().t; for (final card in user.cards) { final categories = _cleanStrings(card['categories'] as List?); final skills = _cleanStrings(card['skills'] as List?); if (categories.isNotEmpty) { final slug = categories.first; final localizedCategory = t('category.$slug'); final categoryLabel = localizedCategory == 'category.$slug' ? slug : localizedCategory; if (skills.isNotEmpty) { return '$categoryLabel • ${skills.take(2).join(' • ')}'; } return categoryLabel; } final title = (card['title'] ?? card['name'])?.toString().trim(); if (title != null && title.isNotEmpty) return title; if (skills.isNotEmpty) { return skills.take(3).join(' • '); } } final skills = user.skills?.map((s) => s.trim()).where((s) => s.isNotEmpty).toList() ?? const []; if (skills.isEmpty) return null; final preview = skills.take(3).join(' • '); if (skills.length > 3) { return '$preview +${skills.length - 3}'; } return preview; } List _categoryChips(BuildContext context) { final t = context.read().t; final slugs = []; for (final card in user.cards) { final categories = _cleanStrings(card['categories'] as List?); for (final slug in categories) { if (!slugs.contains(slug)) slugs.add(slug); } } if (slugs.isEmpty) { for (final slug in user.specialistCategories ?? const []) { final trimmed = slug.trim(); if (trimmed.isNotEmpty && !slugs.contains(trimmed)) slugs.add(trimmed); } } return slugs.take(2).map((slug) { final localized = t('category.$slug'); return localized == 'category.$slug' ? slug : localized; }).toList(); } List _skillChips() { final skills = []; for (final card in user.cards) { final cardSkills = _cleanStrings(card['skills'] as List?); for (final skill in cardSkills) { if (!skills.contains(skill)) skills.add(skill); } } if (skills.isEmpty) { for (final skill in user.skills ?? const []) { final trimmed = skill.trim(); if (trimmed.isNotEmpty && !skills.contains(trimmed)) { skills.add(trimmed); } } } return skills.take(2).toList(); } Widget _metricMini({ required IconData icon, required String value, Color? color, }) { return Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5), decoration: BoxDecoration( color: AppTheme.surfaceVariant, borderRadius: BorderRadius.circular(999), border: Border.all(color: AppTheme.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 11.5, color: color ?? AppTheme.textSecondary), const SizedBox(width: 3), Text( value, style: const TextStyle( fontSize: 10.5, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, height: 1, ), ), ], ), ); } Widget _chip({required IconData icon, required String value}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(999), border: Border.all(color: AppTheme.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: AppTheme.textHint), const SizedBox(width: 4), Text( value, style: const TextStyle( fontSize: 11.5, fontWeight: FontWeight.w500, color: AppTheme.textSecondary, ), ), ], ), ); } @override Widget build(BuildContext context) { final screenWidth = MediaQuery.sizeOf(context).width; final isTablet = screenWidth >= 700; final bgImageUrl = user.profileBackgroundImage; final hasBackground = bgImageUrl != null; final initials = '${user.firstName.isNotEmpty ? user.firstName[0] : ''}${user.lastName.isNotEmpty ? user.lastName[0] : ''}' .toUpperCase(); final subtitle = _subtitle(context, user); final categories = _categoryChips(context); final skills = _skillChips(); final hasDetails = categories.isNotEmpty || skills.isNotEmpty || user.languages.isNotEmpty; final ratingValue = user.rating > 0 ? user.rating.toStringAsFixed(1) : null; final reviewValue = user.reviewCount > 0 ? user.reviewCount.toString() : null; final planValue = user.planTier.trim().isNotEmpty ? user.planTier.toUpperCase() : 'FREE'; final headerMetrics = [ if (ratingValue != null) _metricMini( icon: Icons.star_rounded, value: ratingValue, color: AppTheme.amber, ), if (reviewValue != null) _metricMini(icon: Icons.rate_review_outlined, value: reviewValue), if (planValue != 'FREE') _metricMini( icon: Icons.workspace_premium_rounded, value: planValue, color: AppTheme.primary, ), ]; final detailChips = [ ...categories.map( (c) => _chip(icon: Icons.work_outline_rounded, value: c), ), ...skills.map((s) => _chip(icon: Icons.auto_awesome_rounded, value: s)), ...user.languages .take(2) .map( (lng) => _chip(icon: Icons.language_rounded, value: lng.toUpperCase()), ), ]; return LayoutBuilder( builder: (context, constraints) { final isCompact = constraints.maxWidth < 300; final visibleDetailChips = isCompact ? detailChips.take(1).toList() : isTablet ? detailChips.take(2).toList() : detailChips.take(5).toList(); final visibleMetrics = isCompact ? headerMetrics.take(2).toList() : headerMetrics; final titleSize = isCompact ? 14.0 : (isTablet ? 16.0 : 15.0); final subtitleSize = isCompact ? 12.0 : (isTablet ? 13.5 : 13.0); final bioSize = isCompact ? 11.5 : (isTablet ? 13.0 : 12.5); final avatarRadius = isCompact ? 21.0 : (isTablet ? 26.0 : 24.0); final avatarFontSize = isCompact ? 13.0 : 15.0; final avatarInnerBorder = isCompact ? 2.0 : 2.4; final cardPadding = isCompact ? 12.0 : (isTablet ? 16.0 : 14.0); final avatarGap = isCompact ? 10.0 : 12.0; return GestureDetector( onTap: onTap, child: Container( 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: ClipRRect( borderRadius: BorderRadius.circular(AppTheme.cardRadius - 1), child: Stack( children: [ if (bgImageUrl != null) Positioned.fill( child: Image.network( bgImageUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ), Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( color: bgImageUrl == null ? Colors.white : null, gradient: bgImageUrl != null ? LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.24), Colors.black.withValues(alpha: 0.52), ], ) : null, ), ), ), Padding( padding: EdgeInsets.all(cardPadding), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack( children: [ Container( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.white, width: avatarInnerBorder, ), boxShadow: const [ BoxShadow( color: Color(0x33000000), blurRadius: 6, offset: Offset(0, 2), ), ], ), child: CircleAvatar( radius: avatarRadius, foregroundImage: (user.avatar != null && user.avatar!.trim().isNotEmpty) ? NetworkImage(user.avatar!) : null, backgroundColor: AppTheme.primaryContainer, child: Text( initials.isNotEmpty ? initials : '?', style: TextStyle( color: AppTheme.primary, fontWeight: FontWeight.bold, fontSize: avatarFontSize, ), ), ), ), if (user.isOnline) Positioned( bottom: 0, right: 0, child: Container( width: isCompact ? 10 : 11, height: isCompact ? 10 : 11, decoration: BoxDecoration( color: AppTheme.success, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), ), ), ), if (user.hasVerifiedBadge) Positioned( top: -1, right: -1, child: Container( width: isCompact ? 15 : 16, height: isCompact ? 15 : 16, decoration: BoxDecoration( color: AppTheme.primary, borderRadius: BorderRadius.circular(99), border: Border.all( color: Colors.white, width: 1.5, ), ), child: const Icon( Icons.check_rounded, size: 11, color: Colors.white, ), ), ), ], ), SizedBox(width: avatarGap), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( user.fullName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: titleSize, fontWeight: FontWeight.w800, color: hasBackground ? Colors.white : AppTheme.textPrimary, shadows: hasBackground ? const [ Shadow( color: Color(0x88000000), blurRadius: 6, offset: Offset(0, 1), ), ] : null, ), ), ), if (visibleMetrics.isNotEmpty) ...[ const SizedBox(width: 6), Flexible( child: Align( alignment: Alignment.topRight, child: Wrap( alignment: WrapAlignment.end, spacing: 5, runSpacing: 5, children: visibleMetrics, ), ), ), ], ], ), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle, maxLines: isCompact ? 1 : 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: subtitleSize, fontWeight: FontWeight.w600, color: hasBackground ? Colors.white.withValues(alpha: 0.98) : AppTheme.textPrimary, shadows: hasBackground ? const [ Shadow( color: Color(0xAA000000), blurRadius: 4, offset: Offset(0, 1), ), ] : null, ), ), ], if (user.bio != null && user.bio!.isNotEmpty) ...[ SizedBox(height: isCompact ? 4 : 8), Text( user.bio!, maxLines: isCompact ? 1 : (isTablet ? 1 : 2), overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: bioSize, height: 1.25, fontWeight: FontWeight.w500, color: hasBackground ? Colors.white.withValues(alpha: 0.93) : AppTheme.textSecondary, shadows: hasBackground ? const [ Shadow( color: Color(0x99000000), blurRadius: 4, offset: Offset(0, 1), ), ] : null, ), ), ], if (hasDetails && visibleDetailChips.isNotEmpty) ...[ SizedBox(height: isCompact ? 6 : 8), Wrap( spacing: 6, runSpacing: 6, children: visibleDetailChips, ), ], ], ), ), ], ), ), ], ), ), ), ); }, ); } } // ─── Category tile ───────────────────────────────────────────────────────── class _CategoryTile extends StatelessWidget { final IconData? icon; final String? emoji; final Color color; final String label; final VoidCallback onTap; final bool selected; final bool hasChildren; const _CategoryTile({ this.icon, this.emoji, required this.color, required this.label, required this.onTap, this.selected = false, this.hasChildren = false, }); @override Widget build(BuildContext context) { final screenWidth = MediaQuery.sizeOf(context).width; final isTablet = screenWidth >= 700; final iconCircleSize = isTablet ? 66.0 : 58.0; final iconSize = isTablet ? 34.0 : 30.0; final labelFontSize = isTablet ? 13.5 : 12.0; final labelTopSpacing = isTablet ? 12.0 : 8.0; return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: selected ? AppTheme.primaryLight : Colors.white, borderRadius: BorderRadius.circular(AppTheme.cardRadius), border: Border.all( color: selected ? AppTheme.primary : AppTheme.border, width: selected ? 2 : 1, ), boxShadow: selected ? [] : const [ BoxShadow( color: AppTheme.shadowColor, blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Stack( children: [ Positioned.fill( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: iconCircleSize, height: iconCircleSize, decoration: BoxDecoration( color: color.withValues(alpha: 0.12), shape: BoxShape.circle, ), child: emoji != null ? Center( child: Text( emoji!, style: TextStyle(fontSize: iconSize), ), ) : Icon(icon, color: color, size: iconSize), ), SizedBox(height: labelTopSpacing), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( label, style: TextStyle( fontSize: labelFontSize, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ], ), // Column ), // Positioned.fill if (hasChildren) Positioned( top: 6, right: 6, child: Container( width: 16, height: 16, decoration: BoxDecoration( color: selected ? AppTheme.primary : AppTheme.border, shape: BoxShape.circle, ), child: Icon( Icons.expand_more, size: 12, color: selected ? Colors.white : AppTheme.textSecondary, ), ), ), ], ), ), ); } } // ─── Subcategory bottom sheet ──────────────────────────────────────────────── class _SubcategorySheet extends StatelessWidget { final Category parent; final String locale; final String? selectedSlug; final String allLabel; final void Function(String? slug, String? label) onSelect; const _SubcategorySheet({ required this.parent, required this.locale, required this.selectedSlug, required this.allLabel, required this.onSelect, }); @override Widget build(BuildContext context) { final parentName = parent.nameFor(locale); final isParentSelected = selectedSlug == parent.slug || selectedSlug == null; return SafeArea( top: false, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.8, ), child: Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Drag handle Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: AppTheme.border, borderRadius: BorderRadius.circular(2), ), ), ), // Title Text( parentName, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), const SizedBox(height: 16), // "All [Category]" chip _SubChip( label: '$allLabel $parentName', selected: isParentSelected, onTap: () { Navigator.pop(context); onSelect(parent.slug, parentName); }, ), const SizedBox(height: 12), // Subcategory chips grid Wrap( spacing: 8, runSpacing: 8, children: parent.children.map((child) { final childName = child.nameFor(locale); return _SubChip( label: childName, selected: selectedSlug == child.slug, onTap: () { Navigator.pop(context); onSelect(child.slug, childName); }, ); }).toList(), ), ], ), ), ), ), ); } } class _SubChip extends StatelessWidget { final String label; final bool selected; final VoidCallback onTap; const _SubChip({ required this.label, required this.selected, required this.onTap, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: selected ? AppTheme.primaryLight : AppTheme.background, borderRadius: BorderRadius.circular(AppTheme.chipRadius), border: Border.all( color: selected ? AppTheme.primary : AppTheme.border, width: selected ? 2 : 1, ), ), child: Text( label, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: selected ? AppTheme.primary : AppTheme.textPrimary, ), ), ), ); } } // ─── Search result tile ───────────────────────────────────────────────────── class _SearchResultTile extends StatelessWidget { final Task task; final VoidCallback onTap; const _SearchResultTile({required this.task, required this.onTap}); Color get _color { switch (task.category) { case 'cleaning': return const Color(0xFF22C55E); case 'plumbing': return const Color(0xFF3B82F6); case 'electrical': return const Color(0xFFF59E0B); case 'moving': return const Color(0xFF8B5CF6); case 'tutoring': return const Color(0xFF06B6D4); case 'beauty': return const Color(0xFFEC4899); case 'it_support': return const Color(0xFF6366F1); case 'repairs': return const Color(0xFFEF4444); default: return AppTheme.textSecondary; } } IconData get _icon { switch (task.category) { case 'cleaning': return Icons.cleaning_services_rounded; case 'plumbing': return Icons.plumbing_rounded; case 'electrical': return Icons.electrical_services_rounded; case 'moving': return Icons.local_shipping_rounded; case 'tutoring': return Icons.school_rounded; case 'beauty': return Icons.face_retouching_natural_rounded; case 'it_support': return Icons.computer_rounded; case 'repairs': return Icons.build_rounded; default: return Icons.category_rounded; } } @override Widget build(BuildContext context) { final color = _color; return GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.only(bottom: 10), 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(_icon, 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, ), if (task.location != null) ...[ const SizedBox(height: 3), Row( children: [ const Icon( Icons.location_on_rounded, size: 12, color: AppTheme.textHint, ), const SizedBox(width: 2), Text( task.location!, 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, ), ], ), ), ); } }