/opt/canhelp/apps/mobile/lib/screens/specialists
Edit: /opt/canhelp/apps/mobile/lib/screens/specialists/specialist_profile_screen.dart (83697B)
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/location.dart';
import '../../models/portfolio_item.dart';
import '../../models/price_list.dart';
import '../../models/review.dart';
import '../../models/specialist_card.dart';
import '../../models/user.dart';
import '../../providers/auth_provider.dart';
import '../../providers/categories_provider.dart';
import '../../providers/favorites_provider.dart';
import '../../providers/locale_provider.dart';
import '../../providers/locations_provider.dart';
import '../../services/api_client.dart';
import '../../services/storage_service.dart';
import '../../theme/app_theme.dart';
import '../../widgets/upgrade_prompt.dart';
class SpecialistProfileScreen extends StatefulWidget {
final String userId;
const SpecialistProfileScreen({super.key, required this.userId});
@override
State
createState() =>
_SpecialistProfileScreenState();
}
class _SpecialistProfileScreenState extends State {
User? _user;
List _reviews = [];
List _cards = [];
List _portfolio = [];
bool _isLoading = true;
String? _error;
bool _isBlockedView = false;
bool? _canMessage;
bool _isStartingChat = false;
bool _titleVisible = false;
String? _blockedDisplayName;
final ScrollController _scrollController = ScrollController();
final StorageService _storage = StorageService();
// Height at which name disappears: expandedHeight minus toolbar
static const double _expandedHeight = 220;
static const double _titleThreshold = _expandedHeight - kToolbarHeight;
String? _lastLoadedLocale;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = context.watch().locale;
if (_lastLoadedLocale != locale) {
_lastLoadedLocale = locale;
_load();
}
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
void _onScroll() {
final show = _scrollController.offset > _titleThreshold;
if (show != _titleVisible) setState(() => _titleVisible = show);
}
Future _load() async {
setState(() {
_isLoading = true;
_error = null;
_isBlockedView = false;
_blockedDisplayName = null;
});
try {
if (await _storage.isUserBlocked(widget.userId)) {
final blockedUsers = await _storage.getBlockedUsers();
final blockedUser = blockedUsers
.where((u) => u.userId == widget.userId)
.cast()
.firstWhere((u) => u != null, orElse: () => null);
if (mounted) {
final locale = context.read();
setState(() {
_isLoading = false;
_isBlockedView = true;
_error = locale.t('safety.userBlocked', 'This user is blocked');
_blockedDisplayName = blockedUser?.displayName;
});
}
return;
}
final locale = context.read().locale;
final result = await ApiClient().getSpecialist(
widget.userId,
locale: locale,
);
setState(() {
_user = result.user;
_reviews = result.reviews;
_cards = result.cards;
});
_checkCanMessage();
// Load portfolio in background (non-critical)
ApiClient()
.getUserPortfolio(widget.userId)
.then((items) {
if (mounted) setState(() => _portfolio = items);
})
.catchError((_) {});
} catch (e) {
setState(() {
_isBlockedView = false;
_error = e.toString();
});
} finally {
setState(() => _isLoading = false);
}
}
Future _unblockAndReload() async {
final locale = context.read();
try {
await _storage.unblockUser(widget.userId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locale.t('safety.unblocked', 'User unblocked'))),
);
await _load();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${locale.t('common.error', 'Error')}: $e')),
);
}
}
Future _checkCanMessage() async {
try {
final data = await ApiClient().canMessageUser(widget.userId);
if (mounted) {
setState(() => _canMessage = data['allowed'] as bool? ?? true);
}
} catch (_) {
if (mounted) setState(() => _canMessage = true);
}
}
Future _startChat() async {
if (_isStartingChat) return;
setState(() => _isStartingChat = true);
try {
final result = await ApiClient().getChatDirectRoom(_user!.id);
if (mounted) context.push('/chat/${result.room.id}');
} catch (_) {
// ignore — room already shown or network error
} finally {
if (mounted) setState(() => _isStartingChat = false);
}
}
Future _shareSpecialistProfile(BuildContext originContext) async {
final user = _user;
if (user == null) return;
final locale = context.read();
final slug = user.personalSiteSlug?.trim() ?? '';
final url = slug.isNotEmpty
? 'https://canhelp.gr/site/$slug'
: 'https://canhelp.gr/specialists/${user.id}';
final text = '${user.fullName}\n$url';
try {
final box = originContext.findRenderObject() as RenderBox?;
final origin = box != null && box.hasSize
? box.localToGlobal(Offset.zero) & box.size
: null;
await Share.share(text, sharePositionOrigin: origin);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
locale.t('specialist.shareError', 'Не удалось поделиться профилем'),
),
),
);
}
}
Future _openPersonalSite() async {
final user = _user;
if (user == null) return;
final slug = user.personalSiteSlug?.trim() ?? '';
if (slug.isEmpty) return;
final uri = Uri.parse('https://canhelp.gr/site/$slug');
final locale = context.read();
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
locale.t(
'profile.personalSiteOpenError',
'Не удалось открыть сайт',
),
),
),
);
}
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
locale.t(
'profile.personalSiteOpenError',
'Не удалось открыть сайт',
),
),
),
);
}
}
Future _reportCurrentUser() async {
final user = _user;
if (user == null) return;
final locale = context.read();
final auth = context.read();
if (!auth.isAuthenticated) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(locale.t('auth.loginRequired'))));
context.push('/login');
return;
}
final reasons = ['spam', 'inappropriate', 'fraud', 'other'];
String selected = reasons[1];
String reasonLabel(String reason) {
switch (reason) {
case 'spam':
return locale.t('safety.reasonSpam', 'Spam');
case 'inappropriate':
return locale.t('safety.reasonInappropriate', 'Inappropriate');
case 'fraud':
return locale.t('safety.reasonFraud', 'Fraud');
default:
return locale.t('safety.reasonOther', 'Other');
}
}
final confirmed = await showDialog(
context: context,
builder: (_) => StatefulBuilder(
builder: (ctx, setLocal) => AlertDialog(
title: Text(locale.t('safety.reportUserTitle', 'Report user')),
content: Column(
mainAxisSize: MainAxisSize.min,
children: reasons
.map(
(r) => RadioListTile(
value: r,
groupValue: selected,
contentPadding: EdgeInsets.zero,
title: Text(reasonLabel(r)),
onChanged: (v) => setLocal(() => selected = v ?? selected),
),
)
.toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(locale.t('common.cancel', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(locale.t('common.send', 'Send')),
),
],
),
),
);
if (confirmed != true) return;
try {
await ApiClient().createReport(
targetType: 'user',
targetId: user.id,
reason: selected,
description: 'Reported from specialist profile',
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locale.t('safety.reportSent', 'Report sent'))),
);
} catch (e) {
if (!mounted) return;
if (e is ApiException && e.statusCode == 401) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(locale.t('auth.loginRequired'))));
context.push('/login');
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${locale.t('common.error', 'Error')}: $e')),
);
}
}
Future _blockCurrentUser() async {
final user = _user;
if (user == null) return;
final locale = context.read();
final ok = await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(locale.t('safety.blockUserTitle', 'Block this user?')),
content: Text(
locale.t(
'safety.blockUserDesc',
'You will no longer see this user\'s content in chats and specialist lists.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(locale.t('common.cancel', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(locale.t('common.block', 'Block')),
),
],
),
);
if (ok != true) return;
await _storage.blockUser(
user.id,
displayName: user.fullName,
avatarUrl: user.avatar,
);
try {
await ApiClient().createReport(
targetType: 'user',
targetId: user.id,
reason: 'inappropriate',
description: 'User blocked from specialist profile',
);
} catch (_) {}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
locale.t(
'safety.blockedNowHidden',
'User has been blocked and hidden from your feed',
),
),
),
);
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
}
String _formatDate(DateTime dt) =>
'${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}';
static const _langNames = {
'el': {'el': 'Ελληνικά', 'en': 'Greek', 'ru': 'Греческий', 'uk': 'Грецька'},
'en': {
'el': 'Αγγλικά',
'en': 'English',
'ru': 'Английский',
'uk': 'Англійська',
},
'ru': {'el': 'Ρωσικά', 'en': 'Russian', 'ru': 'Русский', 'uk': 'Російська'},
'uk': {
'el': 'Ουκρανικά',
'en': 'Ukrainian',
'ru': 'Украинский',
'uk': 'Українська',
},
'de': {
'el': 'Γερμανικά',
'en': 'German',
'ru': 'Немецкий',
'uk': 'Німецька',
},
'fr': {
'el': 'Γαλλικά',
'en': 'French',
'ru': 'Французский',
'uk': 'Французька',
},
'it': {
'el': 'Ιταλικά',
'en': 'Italian',
'ru': 'Итальянский',
'uk': 'Італійська',
},
'es': {
'el': 'Ισπανικά',
'en': 'Spanish',
'ru': 'Испанский',
'uk': 'Іспанська',
},
'ar': {'el': 'Αραβικά', 'en': 'Arabic', 'ru': 'Арабский', 'uk': 'Арабська'},
'zh': {
'el': 'Κινεζικά',
'en': 'Chinese',
'ru': 'Китайский',
'uk': 'Китайська',
},
'tr': {
'el': 'Τουρκικά',
'en': 'Turkish',
'ru': 'Турецкий',
'uk': 'Турецька',
},
'pl': {
'el': 'Πολωνικά',
'en': 'Polish',
'ru': 'Польский',
'uk': 'Польська',
},
'bg': {
'el': 'Βουλγαρικά',
'en': 'Bulgarian',
'ru': 'Болгарский',
'uk': 'Болгарська',
},
'ro': {
'el': 'Ρουμανικά',
'en': 'Romanian',
'ru': 'Румынский',
'uk': 'Румунська',
},
'sr': {
'el': 'Σερβικά',
'en': 'Serbian',
'ru': 'Сербский',
'uk': 'Сербська',
},
};
String _localizeLanguage(String code, String uiLocale) {
final map = _langNames[code.toLowerCase()];
if (map == null) return code.toUpperCase();
return map[uiLocale] ?? map['en'] ?? code.toUpperCase();
}
List _portfolioForCard(String cardId) =>
_portfolio.where((item) => (item.cardId ?? '').trim() == cardId).toList();
List get _commonPortfolio =>
_portfolio.where((item) => (item.cardId ?? '').trim().isEmpty).toList();
void _openImageViewer(String imageUrl) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _FullscreenImageViewer(imageUrl: imageUrl),
),
);
}
@override
Widget build(BuildContext context) {
final locale = context.watch();
final auth = context.watch();
final favorites = context.watch();
if (_isLoading) {
return const _SpecialistProfileSkeleton();
}
if (_error != null || _user == null) {
if (_isBlockedView) {
final blockedTitle =
(_blockedDisplayName != null &&
_blockedDisplayName!.trim().isNotEmpty)
? _blockedDisplayName!.trim()
: '#${widget.userId.substring(0, widget.userId.length > 8 ? 8 : widget.userId.length)}';
return Scaffold(
backgroundColor: const Color(0xFFFFF5F5),
appBar: AppBar(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white,
title: Text(
blockedTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.block_rounded,
size: 64,
color: AppTheme.danger,
),
const SizedBox(height: 16),
Text(
_error ?? locale.t('safety.userBlocked'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: AppTheme.danger,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 22),
FilledButton(
onPressed: _unblockAndReload,
style: FilledButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white,
),
child: Text(locale.t('safety.unblockUser', 'Unblock user')),
),
],
),
),
),
);
}
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(backgroundColor: AppTheme.primary),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.person_off_outlined,
size: 56,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
Text(
_error ?? locale.t('error.unknown'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 20),
TextButton(
onPressed: _load,
child: Text(locale.t('common.retry')),
),
],
),
),
),
);
}
final user = _user!;
final isFav = favorites.isFavorite(user.id);
final isMe = auth.user?.id == user.id;
final initials =
'${user.firstName.isNotEmpty ? user.firstName[0] : ''}${user.lastName.isNotEmpty ? user.lastName[0] : ''}'
.toUpperCase();
final hasCustomBackground =
user.profileBackgroundImage != null &&
user.profileBackgroundImage!.isNotEmpty;
return Scaffold(
backgroundColor: AppTheme.background,
// ── Sticky "Send message" bar ────────────────────────────
bottomNavigationBar: isMe
? null
: Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: AppTheme.border)),
boxShadow: [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 12,
offset: Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: !auth.isAuthenticated
? SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
icon: const Icon(
Icons.chat_bubble_outline_rounded,
size: 18,
),
label: Text(
locale.t('specialist.contact'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
onPressed: () => context.push('/login'),
),
)
: _canMessage == false
? UpgradePrompt(
feature: locale.t('specialist.contact'),
requiredTier: 'pro',
variant: 'block',
)
: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
icon: _isStartingChat
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.chat_bubble_outline_rounded,
size: 18,
),
label: Text(
locale.t('specialist.contact'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
onPressed: _isStartingChat ? null : _startChat,
),
),
),
),
body: CustomScrollView(
controller: _scrollController,
slivers: [
// ── Hero header ────────────────────────────────────
SliverAppBar(
expandedHeight: _expandedHeight,
pinned: true,
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
leading: Padding(
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6),
child: _HeaderCircleButton(
icon: Icons.arrow_back_ios_new_rounded,
tooltip: locale.t('common.back', 'Назад'),
onTap: () =>
context.canPop() ? context.pop() : context.go('/home'),
),
),
title: _titleVisible
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
user.fullName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
_ReadablePlanBadge(tier: user.planTier, compact: true),
],
)
: null,
actions: [
Builder(
builder: (actionContext) => Padding(
padding: const EdgeInsets.only(
left: 6,
right: 4,
top: 6,
bottom: 6,
),
child: _HeaderCircleButton(
icon: Icons.ios_share_rounded,
tooltip: locale.t('common.share', 'Share'),
onTap: () => _shareSpecialistProfile(actionContext),
),
),
),
if (!isMe)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
child: _HeaderCircleButton(
icon: isFav
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
tooltip: locale.t('common.favorites', 'Favorites'),
onTap: auth.isAuthenticated
? () => favorites.toggle(user.id, user)
: () => context.push('/login'),
),
),
if (!isMe)
Padding(
padding: const EdgeInsets.only(
left: 4,
right: 10,
top: 6,
bottom: 6,
),
child: _HeaderSafetyMenuButton(
locale: locale,
onReport: _reportCurrentUser,
onBlock: _blockCurrentUser,
),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: hasCustomBackground
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppTheme.primary, AppTheme.primaryDark],
),
image: hasCustomBackground
? DecorationImage(
image: NetworkImage(user.profileBackgroundImage!),
fit: BoxFit.cover,
)
: null,
),
child: Container(
decoration: BoxDecoration(
gradient: hasCustomBackground
? null
: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.18),
Colors.black.withValues(alpha: 0.34),
],
),
),
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const SizedBox(height: 12),
Container(
margin: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.34),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.24),
),
),
child: Row(
children: [
Stack(
alignment: Alignment.bottomRight,
children: [
CircleAvatar(
radius: 36,
backgroundImage: user.avatar != null
? NetworkImage(user.avatar!)
: null,
backgroundColor: Colors.white.withValues(
alpha: 0.2,
),
child: user.avatar == null
? Text(
initials.isNotEmpty
? initials
: '?',
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.w600,
),
)
: null,
),
if (user.isOnline)
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: AppTheme.emerald,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
crossAxisAlignment:
WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Text(
user.fullName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
_ReadablePlanBadge(
tier: user.planTier,
compact: true,
),
],
),
const SizedBox(height: 6),
Row(
children: [
if (user.rating > 0) ...[
const Icon(
Icons.star_rounded,
size: 15,
color: Color(0xFFFBBF24),
),
const SizedBox(width: 3),
Text(
'${user.rating.toStringAsFixed(1)} (${user.reviewCount})',
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
const SizedBox(width: 8),
],
if (user.isOnline)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppTheme.emerald
.withValues(alpha: 0.2),
borderRadius:
BorderRadius.circular(
AppTheme.chipRadius,
),
border: Border.all(
color: AppTheme.emerald
.withValues(alpha: 0.5),
),
),
child: Text(
context.read().t(
'specialists.online',
),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
],
),
),
const SizedBox(height: 16),
],
),
),
),
),
),
),
// ── Content ────────────────────────────────────────
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status badges (verified + canHelp now)
if (user.hasVerifiedBadge ||
user.activeStatuses.isNotEmpty) ...[
Wrap(
spacing: 8,
runSpacing: 6,
children: [
if (user.hasVerifiedBadge)
_StatusChip(
label: locale.t('profile.verifiedBadge'),
color: AppTheme.primary,
icon: Icons.verified_rounded,
),
if (user.activeStatuses.contains('canhelp_now'))
_StatusChip(
label: locale.t('status.canHelpNow'),
color: AppTheme.success,
icon: Icons.bolt_rounded,
),
if (user.activeStatuses.contains('need_help'))
_StatusChip(
label: locale.t('status.needHelp'),
color: AppTheme.warning,
icon: Icons.help_outline_rounded,
),
],
),
const SizedBox(height: 14),
],
// Bio
if (user.bio != null && user.bio!.isNotEmpty) ...[
_SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(locale.t('profile.bio')),
const SizedBox(height: 8),
Text(
user.bio!,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 10),
],
// Skills (profile-level)
if (user.skills != null && user.skills!.isNotEmpty) ...[
_SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(locale.t('profile.skills')),
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: user.skills!
.map(
(s) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: AppTheme.primaryContainer,
borderRadius: BorderRadius.circular(
AppTheme.chipRadius,
),
),
child: Text(
s,
style: const TextStyle(
fontSize: 13,
color: AppTheme.primaryDark,
fontWeight: FontWeight.w500,
),
),
),
)
.toList(),
),
],
),
),
const SizedBox(height: 10),
],
// Specialist cards (services)
if (_cards.isNotEmpty) ...[
_SectionTitle(locale.t('profile.services')),
const SizedBox(height: 8),
..._cards.map(
(card) => _SpecialistCardTile(
card: card,
cardPortfolio: _portfolioForCard(card.id),
),
),
const SizedBox(height: 6),
],
// Info card
_SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(
icon: Icons.calendar_today_outlined,
label: locale.t('profile.memberSince'),
value: _formatDate(user.createdAt),
),
if (user.languages.isNotEmpty) ...[
const SizedBox(height: 10),
_InfoRow(
icon: Icons.language_outlined,
label: locale.t('profile.languages'),
value: user.languages
.map(
(code) =>
_localizeLanguage(code, locale.locale),
)
.join(', '),
),
],
if (user.phone != null && user.phone!.isNotEmpty) ...[
const SizedBox(height: 10),
_InfoRow(
icon: Icons.phone_outlined,
label: locale.t('profile.phone'),
value: user.phone!,
),
],
if (user.personalSiteSlug != null &&
user.personalSiteSlug!.isNotEmpty) ...[
const SizedBox(height: 10),
_InfoRow(
icon: Icons.link_rounded,
label: locale.t('profile.personalSite'),
value: 'canhelp.gr/site/${user.personalSiteSlug!}',
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openPersonalSite,
icon: const Icon(
Icons.open_in_new_rounded,
size: 18,
),
label: Text(
locale.t(
'profile.openPersonalSite',
'Открыть сайт',
),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(color: AppTheme.primary),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
),
),
],
],
),
),
// Stats
const SizedBox(height: 10),
_SectionCard(
child: Row(
children: [
Expanded(
child: _StatCell(
icon: Icons.star_rounded,
color: AppTheme.amber,
value: user.rating > 0
? user.rating.toStringAsFixed(1)
: '—',
label: locale.t('profile.rating'),
),
),
Container(width: 1, height: 40, color: AppTheme.border),
Expanded(
child: _StatCell(
icon: Icons.check_circle_outline_rounded,
color: AppTheme.primary,
value: '${user.reviewCount}',
label: locale.t('profile.completedJobs'),
),
),
Container(width: 1, height: 40, color: AppTheme.border),
Expanded(
child: _StatCell(
icon: Icons.thumb_up_alt_outlined,
color: AppTheme.success,
value: user.reviewCount > 0
? '${(user.rating / 5 * 100).round()}%'
: '—',
label: locale.t('profile.positive'),
),
),
Container(width: 1, height: 40, color: AppTheme.border),
Expanded(
child: _StatCell(
icon: Icons.calendar_today_outlined,
color: AppTheme.textSecondary,
value: () {
final years =
DateTime.now().year - user.createdAt.year;
return years > 0 ? '$years' : '<1';
}(),
label: locale.t('profile.yearsOn'),
),
),
],
),
),
// Portfolio
if (_commonPortfolio.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionTitle(locale.t('portfolio.title', 'Portfolio')),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: _commonPortfolio.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final item = _commonPortfolio[i];
return GestureDetector(
onTap: () => _openImageViewer(item.imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppTheme.smallRadius,
),
child: Image.network(
item.imageUrl,
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 120,
height: 120,
color: AppTheme.border,
child: const Icon(
Icons.broken_image_outlined,
color: AppTheme.textHint,
),
),
),
),
);
},
),
),
],
// Reviews
if (_reviews.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionTitle(locale.t('profile.reviews')),
const SizedBox(height: 10),
..._reviews.map((r) => _ReviewCard(review: r)),
],
],
),
),
),
],
),
);
}
}
// ── Status chip ───────────────────────────────────────────────────────────────
class _StatusChip extends StatelessWidget {
final String label;
final Color color;
final IconData icon;
const _StatusChip({
required this.label,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppTheme.chipRadius),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ── Info row ──────────────────────────────────────────────────────────────────
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoRow({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: AppTheme.textHint),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(fontSize: 13, height: 1.4),
children: [
TextSpan(
text: '$label: ',
style: const TextStyle(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
TextSpan(
text: value,
style: const TextStyle(color: AppTheme.textPrimary),
),
],
),
),
),
],
);
}
}
// ── Specialist card tile ──────────────────────────────────────────────────────
class _LocGroup {
final Location parent;
final List children;
_LocGroup({required this.parent, required this.children});
}
class _SpecialistCardTile extends StatefulWidget {
final SpecialistCard card;
final List cardPortfolio;
const _SpecialistCardTile({
required this.card,
this.cardPortfolio = const [],
});
@override
State<_SpecialistCardTile> createState() => _SpecialistCardTileState();
}
class _SpecialistCardTileState extends State<_SpecialistCardTile> {
List _priceList = [];
String? _lastLoadedLocale;
Future _loadPriceList(String locale) async {
_lastLoadedLocale = locale;
ApiClient()
.getPriceList(widget.card.id, locale: locale)
.then((data) {
if (mounted) setState(() => _priceList = data);
})
.catchError((_) {});
}
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = context.watch().locale;
if (_lastLoadedLocale != locale) {
_loadPriceList(locale);
}
}
List<_LocGroup> _buildGroups(
List slugs,
List allLocations,
) {
final Map groups = {};
for (final slug in slugs) {
Location? found;
Location? parentLoc;
outer:
for (final city in allLocations) {
if (city.slug == slug) {
found = city;
break;
}
for (final child in city.children) {
if (child.slug == slug) {
found = child;
parentLoc = city;
break outer;
}
}
}
if (found == null) continue;
if (parentLoc == null) {
groups.putIfAbsent(slug, () => _LocGroup(parent: found!, children: []));
} else {
groups.putIfAbsent(
parentLoc.slug,
() => _LocGroup(parent: parentLoc!, children: []),
);
groups[parentLoc.slug]!.children.add(found);
}
}
return groups.values.toList();
}
@override
Widget build(BuildContext context) {
final locsProvider = context.watch();
final catsProvider = context.watch();
final locale = context.watch().locale;
final card = widget.card;
final locGroups = _buildGroups(card.locations, locsProvider.locations);
final cardTitle = card.getLocalizedTitle(locale);
final cardDescription = card.getLocalizedDescription(locale);
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cardTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
if (cardDescription != null && cardDescription.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
cardDescription,
softWrap: true,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
],
// Skills
if (card.skills.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 5,
runSpacing: 5,
children: card.skills.map((s) => _SkillChip(label: s)).toList(),
),
],
// Categories (localized)
if (card.categories.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 5,
runSpacing: 5,
children: card.categories.map((slug) {
final name = catsProvider.nameFor(slug, locale);
return _TagChip(label: name);
}).toList(),
),
],
// Card gallery images
if (widget.cardPortfolio.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 80,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: widget.cardPortfolio.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final item = widget.cardPortfolio[i];
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
_FullscreenImageViewer(imageUrl: item.imageUrl),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.smallRadius),
child: Image.network(
item.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 80,
height: 80,
color: AppTheme.border,
child: const Icon(
Icons.broken_image_outlined,
size: 18,
color: AppTheme.textHint,
),
),
),
),
);
},
),
),
],
// Locations with count badges (compact display)
if (locGroups.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: locGroups.map((group) {
return Container(
decoration: BoxDecoration(
color: AppTheme.surface,
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(AppTheme.chipRadius),
),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.location_on_outlined,
size: 14,
color: AppTheme.primary,
),
const SizedBox(width: 4),
Text(
group.parent.nameFor(locale),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
if (group.children.isNotEmpty) ...[
const SizedBox(width: 6),
Container(
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(
AppTheme.smallRadius,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
child: Text(
'${group.children.length}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
],
),
);
}).toList(),
),
],
// Price list
if (_priceList.isNotEmpty) ...[
const SizedBox(height: 10),
const Divider(height: 1, color: AppTheme.border),
const SizedBox(height: 10),
..._priceList.map(
(group) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.getTitle(locale),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppTheme.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
...group.items.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.getTitle(locale),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
if ((item.getDescription(locale) ?? '')
.isNotEmpty)
Text(
item.getDescription(locale)!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
if (item.price != null ||
item.getUnit(locale) != null) ...[
const SizedBox(width: 8),
Text(
'${item.price != null ? '€${item.price}' : ''}${item.getUnit(locale) != null ? ' ${item.getUnit(locale)}' : ''}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
),
),
],
],
),
),
),
],
),
),
),
],
],
),
);
}
}
// ── Section card ──────────────────────────────────────────────────────────────
class _SectionCard extends StatelessWidget {
final Widget child;
const _SectionCard({required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: child,
);
}
}
// ── Section title ─────────────────────────────────────────────────────────────
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
);
}
}
// ── Stat cell ─────────────────────────────────────────────────────────────────
class _StatCell extends StatelessWidget {
final IconData icon;
final Color color;
final String value;
final String label;
const _StatCell({
required this.icon,
required this.color,
required this.value,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(icon, color: color, size: 22),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
],
);
}
}
class _FullscreenImageViewer extends StatelessWidget {
const _FullscreenImageViewer({required this.imageUrl});
final String imageUrl;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0,
),
body: SafeArea(
child: Center(
child: InteractiveViewer(
minScale: 1,
maxScale: 4,
child: Image.network(
imageUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.white54,
),
),
),
),
),
);
}
}
class _HeaderCircleButton extends StatelessWidget {
const _HeaderCircleButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Ink(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.32),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.3)),
),
child: Icon(icon, size: 20, color: Colors.white),
),
),
),
);
}
}
class _HeaderSafetyMenuButton extends StatelessWidget {
const _HeaderSafetyMenuButton({
required this.locale,
required this.onReport,
required this.onBlock,
});
final LocaleProvider locale;
final VoidCallback onReport;
final VoidCallback onBlock;
@override
Widget build(BuildContext context) {
return PopupMenuButton(
tooltip: locale.t('safety.moreActions', 'More actions'),
onSelected: (value) {
if (value == 'report') onReport();
if (value == 'block') onBlock();
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'report',
child: Text(locale.t('safety.reportUser', 'Report user')),
),
PopupMenuItem(
value: 'block',
child: Text(locale.t('safety.blockUser', 'Block user')),
),
],
child: Tooltip(
message: locale.t('safety.moreActions', 'More actions'),
child: Material(
color: Colors.transparent,
child: Ink(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.32),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.3)),
),
child: const Icon(
Icons.more_horiz_rounded,
size: 20,
color: Colors.white,
),
),
),
),
);
}
}
class _ReadablePlanBadge extends StatelessWidget {
const _ReadablePlanBadge({required this.tier, this.compact = false});
final String tier;
final bool compact;
@override
Widget build(BuildContext context) {
if (tier == 'free') return const SizedBox.shrink();
final isUltimate = tier == 'ultimate';
final label = isUltimate ? 'Ultimate' : 'Pro';
final icon = isUltimate ? Icons.workspace_premium_rounded : Icons.bolt;
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 10,
vertical: compact ? 3 : 4,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: isUltimate
? const Color(0xFFF59E0B).withValues(alpha: 0.65)
: AppTheme.primary.withValues(alpha: 0.55),
),
boxShadow: const [
BoxShadow(
color: Color(0x1F000000),
blurRadius: 6,
offset: Offset(0, 1),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: compact ? 12 : 13,
color: isUltimate
? const Color(0xFFB45309)
: const Color(0xFF166534),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w800,
color: isUltimate
? const Color(0xFF92400E)
: const Color(0xFF14532D),
letterSpacing: 0.2,
),
),
],
),
);
}
}
// ── Chip helpers ──────────────────────────────────────────────────────────────
class _SkillChip extends StatelessWidget {
final String label;
const _SkillChip({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryContainer,
borderRadius: BorderRadius.circular(AppTheme.chipRadius),
),
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.primaryDark,
fontWeight: FontWeight.w500,
),
),
);
}
}
class _TagChip extends StatelessWidget {
final String label;
const _TagChip({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.chipRadius),
border: Border.all(color: AppTheme.border),
),
child: Text(
label,
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
);
}
}
// ── Review card ───────────────────────────────────────────────────────────────
class _ReviewCard extends StatelessWidget {
final Review review;
const _ReviewCard({required this.review});
@override
Widget build(BuildContext context) {
final authorName = review.author?.name ?? '';
final initials = authorName.isNotEmpty ? authorName[0].toUpperCase() : '?';
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 6,
offset: Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 16,
backgroundImage: review.author?.image != null
? NetworkImage(review.author!.image!)
: null,
backgroundColor: AppTheme.primaryContainer,
child: review.author?.image == null
? Text(
initials,
style: const TextStyle(
fontSize: 13,
color: AppTheme.primary,
fontWeight: FontWeight.w600,
),
)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Text(
authorName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
Row(
children: List.generate(
5,
(i) => Icon(
i < review.rating.round()
? Icons.star_rounded
: Icons.star_outline_rounded,
size: 14,
color: AppTheme.amber,
),
),
),
],
),
if (review.comment != null && review.comment!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
review.comment!,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.4,
),
),
],
],
),
);
}
}
// ─── Skeleton loading screen ────────────────────────────────────────────────
class _SpecialistProfileSkeleton extends StatefulWidget {
const _SpecialistProfileSkeleton();
@override
State<_SpecialistProfileSkeleton> createState() =>
_SpecialistProfileSkeletonState();
}
class _SpecialistProfileSkeletonState extends State<_SpecialistProfileSkeleton>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
_anim = Tween(
begin: -2,
end: 2,
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut));
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(backgroundColor: AppTheme.primary),
body: AnimatedBuilder(
animation: _anim,
builder: (context, _) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar + name row
Row(
children: [
_Shimmer(
width: 72,
height: 72,
radius: 36,
animValue: _anim.value,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Shimmer(
width: double.infinity,
height: 18,
radius: 6,
animValue: _anim.value,
),
const SizedBox(height: 8),
_Shimmer(
width: 140,
height: 14,
radius: 6,
animValue: _anim.value,
),
const SizedBox(height: 8),
Row(
children: [
_Shimmer(
width: 60,
height: 12,
radius: 4,
animValue: _anim.value,
),
const SizedBox(width: 12),
_Shimmer(
width: 60,
height: 12,
radius: 4,
animValue: _anim.value,
),
],
),
],
),
),
],
),
const SizedBox(height: 20),
// Bio block
_Shimmer(
width: double.infinity,
height: 14,
radius: 5,
animValue: _anim.value,
),
const SizedBox(height: 8),
_Shimmer(
width: double.infinity,
height: 14,
radius: 5,
animValue: _anim.value,
),
const SizedBox(height: 8),
_Shimmer(
width: 200,
height: 14,
radius: 5,
animValue: _anim.value,
),
const SizedBox(height: 24),
// Chips row (skills/categories)
Wrap(
spacing: 8,
runSpacing: 8,
children: [90, 70, 110, 80, 60].map((w) {
return _Shimmer(
width: w.toDouble(),
height: 28,
radius: 14,
animValue: _anim.value,
);
}).toList(),
),
const SizedBox(height: 24),
// Card 1 (collapsed header)
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
border: Border.all(color: AppTheme.border),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _Shimmer(
width: double.infinity,
height: 16,
radius: 6,
animValue: _anim.value,
),
),
const SizedBox(width: 12),
_Shimmer(
width: 20,
height: 20,
radius: 4,
animValue: _anim.value,
),
],
),
const SizedBox(height: 12),
_Shimmer(
width: double.infinity,
height: 12,
radius: 4,
animValue: _anim.value,
),
const SizedBox(height: 8),
_Shimmer(
width: 240,
height: 12,
radius: 4,
animValue: _anim.value,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [100, 80, 120].map((w) {
return _Shimmer(
width: w.toDouble(),
height: 26,
radius: 13,
animValue: _anim.value,
);
}).toList(),
),
],
),
),
const SizedBox(height: 12),
// Card 2 (collapsed)
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
border: Border.all(color: AppTheme.border),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Expanded(
child: _Shimmer(
width: double.infinity,
height: 16,
radius: 6,
animValue: _anim.value,
),
),
const SizedBox(width: 12),
_Shimmer(
width: 20,
height: 20,
radius: 4,
animValue: _anim.value,
),
],
),
),
],
),
);
},
),
);
}
}
class _Shimmer extends StatelessWidget {
const _Shimmer({
required this.width,
required this.height,
required this.radius,
required this.animValue,
});
final double width;
final double height;
final double radius;
final double animValue;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
gradient: LinearGradient(
begin: Alignment(animValue - 1, 0),
end: Alignment(animValue + 1, 0),
colors: const [
Color(0xFFEEEEEE),
Color(0xFFF8F8F8),
Color(0xFFEEEEEE),
],
stops: const [0.0, 0.5, 1.0],
),
),
);
}
}