/// 앨범 목록 화면 library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_service.dart'; class AlbumView extends StatefulWidget { const AlbumView({super.key}); @override State createState() => _AlbumViewState(); } class _AlbumViewState extends State { late Future> _albumsFuture; bool _initialLoadComplete = false; @override void initState() { super.initState(); _albumsFuture = getAlbums(); // 초기 애니메이션 시간 후에는 새로 생성되는 카드에 애니메이션 적용 안함 Future.delayed(const Duration(milliseconds: 600), () { if (mounted) { setState(() => _initialLoadComplete = true); } }); } @override Widget build(BuildContext context) { return FutureBuilder>( future: _albumsFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator( color: AppColors.primary, ), ); } if (snapshot.hasError) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( LucideIcons.alertCircle, size: 48, color: AppColors.textTertiary, ), const SizedBox(height: 16), Text( '앨범을 불러오는데 실패했습니다', style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), const SizedBox(height: 16), TextButton( onPressed: () { setState(() { _albumsFuture = getAlbums(); }); }, child: const Text('다시 시도'), ), ], ), ); } final albums = snapshot.data ?? []; return GridView.builder( padding: const EdgeInsets.all(16), clipBehavior: Clip.none, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 0.75, ), itemCount: albums.length, itemBuilder: (context, index) { final album = albums[index]; return _AlbumCard( album: album, index: index, skipAnimation: _initialLoadComplete, ); }, ); }, ); } } class _AlbumCard extends StatefulWidget { final Album album; final int index; final bool skipAnimation; const _AlbumCard({ required this.album, required this.index, this.skipAnimation = false, }); @override State<_AlbumCard> createState() => _AlbumCardState(); } class _AlbumCardState extends State<_AlbumCard> with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { late AnimationController _controller; late Animation _opacityAnimation; late Animation _slideAnimation; bool _hasAnimated = false; @override bool get wantKeepAlive => true; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 400), vsync: this, ); _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); _slideAnimation = Tween( begin: const Offset(0, 0.1), end: Offset.zero, ).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); // skipAnimation이면 애니메이션 건너뛰고 바로 표시 if (widget.skipAnimation) { _hasAnimated = true; _controller.value = 1.0; } else { // 순차적으로 애니메이션 시작 (최대 8개까지만 순차, 이후는 동시에) final delay = widget.index < 8 ? widget.index * 50 : 400; Future.delayed(Duration(milliseconds: delay), () { if (mounted && !_hasAnimated) { _hasAnimated = true; _controller.forward(); } }); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); return FadeTransition( opacity: _opacityAnimation, child: SlideTransition( position: _slideAnimation, child: GestureDetector( onTap: () { final folderName = widget.album.folderName; if (folderName != null && folderName.isNotEmpty) { context.push('/album/$folderName'); } }, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 앨범 커버 Expanded( child: ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), child: Container( width: double.infinity, color: AppColors.divider, child: widget.album.coverThumbUrl != null ? CachedNetworkImage( imageUrl: widget.album.coverThumbUrl!, fit: BoxFit.cover, placeholder: (context, url) => Container( color: AppColors.divider, ), errorWidget: (context, url, error) => const Center( child: Icon( LucideIcons.disc3, size: 48, color: AppColors.textTertiary, ), ), ) : const Center( child: Icon( LucideIcons.disc3, size: 48, color: AppColors.textTertiary, ), ), ), ), ), // 앨범 정보 Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.album.title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( '${widget.album.albumTypeShort ?? ''} · ${widget.album.releaseYear ?? ''}', style: const TextStyle( fontSize: 12, color: AppColors.textTertiary, ), ), ], ), ), ], ), ), ), ), ); } }