/// 멤버 화면 (MVCS의 View 레이어) /// /// 웹과 동일한 2열 그리드 + 모달 디자인 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 '../../controllers/members_controller.dart'; class MembersView extends ConsumerStatefulWidget { const MembersView({super.key}); @override ConsumerState createState() => _MembersViewState(); } class _MembersViewState extends ConsumerState { String? _previousPath; // 탭 전환 시 애니메이션 재생을 위한 키 Key _gridKey = UniqueKey(); @override void didChangeDependencies() { super.didChangeDependencies(); final currentPath = GoRouterState.of(context).uri.path; if (_previousPath != null && _previousPath != currentPath && currentPath == '/members') { setState(() => _gridKey = UniqueKey()); } _previousPath = currentPath; } /// 인스타그램 열기 (딥링크 우선, 없으면 웹) Future _openInstagram(String? url) async { if (url == null) return; 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); } } /// 멤버 모달 표시 void _showMemberModal(Member member) { final controller = ref.read(membersProvider.notifier); final age = controller.calculateAge(member.birthDate); showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: '닫기', barrierColor: Colors.black.withValues(alpha: 0.6), transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, animation, secondaryAnimation) { return Center( child: ScaleTransition( scale: CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, ), child: FadeTransition( opacity: animation, child: Material( color: Colors.transparent, child: Container( width: 264, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ // 이미지 (3:4 비율) AspectRatio( aspectRatio: 3 / 4, child: Stack( fit: StackFit.expand, children: [ if (member.imageUrl != null) CachedNetworkImage( imageUrl: member.imageUrl!, fit: BoxFit.cover, placeholder: (context, url) => Container(color: Colors.grey[200]), ) else Container( color: Colors.grey[200], child: Center( child: Text( member.name[0], style: TextStyle( fontSize: 30, fontWeight: FontWeight.bold, color: Colors.grey[400], ), ), ), ), // 닫기 버튼 Positioned( top: 8, right: 8, child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( width: 32, height: 32, decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle, ), child: const Icon( Icons.close, color: Colors.white, size: 18, ), ), ), ), ], ), ), // 정보 영역 Padding( padding: const EdgeInsets.all(16), child: Column( children: [ // 이름 Text( member.name, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), // 생일 if (member.birthDate != null) ...[ const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildIcon('cake', 14, Colors.grey[500]!), const SizedBox(width: 4), Text( controller.formatBirthDate(member.birthDate), style: TextStyle( fontSize: 14, color: Colors.grey[500], ), ), if (age != null) ...[ const SizedBox(width: 4), Text( '($age세)', style: const TextStyle( fontSize: 14, color: AppColors.primary, ), ), ], ], ), ], // 인스타그램 버튼 if (!member.isFormer && member.instagram != null) ...[ const SizedBox(height: 12), GestureDetector( onTap: () => _openInstagram(member.instagram), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF833AB4), Color(0xFFE1306C), Color(0xFFF77737)], ), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildIcon('instagram', 16, Colors.white), const SizedBox(width: 6), const Text( 'Instagram', style: TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, ), ), ], ), ), ), ], ], ), ), ], ), ), ), ), ), ); }, ); } @override Widget build(BuildContext context) { final membersState = ref.watch(membersProvider); if (membersState.isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (membersState.members.isEmpty) { return const Center( child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)), ); } // 현재 멤버만 표시 final currentMembers = membersState.members.where((m) => !m.isFormer).toList(); return Container( color: const Color(0xFFF9FAFB), // bg-gray-50 child: GridView.builder( key: _gridKey, padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 3 / 4, ), itemCount: currentMembers.length, itemBuilder: (context, index) { return _AnimatedMemberCard( index: index, member: currentMembers[index], onTap: () => _showMemberModal(currentMembers[index]), ); }, ), ); } /// SVG 아이콘 빌더 Widget _buildIcon(String name, double size, Color color) { const icons = { 'cake': '', 'instagram': '', }; final svg = '''${icons[name]}'''; return SizedBox( width: size, height: size, child: SvgPicture.string( svg, colorFilter: ColorFilter.mode(color, BlendMode.srcIn), ), ); } } /// 개별 애니메이션이 적용된 멤버 카드 /// Framer Motion과 동일: delay index*50ms, opacity 0→1, y 20→0, tap scale 0.97 class _AnimatedMemberCard extends StatefulWidget { final int index; final Member member; final VoidCallback onTap; const _AnimatedMemberCard({ required this.index, required this.member, required this.onTap, }); @override State<_AnimatedMemberCard> createState() => _AnimatedMemberCardState(); } class _AnimatedMemberCardState extends State<_AnimatedMemberCard> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _opacity; late Animation _slide; double _scale = 1.0; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _opacity = Tween(begin: 0, end: 1).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); _slide = Tween(begin: const Offset(0, 20), end: Offset.zero).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); // 개별 딜레이 (index * 50ms) Future.delayed(Duration(milliseconds: widget.index * 50), () { if (mounted) _controller.forward(); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Opacity( opacity: _opacity.value, child: Transform.translate( offset: _slide.value, child: child, ), ); }, child: GestureDetector( onTapDown: (_) => setState(() => _scale = 0.97), onTapUp: (_) { setState(() => _scale = 1.0); widget.onTap(); }, onTapCancel: () => setState(() => _scale = 1.0), child: AnimatedScale( scale: _scale, duration: const Duration(milliseconds: 100), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Stack( fit: StackFit.expand, children: [ // 이미지 if (widget.member.imageUrl != null) CachedNetworkImage( imageUrl: widget.member.imageUrl!, fit: BoxFit.cover, placeholder: (context, url) => Container(color: Colors.grey[200]), ) else Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey[200]!, Colors.grey[300]!], ), ), child: Center( child: Text( widget.member.name[0], style: TextStyle( fontSize: 36, fontWeight: FontWeight.bold, color: Colors.grey[400], ), ), ), ), // 하단 그라데이션 + 이름 Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.45), ], ), ), child: Text( widget.member.name, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), ), ), ], ), ), ), ), ), ); } }