/opt/canhelp/apps/mobile/lib/screens/specialists
NameSizeModeActions
specialists_screen.dart596870644editdlrm
specialist_profile_screen.dart836970644editdlrm
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], ), ), ); } }