fromis_9/app/lib/views/members/members_view.dart

574 lines
21 KiB
Dart
Raw Normal View History

/// 멤버 화면 (MVCS의 View 레이어)
///
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.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<MembersView> createState() => _MembersViewState();
}
class _MembersViewState extends ConsumerState<MembersView> with TickerProviderStateMixin {
late PageController _pageController;
late ScrollController _indicatorScrollController;
late AnimationController _animController;
// 애니메이션
late Animation<double> _indicatorOpacity;
late Animation<double> _indicatorSlide;
late Animation<double> _cardOpacity;
late Animation<double> _cardSlide;
// 인디케이터 아이템 크기 (48px 썸네일 + 12px 마진)
static const double _indicatorItemWidth = 64.0;
// 이전 경로 저장 (탭 전환 감지용)
String? _previousPath;
bool _animationStarted = false;
@override
void initState() {
super.initState();
_pageController = PageController(viewportFraction: 0.88);
_indicatorScrollController = ScrollController();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_setupAnimations();
}
/// 애니메이션 설정
void _setupAnimations() {
// 인디케이터 페이드인 (0~0.4)
_indicatorOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0, 0.4, curve: Curves.easeOut),
),
);
// 인디케이터 슬라이드 (위에서 아래로)
_indicatorSlide = Tween<double>(begin: -20, end: 0).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0, 0.4, curve: Curves.easeOut),
),
);
// 카드 페이드인 (0.2~0.7)
_cardOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.2, 0.7, curve: Curves.easeOut),
),
);
// 카드 슬라이드 (아래에서 위로)
_cardSlide = Tween<double>(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<void> _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) {
final membersState = ref.watch(membersProvider);
final controller = ref.read(membersProvider.notifier);
// 데이터 로드 완료 시 애니메이션 시작
if (!membersState.isLoading && !_animationStarted && membersState.members.isNotEmpty) {
_animationStarted = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_animController.forward();
});
}
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)),
);
}
return AnimatedBuilder(
animation: _animController,
builder: (context, child) {
return Column(
children: [
// 상단 썸네일 인디케이터 (애니메이션 적용)
Transform.translate(
offset: Offset(0, _indicatorSlide.value),
child: Opacity(
opacity: _indicatorOpacity.value,
child: _buildThumbnailIndicator(membersState),
),
),
// 메인 카드 영역 (애니메이션 적용)
Expanded(
child: Transform.translate(
offset: Offset(0, _cardSlide.value),
child: Opacity(
opacity: _cardOpacity.value,
child: PageView.builder(
controller: _pageController,
itemCount: membersState.members.length,
padEnds: true,
onPageChanged: (index) {
controller.setCurrentIndex(index);
HapticFeedback.selectionClick();
_scrollIndicatorToIndex(index);
},
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _pageController,
// child를 캐싱하여 매 프레임 rebuild 방지
// RepaintBoundary로 리페인트 범위 제한
child: RepaintBoundary(
child: _buildMemberCard(membersState.members[index], controller),
),
builder: (context, child) {
double value = 1.0;
if (_pageController.position.haveDimensions) {
value = (_pageController.page! - index).abs();
value = (1 - (value * 0.25)).clamp(0.0, 1.0);
}
return Transform.scale(
scale: Curves.easeOut.transform(value),
child: child,
);
},
);
},
),
),
),
),
],
);
},
);
}
/// 멤버 카드
Widget _buildMemberCard(Member member, MembersController controller) {
final isFormer = member.isFormer;
final age = controller.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: (context, url) => 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(
controller.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(MembersState membersState) {
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: membersState.members.length,
itemBuilder: (context, index) {
final member = membersState.members[index];
final isSelected = index == membersState.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: (context, url) => 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': '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
'instagram': '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
};
final svg =
'''<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>''';
return SizedBox(
width: size,
height: size,
child: SvgPicture.string(
svg,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
);
}
}