/// 홈 화면 - 모바일 웹과 픽셀 단위까지 동일한 디자인 + 애니메이션 library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../core/constants.dart'; import '../../models/member.dart'; import '../../models/album.dart'; import '../../models/schedule.dart'; import '../../services/members_service.dart'; import '../../services/albums_service.dart'; import '../../services/schedules_service.dart'; class HomeView extends StatefulWidget { const HomeView({super.key}); @override State createState() => _HomeViewState(); } class _HomeViewState extends State with TickerProviderStateMixin { List _members = []; List _albums = []; List _schedules = []; bool _isLoading = true; bool _dataLoaded = false; // 애니메이션 컨트롤러 late AnimationController _animController; // 각 섹션별 애니메이션 late Animation _heroOpacity; late Animation _heroContentOpacity; late Animation _heroContentSlide; late Animation _membersSectionOpacity; late Animation _membersSectionSlide; late Animation _albumsSectionOpacity; late Animation _albumsSectionSlide; late Animation _schedulesSectionOpacity; late Animation _schedulesSectionSlide; // 현재 경로 추적 (홈 탭 선택 시 애니메이션 재시작용) String? _previousPath; @override void initState() { super.initState(); _setupAnimations(); _loadData(); } void _setupAnimations() { // 전체 애니메이션 길이 (웹 기준 마지막 애니메이션: 0.8 + 0.3 = 1.1초) _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), ); // 히어로 섹션: opacity 0→1, duration 0.5s (0~500ms) _heroOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0, 0.42, curve: Curves.easeOut), // 0.5/1.2 ≈ 0.42 ), ); // 히어로 내용: delay 0.2s, duration 0.5s (200~700ms) _heroContentOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.17, 0.58, curve: Curves.easeOut), // 0.2/1.2, 0.7/1.2 ), ); _heroContentSlide = Tween( begin: const Offset(0, 20), end: Offset.zero, ).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.17, 0.58, curve: Curves.easeOut), ), ); // 멤버 섹션: delay 0.3s, duration 0.5s (300~800ms) _membersSectionOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.25, 0.67, curve: Curves.easeOut), // 0.3/1.2, 0.8/1.2 ), ); _membersSectionSlide = Tween( begin: const Offset(0, 20), end: Offset.zero, ).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.25, 0.67, curve: Curves.easeOut), ), ); // 앨범 섹션: delay 0.5s, duration 0.5s (500~1000ms) _albumsSectionOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.42, 0.83, curve: Curves.easeOut), // 0.5/1.2, 1.0/1.2 ), ); _albumsSectionSlide = Tween( begin: const Offset(0, 20), end: Offset.zero, ).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.42, 0.83, curve: Curves.easeOut), ), ); // 일정 섹션: delay 0.7s, duration 0.5s (700~1200ms) _schedulesSectionOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.58, 1.0, curve: Curves.easeOut), // 0.7/1.2, 1.2/1.2 ), ); _schedulesSectionSlide = Tween( begin: const Offset(0, 20), end: Offset.zero, ).animate( CurvedAnimation( parent: _animController, curve: const Interval(0.58, 1.0, curve: Curves.easeOut), ), ); } /// 애니메이션 시작 (처음 또는 페이지 복귀 시) void _startAnimations() { _animController.reset(); _animController.forward(); } @override void didChangeDependencies() { super.didChangeDependencies(); // go_router에서 현재 경로 감지 final currentPath = GoRouterState.of(context).uri.path; // 다른 탭에서 홈('/')으로 돌아왔을 때 애니메이션 재시작 if (_previousPath != null && _previousPath != '/' && currentPath == '/' && _dataLoaded) { _startAnimations(); } _previousPath = currentPath; } @override void dispose() { _animController.dispose(); super.dispose(); } Future _loadData() async { try { final results = await Future.wait([ getActiveMembers(), getRecentAlbums(2), getUpcomingSchedules(3), ]); setState(() { _members = results[0] as List; _albums = results[1] as List; _schedules = results[2] as List; _isLoading = false; _dataLoaded = true; }); // 데이터 로드 완료 후 애니메이션 시작 _startAnimations(); } catch (e) { setState(() { _isLoading = false; _dataLoaded = true; }); _startAnimations(); } } @override Widget build(BuildContext context) { if (_isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } return AnimatedBuilder( animation: _animController, builder: (context, child) { return SingleChildScrollView( child: Column( children: [ _buildHeroSection(), _buildMembersSection(), _buildAlbumsSection(), _buildSchedulesSection(), ], ), ); }, ); } /// 히어로 섹션 - py-12(48px) px-4(16px) Widget _buildHeroSection() { return Opacity( opacity: _heroOpacity.value, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [AppColors.primary, AppColors.primaryDark], ), ), child: Stack( clipBehavior: Clip.none, children: [ // 장식 원 1 (오른쪽 상단) - w-32(128px) Positioned( right: -64, top: -64, child: Container( width: 128, height: 128, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.1), ), ), ), // 장식 원 2 (왼쪽 하단) - w-24(96px) Positioned( left: -48, bottom: -48, child: Container( width: 96, height: 96, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.05), ), ), ), // 내용 (애니메이션 적용) Center( child: Opacity( opacity: _heroContentOpacity.value, child: Transform.translate( offset: _heroContentSlide.value, child: Column( children: [ // text-3xl(30px) font-bold mb-1(4px) const Text( 'fromis_9', style: TextStyle( fontSize: 30, fontWeight: FontWeight.bold, color: Colors.white, ), ), const SizedBox(height: 4), // text-lg(18px) font-light mb-3(12px) const Text( '프로미스나인', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w300, color: Colors.white, ), ), const SizedBox(height: 12), // text-sm(14px) opacity-80 Text( '인사드리겠습니다. 둘, 셋!\n이제는 약속해 소중히 간직해,\n당신의 아이돌로 성장하겠습니다!', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: Colors.white.withValues(alpha: 0.8), height: 1.5, ), ), ], ), ), ), ), ], ), ), ); } /// 멤버 섹션 - px-4(16px) py-6(24px) Widget _buildMembersSection() { return Opacity( opacity: _membersSectionOpacity.value, child: Transform.translate( offset: _membersSectionSlide.value, child: Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), child: Column( children: [ // mb-4(16px) _buildSectionHeader('멤버', () => context.go('/members')), const SizedBox(height: 16), // grid-cols-5 gap-2(8px) Row( children: _members.asMap().entries.map((entry) { final index = entry.key; final member = entry.value; // 멤버 아이템: delay 0.4+index*0.05s, duration 0.3s final itemDelay = 0.33 + index * 0.04; // 0.4/1.2 + index * 0.05/1.2 final itemEnd = itemDelay + 0.25; // 0.3/1.2 final itemOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), ), ); final itemScale = Tween(begin: 0.8, end: 1).animate( CurvedAnimation( parent: _animController, curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), ), ); return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Opacity( opacity: itemOpacity.value, child: Transform.scale( scale: itemScale.value, child: Column( children: [ // mb-1(4px) AspectRatio( aspectRatio: 1, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.grey[200], ), clipBehavior: Clip.antiAlias, child: member.imageUrl != null ? CachedNetworkImage( imageUrl: member.imageUrl!, fit: BoxFit.cover, placeholder: (_, __) => Container(color: Colors.grey[200]), ) : null, ), ), const SizedBox(height: 4), // text-xs(12px) font-medium Text( member.name, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis, ), ], ), ), ), ), ); }).toList(), ), ], ), ), ), ); } /// 앨범 섹션 - px-4(16px) py-6(24px) Widget _buildAlbumsSection() { return Opacity( opacity: _albumsSectionOpacity.value, child: Transform.translate( offset: _albumsSectionSlide.value, child: Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), child: Column( children: [ // mb-4(16px) _buildSectionHeader('앨범', () => context.go('/album')), const SizedBox(height: 16), // grid-cols-2 gap-3(12px) Row( children: _albums.asMap().entries.map((entry) { final index = entry.key; final album = entry.value; // 앨범 아이템: delay 0.6+index*0.1s, duration 0.3s final itemDelay = 0.5 + index * 0.08; // 0.6/1.2 + index * 0.1/1.2 final itemEnd = itemDelay + 0.25; // 0.3/1.2 final itemOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), ), ); final itemSlide = Tween(begin: const Offset(0, 20), end: Offset.zero).animate( CurvedAnimation( parent: _animController, curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), ), ); return Expanded( child: Padding( padding: EdgeInsets.only( left: index == 0 ? 0 : 6, right: index == 1 ? 0 : 6, ), child: Opacity( opacity: itemOpacity.value, child: Transform.translate( offset: itemSlide.value, child: GestureDetector( onTap: () => context.go('/album/${album.folderName}'), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2), ), ], ), clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AspectRatio( aspectRatio: 1, child: album.coverThumbUrl != null ? CachedNetworkImage( imageUrl: album.coverThumbUrl!, fit: BoxFit.cover, placeholder: (_, __) => Container(color: Colors.grey[200]), ) : Container(color: Colors.grey[200]), ), // p-3(12px) Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // font-medium text-sm(14px) Text( album.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), // text-xs(12px) text-gray-400 Text( album.releaseYear ?? '', style: TextStyle(fontSize: 12, color: Colors.grey[400]), ), ], ), ), ], ), ), ), ), ), ), ); }).toList(), ), ], ), ), ), ); } /// 일정 섹션 - px-4(16px) py-4(16px) Widget _buildSchedulesSection() { return Opacity( opacity: _schedulesSectionOpacity.value, child: Transform.translate( offset: _schedulesSectionSlide.value, child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ // mb-4(16px) _buildSectionHeader('다가오는 일정', () => context.go('/schedule')), const SizedBox(height: 16), if (_schedules.isEmpty) Container( padding: const EdgeInsets.symmetric(vertical: 32), child: Text( '다가오는 일정이 없습니다', style: TextStyle(color: Colors.grey[400]), ), ) else // space-y-3(12px) Column( children: _schedules.asMap().entries.map((entry) { final index = entry.key; final schedule = entry.value; // 일정 아이템: delay 0.8+index*0.1s, duration 0.3s, x -20→0 final itemDelay = 0.67 + index * 0.08; // 0.8/1.2 + index * 0.1/1.2 final itemEnd = itemDelay + 0.25; // 0.3/1.2 final itemOpacity = Tween(begin: 0, end: 1).animate( CurvedAnimation( parent: _animController, curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), ), ); final itemSlide = Tween(begin: const Offset(-20, 0), end: Offset.zero).animate( CurvedAnimation( parent: _animController, curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), ), ); return Padding( padding: EdgeInsets.only(bottom: index < _schedules.length - 1 ? 12 : 0), child: Opacity( opacity: itemOpacity.value, child: Transform.translate( offset: itemSlide.value, child: _buildScheduleCard(schedule), ), ), ); }).toList(), ), ], ), ), ), ); } /// 일정 카드 - flex gap-4(16px) p-4(16px) Widget _buildScheduleCard(Schedule schedule) { final scheduleDate = DateTime.parse(schedule.date); final now = DateTime.now(); final isCurrentYear = scheduleDate.year == now.year; final isCurrentMonth = isCurrentYear && scheduleDate.month == now.month; final weekdays = ['일', '월', '화', '수', '목', '금', '토']; final memberList = schedule.memberList; return GestureDetector( onTap: () => context.go('/schedule'), child: Container( // p-4(16px) rounded-xl(12px) padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFF3F4F6)), // border-gray-100 boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 4, offset: const Offset(0, 1), ), ], ), child: IntrinsicHeight( child: Row( children: [ // 날짜 영역 - min-w-[50px] SizedBox( width: 50, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 현재 년도가 아니면 년.월 표시 - text-[10px] if (!isCurrentYear) Text( '${scheduleDate.year}.${scheduleDate.month}', style: TextStyle( fontSize: 10, color: Colors.grey[400], fontWeight: FontWeight.w500, ), ) else if (!isCurrentMonth) Text( '${scheduleDate.month}월', style: TextStyle( fontSize: 10, color: Colors.grey[400], fontWeight: FontWeight.w500, ), ), // 일 - text-2xl(24px) font-bold text-primary Text( '${scheduleDate.day}', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: AppColors.primary, ), ), // 요일 - text-xs(12px) text-gray-400 Text( weekdays[scheduleDate.weekday % 7], style: TextStyle( fontSize: 12, color: Colors.grey[400], fontWeight: FontWeight.w500, ), ), ], ), ), // gap-4(16px)의 절반 + 구분선 + 절반 const SizedBox(width: 16), // 세로 구분선 - w-px bg-gray-100 Container(width: 1, color: const Color(0xFFF3F4F6)), const SizedBox(width: 16), // 내용 영역 - flex-1 min-w-0 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ // font-semibold text-sm(14px) text-gray-800 line-clamp-2 leading-snug Text( schedule.title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1F2937), // text-gray-800 height: 1.375, // leading-snug ), maxLines: 2, overflow: TextOverflow.ellipsis, ), // mt-2(8px) text-xs(12px) text-gray-400 const SizedBox(height: 8), Row( children: [ // gap-3(12px) 사이 if (schedule.formattedTime != null) ...[ // gap-1(4px) _buildIcon('clock', 12, Colors.grey[400]!), const SizedBox(width: 4), Text( schedule.formattedTime!, style: TextStyle(fontSize: 12, color: Colors.grey[400]), ), const SizedBox(width: 12), ], if (schedule.categoryName != null) ...[ _buildIcon('tag', 12, Colors.grey[400]!), const SizedBox(width: 4), Text( schedule.categoryName!, style: TextStyle(fontSize: 12, color: Colors.grey[400]), ), ], ], ), // mt-2(8px) if (memberList.isNotEmpty) ...[ const SizedBox(height: 8), // gap-1(4px) Wrap( spacing: 4, runSpacing: 4, children: (memberList.length >= 5 ? ['프로미스나인'] : memberList) .map((name) => Container( // px-2(8px) py-0.5(2px) rounded-full text-[10px] padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.1), // bg-primary/10 borderRadius: BorderRadius.circular(12), ), child: Text( name, style: const TextStyle( fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.w500, ), ), )) .toList(), ), ], ], ), ), ], ), ), ), ); } /// SVG 아이콘 빌더 Widget _buildIcon(String name, double size, Color color) { const icons = { 'clock': '', 'tag': '', 'chevron-right': '', }; final svg = '''${icons[name]}'''; return SizedBox( width: size, height: size, child: SvgPicture.string( svg, colorFilter: ColorFilter.mode(color, BlendMode.srcIn), ), ); } /// 섹션 헤더 - text-lg(18px) font-bold Widget _buildSectionHeader(String title, VoidCallback onTap) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), GestureDetector( onTap: onTap, child: Row( children: [ // text-sm(14px) text-primary gap-1(4px) const Text( '전체보기', style: TextStyle(fontSize: 14, color: AppColors.primary), ), const SizedBox(width: 4), _buildIcon('chevron-right', 16, AppColors.primary), ], ), ), ], ); } }