/// 멤버 화면 - 카드 스와이프 스타일 library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; import '../../models/member.dart'; import '../../services/members_service.dart'; class MembersView extends StatefulWidget { const MembersView({super.key}); @override State createState() => _MembersViewState(); } class _MembersViewState extends State with TickerProviderStateMixin { List _members = []; bool _isLoading = true; int _currentIndex = 0; late PageController _pageController; late ScrollController _indicatorScrollController; late AnimationController _animController; // 애니메이션 late Animation _indicatorOpacity; late Animation _indicatorSlide; late Animation _cardOpacity; late Animation _cardSlide; // 인디케이터 아이템 크기 (48px 썸네일 + 12px 마진) static const double _indicatorItemWidth = 64.0; // 이전 경로 저장 (탭 전환 감지용) String? _previousPath; @override void initState() { super.initState(); _pageController = PageController(viewportFraction: 0.88); _indicatorScrollController = ScrollController(); _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _setupAnimations(); _loadData(); } /// 애니메이션 설정 void _setupAnimations() { // 인디케이터 페이드인 (0~0.4) _indicatorOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0, 0.4, curve: Curves.easeOut), ), ); // 인디케이터 슬라이드 (위에서 아래로) _indicatorSlide = Tween(begin: -20, end: 0).animate( CurvedAnimation( parent: _animController, curve: const Interval(0, 0.4, curve: Curves.easeOut), ), ); // 카드 페이드인 (0.2~0.7) _cardOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.2, 0.7, curve: Curves.easeOut), ), ); // 카드 슬라이드 (아래에서 위로) _cardSlide = Tween(begin: 40, end: 0).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.2, 0.7, curve: Curves.easeOut), ), ); } @override void didChangeDependencies() { super.didChangeDependencies(); // 경로 변경 감지하여 애니메이션 재생 final currentPath = GoRouterState.of(context).uri.path; if (_previousPath != null && _previousPath != currentPath && currentPath == '/members') { _animController.reset(); _animController.forward(); } _previousPath = currentPath; } @override void dispose() { _pageController.dispose(); _indicatorScrollController.dispose(); _animController.dispose(); super.dispose(); } /// 인디케이터 자동 스크롤 void _scrollIndicatorToIndex(int index) { if (!_indicatorScrollController.hasClients) return; final screenWidth = MediaQuery.of(context).size.width; // 아이템 중심 위치: 왼쪽패딩(16) + index * 아이템너비(64) + 아이템반지름(26) const itemRadius = 26.0; // 52 / 2 final targetOffset = (index * _indicatorItemWidth) + 16 + itemRadius - (screenWidth / 2); final maxOffset = _indicatorScrollController.position.maxScrollExtent; _indicatorScrollController.animateTo( targetOffset.clamp(0.0, maxOffset), duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } Future _loadData() async { try { final members = await getMembers(); // 현재 멤버 먼저, 전 멤버 나중에 정렬 members.sort((a, b) { if (a.isFormer != b.isFormer) { return a.isFormer ? 1 : -1; } return 0; }); setState(() { _members = members; _isLoading = false; }); _animController.forward(); } catch (e) { setState(() => _isLoading = false); } } /// 나이 계산 int? _calculateAge(String? birthDate) { if (birthDate == null) return null; final birth = DateTime.tryParse(birthDate); if (birth == null) return null; final today = DateTime.now(); int age = today.year - birth.year; if (today.month < birth.month || (today.month == birth.month && today.day < birth.day)) { age--; } return age; } /// 생일 포맷팅 String _formatBirthDate(String? birthDate) { if (birthDate == null) return ''; return birthDate.substring(0, 10).replaceAll('-', '.'); } /// 인스타그램 열기 (딥링크 우선, 없으면 웹) Future _openInstagram(String? url) async { if (url == null) return; // URL에서 username 추출 (instagram.com/username 형태) String? username; final uri = Uri.tryParse(url); if (uri != null && uri.pathSegments.isNotEmpty) { username = uri.pathSegments.first; } // 인스타그램 앱 딥링크 시도 if (username != null) { final deepLink = Uri.parse('instagram://user?username=$username'); if (await canLaunchUrl(deepLink)) { await launchUrl(deepLink); return; } } // 앱이 없으면 웹으로 열기 final webUri = Uri.parse(url); if (await canLaunchUrl(webUri)) { await launchUrl(webUri, mode: LaunchMode.externalApplication); } } @override Widget build(BuildContext context) { if (_isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (_members.isEmpty) { return const Center( child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)), ); } return AnimatedBuilder( animation: _animController, builder: (context, child) { return Column( children: [ // 상단 썸네일 인디케이터 (애니메이션 적용) Transform.translate( offset: Offset(0, _indicatorSlide.value), child: Opacity( opacity: _indicatorOpacity.value, child: _buildThumbnailIndicator(), ), ), // 메인 카드 영역 (애니메이션 적용) Expanded( child: Transform.translate( offset: Offset(0, _cardSlide.value), child: Opacity( opacity: _cardOpacity.value, child: PageView.builder( controller: _pageController, itemCount: _members.length, padEnds: true, onPageChanged: (index) { setState(() => _currentIndex = index); HapticFeedback.selectionClick(); _scrollIndicatorToIndex(index); }, itemBuilder: (context, index) { return AnimatedBuilder( animation: _pageController, builder: (context, child) { double value = 1.0; if (_pageController.position.haveDimensions) { value = (_pageController.page! - index).abs(); value = (1 - (value * 0.15)).clamp(0.0, 1.0); } return Transform.scale( scale: Curves.easeOut.transform(value), child: _buildMemberCard(_members[index], index), ); }, ); }, ), ), ), ), ], ); }, ); } /// 멤버 카드 Widget _buildMemberCard(Member member, int index) { final isFormer = member.isFormer; final age = _calculateAge(member.birthDate); return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), blurRadius: 20, offset: const Offset(0, 10), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: Stack( fit: StackFit.expand, children: [ // 배경 이미지 if (member.imageUrl != null) ColorFiltered( colorFilter: isFormer ? const ColorFilter.mode(Colors.grey, BlendMode.saturation) : const ColorFilter.mode(Colors.transparent, BlendMode.multiply), child: CachedNetworkImage( imageUrl: member.imageUrl!, fit: BoxFit.cover, placeholder: (_, _) => Container( color: Colors.grey[200], child: const Center( child: CircularProgressIndicator(color: AppColors.primary), ), ), ), ) else Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey[300]!, Colors.grey[400]!], ), ), ), // 하단 그라데이션 오버레이 Positioned( left: 0, right: 0, bottom: 0, child: Container( height: 220, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.3), Colors.black.withValues(alpha: 0.8), ], stops: const [0.0, 0.4, 1.0], ), ), ), ), // 전 멤버 라벨 if (isFormer) Positioned( top: 16, right: 16, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(20), ), child: const Text( '전 멤버', style: TextStyle( color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ), // 멤버 정보 Positioned( left: 24, right: 24, bottom: 24, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // 이름 Text( member.name, style: const TextStyle( fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white, shadows: [ Shadow( blurRadius: 10, color: Colors.black45, ), ], ), ), const SizedBox(height: 8), // 포지션 if (member.position != null) Text( member.position!, style: TextStyle( fontSize: 16, color: Colors.white.withValues(alpha: 0.9), fontWeight: FontWeight.w500, ), ), const SizedBox(height: 12), // 생일 정보 if (member.birthDate != null) Row( children: [ _buildIcon('calendar', 16, Colors.white70), const SizedBox(width: 6), Text( _formatBirthDate(member.birthDate), style: TextStyle( fontSize: 14, color: Colors.white.withValues(alpha: 0.8), ), ), if (age != null) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), child: Text( '$age세', style: const TextStyle( fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500, ), ), ), ], ], ), // 인스타그램 버튼 if (!isFormer && member.instagram != null) ...[ const SizedBox(height: 16), GestureDetector( onTap: () => _openInstagram(member.instagram), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF833AB4), Color(0xFFE1306C), Color(0xFFF77737)], ), borderRadius: BorderRadius.circular(25), boxShadow: [ BoxShadow( color: const Color(0xFFE1306C).withValues(alpha: 0.4), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildIcon('instagram', 18, Colors.white), const SizedBox(width: 8), const Text( 'Instagram', style: TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, ), ), ], ), ), ), ], ], ), ), ], ), ), ), ); } /// 상단 썸네일 인디케이터 Widget _buildThumbnailIndicator() { return Container( height: 88, decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: ListView.builder( controller: _indicatorScrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), itemCount: _members.length, itemBuilder: (context, index) { final member = _members[index]; final isSelected = index == _currentIndex; final isFormer = member.isFormer; return GestureDetector( onTap: () { _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); }, child: Padding( padding: const EdgeInsets.only(right: 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 테두리 + 그림자를 위한 외부 컨테이너 AnimatedContainer( duration: const Duration(milliseconds: 200), width: 52, height: 52, padding: const EdgeInsets.all(2), decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: isSelected ? AppColors.primary : Colors.grey.shade300, width: isSelected ? 2.5 : 1.5, ), boxShadow: isSelected ? [ BoxShadow( color: AppColors.primary.withValues(alpha: 0.35), blurRadius: 8, spreadRadius: 1, ), ] : null, ), // 이미지를 담는 내부 ClipOval child: ClipOval( child: ColorFiltered( colorFilter: isFormer ? const ColorFilter.mode(Colors.grey, BlendMode.saturation) : const ColorFilter.mode(Colors.transparent, BlendMode.multiply), child: member.imageUrl != null ? CachedNetworkImage( imageUrl: member.imageUrl!, fit: BoxFit.cover, width: 48, height: 48, placeholder: (_, _) => Container(color: Colors.grey[300]), ) : Container( width: 48, height: 48, color: Colors.grey[300], child: Center( child: Text( member.name[0], style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), ), ), ), ), ), ], ), ), ); }, ), ); } /// SVG 아이콘 빌더 Widget _buildIcon(String name, double size, Color color) { const icons = { 'calendar': '', 'instagram': '', }; final svg = '''${icons[name]}'''; return SizedBox( width: size, height: size, child: SvgPicture.string( svg, colorFilter: ColorFilter.mode(color, BlendMode.srcIn), ), ); } }