/opt/canhelp/apps/mobile/lib/screens/search
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,
),
],
),
),
);
}
}