feat(app): 멤버 화면을 웹과 동일한 2열 그리드 + 모달 디자인으로 변경

- PageView 카드 스와이프 → 2열 그리드 레이아웃
- 상단 썸네일 인디케이터 제거
- 카드 탭 시 모달로 상세 정보 표시 (이미지, 이름, 생일, 인스타그램)
- 개별 카드 staggered 애니메이션 + 탭 scale 효과
- 컨트롤러에서 불필요한 currentIndex 상태 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-27 18:29:22 +09:00
parent 0ddde32bed
commit d6ef851b02
2 changed files with 346 additions and 488 deletions

View file

@ -11,13 +11,11 @@ import '../services/members_service.dart';
///
class MembersState {
final List<Member> members;
final int currentIndex;
final bool isLoading;
final String? error;
const MembersState({
this.members = const [],
this.currentIndex = 0,
this.isLoading = true,
this.error,
});
@ -25,21 +23,15 @@ class MembersState {
/// ( )
MembersState copyWith({
List<Member>? members,
int? currentIndex,
bool? isLoading,
String? error,
}) {
return MembersState(
members: members ?? this.members,
currentIndex: currentIndex ?? this.currentIndex,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
///
Member? get currentMember =>
members.isNotEmpty ? members[currentIndex] : null;
}
///
@ -77,13 +69,6 @@ class MembersController extends Notifier<MembersState> {
}
}
///
void setCurrentIndex(int index) {
if (index >= 0 && index < state.members.length) {
state = state.copyWith(currentIndex: index);
}
}
///
int? calculateAge(String? birthDate) {
if (birthDate == null) return null;

View file

@ -1,10 +1,9 @@
/// (MVCS의 View )
///
/// UI , Controller에 .
/// 2 +
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';
@ -21,121 +20,31 @@ class MembersView extends ConsumerStatefulWidget {
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;
// ( )
class _MembersViewState extends ConsumerState<MembersView> {
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),
),
);
}
//
Key _gridKey = UniqueKey();
@override
void didChangeDependencies() {
super.didChangeDependencies();
//
final currentPath = GoRouterState.of(context).uri.path;
if (_previousPath != null && _previousPath != currentPath && currentPath == '/members') {
_animController.reset();
_animController.forward();
setState(() => _gridKey = UniqueKey());
}
_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)) {
@ -144,25 +53,184 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
}
}
//
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);
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(
@ -176,375 +244,26 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
);
}
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),
),
),
//
final currentMembers = membersState.members.where((m) => !m.isFormer).toList();
// ( )
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,
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) {
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,
),
),
),
),
),
),
),
],
),
),
return _AnimatedMemberCard(
index: index,
member: currentMembers[index],
onTap: () => _showMemberModal(currentMembers[index]),
);
},
),
@ -554,7 +273,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
/// 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"/>',
'cake': '<path d="M20 21v-8a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8"/><path d="M4 16s.5-1 2-1 2.5 2 4 2 2.5-2 4-2 2.5 2 4 2 2-1 2-1"/><path d="M2 21h20"/><path d="M7 8v3"/><path d="M12 8v3"/><path d="M17 8v3"/><path d="M7 4h.01"/><path d="M12 4h.01"/><path d="M17 4h.01"/>',
'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"/>',
};
@ -571,3 +290,157 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
);
}
}
///
/// Framer Motion과 : delay index*50ms, opacity 01, y 200, 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<double> _opacity;
late Animation<Offset> _slide;
double _scale = 1.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_opacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_slide = Tween<Offset>(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,
),
),
),
),
],
),
),
),
),
),
);
}
}