/opt/canhelp/apps/mobile/lib/screens/specialists
Edit: /opt/canhelp/apps/mobile/lib/screens/specialists/specialists_screen.dart (59687B)
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../models/category.dart';
import '../../models/location.dart';
import '../../models/user.dart';
import '../../providers/favorites_provider.dart';
import '../../providers/locale_provider.dart';
import '../../providers/specialists_provider.dart';
import '../../services/api_client.dart';
import '../../theme/app_theme.dart';
import '../../widgets/plan_badge.dart';
class SpecialistsScreen extends StatefulWidget {
final String? initialCategory;
const SpecialistsScreen({super.key, this.initialCategory});
@override
State
createState() => _SpecialistsScreenState();
}
class _SpecialistsScreenState extends State {
final _searchCtrl = TextEditingController();
String? _selectedCategory;
String? _selectedLocation;
List _selectedLanguages = [];
final _scrollCtrl = ScrollController();
List _categories = [];
List _locationTree = [];
String? _lastLocale;
static const _fallbackSlugs = [
'cleaning',
'plumbing',
'electrical',
'moving',
'tutoring',
'beauty',
'it_support',
'repairs',
'other',
];
@override
void initState() {
super.initState();
_selectedCategory = widget.initialCategory;
WidgetsBinding.instance.addPostFrameCallback((_) {
_reload();
_loadFilterData();
});
_scrollCtrl.addListener(_onScroll);
}
Future _loadFilterData() async {
final api = ApiClient();
await Future.wait([
api
.getCategories()
.then((cats) {
if (mounted && cats.isNotEmpty) setState(() => _categories = cats);
})
.catchError((_) {}),
api
.getLocations()
.then((locs) {
if (mounted && locs.isNotEmpty) {
setState(() => _locationTree = locs);
}
})
.catchError((_) {}),
]);
}
void _onScroll() {
if (_scrollCtrl.position.pixels >=
_scrollCtrl.position.maxScrollExtent - 200) {
context.read().loadMore();
}
}
void _reload() {
final locale = context.read().locale;
context.read().search(
query: _searchCtrl.text.trim().isEmpty ? null : _searchCtrl.text.trim(),
category: _selectedCategory,
location: _selectedLocation,
languages: _selectedLanguages.isNotEmpty ? _selectedLanguages : null,
locale: locale,
reset: true,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = context.watch().locale;
if (_lastLocale != locale) {
_lastLocale = locale;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _reload();
});
}
}
@override
void dispose() {
_searchCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
int get _activeFilterCount =>
(_selectedCategory != null ? 1 : 0) +
(_selectedLocation != null ? 1 : 0) +
(_selectedLanguages.isNotEmpty ? 1 : 0);
void _showFilterSheet() {
final locale = context.read();
String? tempCategory = _selectedCategory;
String? tempLocation = _selectedLocation;
List tempLanguages = List.from(_selectedLanguages);
final categoryItems = _categories.isNotEmpty
? _categories
: _fallbackSlugs
.map(
(slug) => Category(
id: slug,
slug: slug,
icon: '',
names: {'en': locale.t('category.$slug')},
),
)
.toList();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (ctx, scrollController) => Container(
decoration: const BoxDecoration(
color: AppTheme.surfaceVariant,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// ── Header ──
Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
boxShadow: [
BoxShadow(
color: AppTheme.shadowColorLight,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Column(
children: [
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppTheme.border,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
Text(
locale.t('tasks.filters'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const Spacer(),
GestureDetector(
onTap: () {
setState(() {
_selectedCategory = null;
_selectedLocation = null;
_selectedLanguages = [];
});
Navigator.of(ctx).pop();
_reload();
},
child: Text(
locale.t('tasks.resetFilters'),
style: const TextStyle(
fontSize: 14,
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
// ── Scrollable content ──
Expanded(
child: ListView(
controller: scrollController,
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 100,
),
children: [
// Category
_SectionLabel(locale.t('tasks.category')),
const SizedBox(height: 8),
_DropdownPicker(
value: tempCategory,
placeholder: locale.t('tasks.allCategories2'),
items: [
_PickerItem(
label: locale.t('tasks.allCategories2'),
value: null,
isParent: false,
),
...categoryItems.map(
(cat) => _PickerItem(
label: cat.nameFor(locale.locale),
value: cat.slug,
icon: cat.uiIcon.isNotEmpty ? cat.uiIcon : null,
isParent: true,
children: cat.children
.map(
(sub) => _PickerItem(
label: sub.nameFor(locale.locale),
value: sub.slug,
isParent: false,
),
)
.toList(),
),
),
],
onChanged: (val) =>
setSheetState(() => tempCategory = val),
),
if (_locationTree.isNotEmpty) ...[
const SizedBox(height: 20),
// Location
_SectionLabel(locale.t('tasks.location')),
const SizedBox(height: 8),
_DropdownPicker(
value: tempLocation,
placeholder: locale.t('tasks.anyLocation'),
items: [
_PickerItem(
label: locale.t('tasks.anyLocation'),
value: null,
isParent: false,
),
..._locationTree.map(
(city) => _PickerItem(
label: city.nameFor(locale.locale),
value: city.slug,
isParent: true,
children: [
_PickerItem(
label:
'${locale.t('tasks.entireCity')} — ${city.nameFor(locale.locale)}',
value: city.slug,
isParent: false,
),
...city.children.map(
(d) => _PickerItem(
label: d.nameFor(locale.locale),
value: d.slug,
isParent: false,
),
),
],
),
),
],
onChanged: (val) =>
setSheetState(() => tempLocation = val),
),
],
const SizedBox(height: 20),
// Language
_SectionLabel(locale.t('profile.languages')),
const SizedBox(height: 8),
...[
('el', 'Ελληνικά'),
('en', 'English'),
('ru', 'Русский'),
('uk', 'Українська'),
('de', 'Deutsch'),
('fr', 'Français'),
('it', 'Italiano'),
('es', 'Español'),
('ar', 'العربية'),
('zh', '中文'),
].map((lang) {
final isSelected = tempLanguages.contains(lang.$1);
return InkWell(
onTap: () => setSheetState(() {
if (isSelected) {
tempLanguages = tempLanguages
.where((l) => l != lang.$1)
.toList();
} else {
tempLanguages = [...tempLanguages, lang.$1];
}
}),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 4,
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 20,
height: 20,
decoration: BoxDecoration(
color: isSelected
? AppTheme.primary
: Colors.transparent,
border: Border.all(
color: isSelected
? AppTheme.primary
: AppTheme.border,
width: 1.5,
),
borderRadius: BorderRadius.circular(5),
),
child: isSelected
? const Icon(
Icons.check,
size: 13,
color: Colors.white,
)
: null,
),
const SizedBox(width: 10),
Text(
lang.$2,
style: TextStyle(
fontSize: 14,
color: isSelected
? AppTheme.primary
: AppTheme.textPrimary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
),
],
),
),
);
}),
const SizedBox(height: 32),
],
),
),
// ── Apply button ──
Container(
color: Colors.white,
padding: EdgeInsets.fromLTRB(
20,
12,
20,
MediaQuery.of(ctx).padding.bottom + 12,
),
child: SizedBox(
height: 50,
child: ElevatedButton(
onPressed: () {
setState(() {
_selectedCategory = tempCategory;
_selectedLocation = tempLocation;
_selectedLanguages = tempLanguages;
});
Navigator.of(ctx).pop();
_reload();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.cardRadius,
),
),
),
child: Text(
locale.t('tasks.applyFilters'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
),
),
);
}
String _categoryLabel(String? slug, String locale) {
if (slug == null) return '';
for (final cat in _categories) {
if (cat.slug == slug) return cat.nameFor(locale);
for (final sub in cat.children) {
if (sub.slug == slug) return sub.nameFor(locale);
}
}
return slug;
}
String _locationLabel(String slug, String locale) {
for (final city in _locationTree) {
if (city.slug == slug) return city.nameFor(locale);
for (final d in city.children) {
if (d.slug == slug) return d.nameFor(locale);
}
}
return slug;
}
@override
Widget build(BuildContext context) {
final locale = context.watch();
final specialists = context.watch();
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
backgroundColor: AppTheme.background,
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: AppTheme.textPrimary,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20,
color: AppTheme.textPrimary,
),
onPressed: () => context.canPop() ? context.pop() : context.go('/'),
),
title: Text(
locale.t('nav.specialists'),
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 18,
color: AppTheme.textPrimary,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: _FilterPill(
activeCount: _activeFilterCount,
onTap: _showFilterSheet,
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(58),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: TextField(
controller: _searchCtrl,
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _reload(),
style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary),
decoration: InputDecoration(
hintText: locale.t('search.placeholder'),
hintStyle: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
prefixIcon: const Icon(
Icons.search,
size: 20,
color: AppTheme.textSecondary,
),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(
Icons.clear,
size: 18,
color: AppTheme.textSecondary,
),
onPressed: () {
_searchCtrl.clear();
_reload();
setState(() {});
},
)
: null,
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
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,
),
),
),
),
),
),
),
body: RefreshIndicator(
color: AppTheme.primary,
onRefresh: () async => _reload(),
child: CustomScrollView(
controller: _scrollCtrl,
slivers: [
// Active filter chips
if (_activeFilterCount > 0)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (_selectedCategory != null)
_ActiveFilterChip(
label: _categoryLabel(
_selectedCategory,
locale.locale,
),
onRemove: () {
setState(() => _selectedCategory = null);
_reload();
},
),
if (_selectedLocation != null)
_ActiveFilterChip(
label: _locationLabel(
_selectedLocation!,
locale.locale,
),
icon: Icons.location_on_outlined,
onRemove: () {
setState(() => _selectedLocation = null);
_reload();
},
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
// Main content
if (specialists.isLoading && specialists.specialists.isEmpty)
const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(color: AppTheme.primary),
),
)
else if (specialists.error != null &&
specialists.specialists.isEmpty)
SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.wifi_off_rounded,
size: 48,
color: AppTheme.textSecondary,
),
const SizedBox(height: 16),
Text(
specialists.error!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 20),
TextButton(
onPressed: _reload,
child: Text(
context.read().t('common.retry'),
),
),
],
),
),
),
)
else if (specialists.specialists.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 16,
offset: Offset(0, 4),
),
],
),
child: const Icon(
Icons.person_search_outlined,
size: 36,
color: AppTheme.primary,
),
),
const SizedBox(height: 16),
Text(
locale.t('specialists.empty'),
style: const TextStyle(
fontSize: 15,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 2, 12, 40),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) {
if (i == specialists.specialists.length) {
return const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
),
),
);
}
return _SpecialistCard(user: specialists.specialists[i]);
},
childCount:
specialists.specialists.length +
(specialists.hasMore ? 1 : 0),
),
),
),
],
),
),
);
}
}
// ── Filter pill ────────────────────────────────────────────────────────────────
class _FilterPill extends StatelessWidget {
final int activeCount;
final VoidCallback onTap;
const _FilterPill({required this.activeCount, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: activeCount > 0 ? AppTheme.primary : Colors.white,
borderRadius: BorderRadius.circular(AppTheme.chipRadius),
border: Border.all(
color: activeCount > 0 ? AppTheme.primary : AppTheme.border,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tune_rounded,
size: 16,
color: activeCount > 0 ? Colors.white : const Color(0xFF475569),
),
if (activeCount > 0) ...[
const SizedBox(width: 4),
Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$activeCount',
style: const TextStyle(
fontSize: 10,
color: AppTheme.primary,
fontWeight: FontWeight.w700,
),
),
),
),
],
],
),
),
);
}
}
// ── Active filter chip ─────────────────────────────────────────────────────────
class _ActiveFilterChip extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback onRemove;
const _ActiveFilterChip({
required this.label,
this.icon,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppTheme.primaryContainer,
borderRadius: BorderRadius.circular(AppTheme.chipRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: AppTheme.primary),
const SizedBox(width: 3),
],
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.close, size: 13, color: AppTheme.primary),
),
],
),
);
}
}
// ── Section label ──────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
letterSpacing: 0.4,
),
);
}
}
// ── Dropdown picker ────────────────────────────────────────────────────────────
class _PickerItem {
final String label;
final String? value;
final bool isParent;
final String? icon;
final List<_PickerItem> children;
const _PickerItem({
required this.label,
required this.value,
required this.isParent,
this.icon,
this.children = const [],
});
}
class _DropdownPicker extends StatefulWidget {
final String? value;
final String placeholder;
final List<_PickerItem> items;
final ValueChanged onChanged;
const _DropdownPicker({
required this.value,
required this.placeholder,
required this.items,
required this.onChanged,
});
@override
State<_DropdownPicker> createState() => _DropdownPickerState();
}
class _DropdownPickerState extends State<_DropdownPicker> {
bool _open = false;
String? _expandedParent;
String _labelFor(String? val) {
if (val == null) return widget.placeholder;
for (final item in widget.items) {
if (item.value == val) return item.label;
for (final child in item.children) {
if (child.value == val) return child.label;
}
}
return widget.placeholder;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
onTap: () => setState(() {
_open = !_open;
if (!_open) _expandedParent = null;
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: _open
? const BorderRadius.vertical(top: Radius.circular(12))
: BorderRadius.circular(AppTheme.inputRadius),
border: Border.all(
color: _open ? AppTheme.primary : AppTheme.border,
),
),
child: Row(
children: [
Expanded(
child: Text(
_labelFor(widget.value),
style: TextStyle(
fontSize: 14,
color: widget.value == null
? AppTheme.textHint
: AppTheme.textPrimary,
fontWeight: widget.value == null
? FontWeight.w400
: FontWeight.w500,
),
),
),
Icon(
_open
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 20,
color: AppTheme.textHint,
),
],
),
),
),
if (_open)
Container(
constraints: const BoxConstraints(maxHeight: 240),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)),
border: Border(
left: BorderSide(color: AppTheme.primary),
right: BorderSide(color: AppTheme.primary),
bottom: BorderSide(color: AppTheme.primary),
),
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(height: 1, color: AppTheme.border),
...widget.items.map((item) {
if (!item.isParent || item.children.isEmpty) {
return _PickerRow(
label: item.label,
icon: item.icon,
selected: widget.value == item.value,
indent: false,
onTap: () {
widget.onChanged(item.value);
setState(() {
_open = false;
_expandedParent = null;
});
},
);
}
final expanded = _expandedParent == item.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PickerRow(
label: item.label,
icon: item.icon,
selected: false,
indent: false,
trailing: Icon(
expanded
? Icons.expand_less_rounded
: Icons.chevron_right_rounded,
size: 18,
color: AppTheme.textHint,
),
onTap: () => setState(() {
_expandedParent = expanded ? null : item.value;
}),
),
if (expanded)
...item.children.map(
(child) => _PickerRow(
label: child.label,
selected: widget.value == child.value,
indent: true,
onTap: () {
widget.onChanged(child.value);
setState(() {
_open = false;
_expandedParent = null;
});
},
),
),
],
);
}),
],
),
),
),
],
);
}
}
class _PickerRow extends StatelessWidget {
final String label;
final String? icon;
final bool selected;
final bool indent;
final Widget? trailing;
final VoidCallback onTap;
const _PickerRow({
required this.label,
this.icon,
required this.selected,
required this.indent,
this.trailing,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
child: InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.only(
left: indent ? 28 : 14,
right: 14,
top: 10,
bottom: 10,
),
child: Row(
children: [
if (icon != null && icon!.isNotEmpty) ...[
Text(icon!, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
],
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: selected
? AppTheme.primary
: const Color(0xFF1E293B),
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
if (selected)
const Icon(
Icons.check_rounded,
size: 16,
color: AppTheme.primary,
)
else if (trailing != null)
trailing!,
],
),
),
),
);
}
}
// ── Specialist card ────────────────────────────────────────────────────────────
class _SpecialistCard extends StatelessWidget {
final User user;
const _SpecialistCard({required this.user});
@override
Widget build(BuildContext context) {
final favorites = context.watch();
final isFav = favorites.isFavorite(user.id);
final initials =
'${user.firstName.isNotEmpty ? user.firstName[0] : ''}${user.lastName.isNotEmpty ? user.lastName[0] : ''}'
.toUpperCase();
return GestureDetector(
onTap: () => context.push('/specialists/${user.id}'),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header row: avatar + name + rating + fav ──
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Stack(
children: [
CircleAvatar(
radius: 26,
backgroundImage: user.avatar != null
? NetworkImage(user.avatar!)
: null,
backgroundColor: AppTheme.primaryContainer,
child: user.avatar == null
? Text(
initials.isNotEmpty ? initials : '?',
style: const TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.bold,
fontSize: 15,
),
)
: null,
),
if (user.isOnline)
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: AppTheme.emerald,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
),
const SizedBox(width: 12),
// Name + rating
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
user.fullName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
PlanBadge(tier: user.planTier, small: true),
],
),
const SizedBox(height: 4),
Row(
children: [
if (user.rating > 0) ...[
const Icon(
Icons.star_rounded,
size: 14,
color: AppTheme.amber,
),
const SizedBox(width: 3),
Text(
user.rating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
if (user.reviewCount > 0) ...[
const SizedBox(width: 3),
Text(
'(${user.reviewCount})',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
const SizedBox(width: 8),
],
if (user.activeStatuses.contains('can_help_now'))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFF0FDF4),
border: Border.all(
color: const Color(0xFFBBF7D0),
),
borderRadius: BorderRadius.circular(
AppTheme.chipRadius,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppTheme.success,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
context.watch().t(
'status.canHelpNow',
),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFF15803D),
),
),
],
),
)
else if (user.todayStatus == 'available')
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: AppTheme.successLight,
borderRadius: BorderRadius.circular(
AppTheme.chipRadius,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppTheme.success,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
context.watch().t(
'schedule.available',
),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFF15803D),
),
),
],
),
)
else if (user.todayStatus == 'busy')
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(
AppTheme.chipRadius,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppTheme.danger,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
context.watch().t(
'schedule.busy',
),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFFB91C1C),
),
),
],
),
)
else if (user.isOnline)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: AppTheme.successLight,
borderRadius: BorderRadius.circular(
AppTheme.chipRadius,
),
),
child: Text(
context.watch().t(
'specialists.online',
),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFF15803D),
),
),
),
],
),
],
),
),
// Favorite button
GestureDetector(
onTap: () => favorites.toggle(user.id, user),
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
isFav
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 22,
color: isFav
? AppTheme.danger
: const Color(0xFFCBD5E1),
),
),
),
],
),
// ── Card titles (service cards) ──
if (user.cards.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
...user.cards
.take(3)
.map(
(card) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(
AppTheme.chipRadius,
),
),
child: Text(
card['title'] as String? ?? '',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
),
),
if (user.cards.length > 3)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
child: Text(
'+${user.cards.length - 3}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
),
],
),
] else if (user.skills != null && user.skills!.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 4,
children: user.skills!
.take(3)
.map(
(s) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppTheme.background,
borderRadius: BorderRadius.circular(
AppTheme.smallRadius,
),
),
child: Text(
s,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
),
)
.toList(),
),
],
// ── Bio ──
if (user.bio != null && user.bio!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
user.bio!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
],
// ── Categories + location + languages ──
if ((user.specialistCategories != null &&
user.specialistCategories!.isNotEmpty) ||
(user.specialistLocations != null &&
user.specialistLocations!.isNotEmpty) ||
user.languages.isNotEmpty ||
user.hasVerifiedBadge) ...[
const SizedBox(height: 10),
const Divider(height: 1, color: AppTheme.border),
const SizedBox(height: 10),
Wrap(
spacing: 12,
runSpacing: 6,
children: [
if (user.hasVerifiedBadge)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.verified_rounded,
size: 14,
color: AppTheme.primary,
),
const SizedBox(width: 4),
Text(
context.watch().t(
'profile.verifiedBadge',
'Verified',
),
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
if (user.specialistCategories != null &&
user.specialistCategories!.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.category_outlined,
size: 14,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
Text(
user.specialistCategories!.take(2).join(', '),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
if (user.specialistLocations != null &&
user.specialistLocations!.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.location_on_outlined,
size: 14,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
Text(
user.specialistLocations!.take(2).join(', '),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
if (user.languages.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.language_outlined,
size: 14,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
Text(
user.languages.take(3).join(', ').toUpperCase(),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
],
),
],
],
),
),
),
);
}
}