fromis_9/app/lib/views/home/home_view.dart
caadiq 488e4094c8 Flutter 앱: 멤버 화면 카드 스와이프 UI 및 홈 애니메이션 추가
- 홈 화면에 웹과 동일한 framer-motion 스타일 애니메이션 적용
- 멤버 화면 카드 스와이프 디자인으로 재구현
- 인스타그램 딥링크 지원 (url_launcher, AndroidManifest queries)
- flutter_svg 추가로 SVG 아이콘 동적 strokeWidth 지원
- 바텀 네비게이션 아이콘 strokeWidth 웹과 동일하게 조정
- 멤버 화면 툴바 그림자 제거 및 인디케이터 그림자 최적화
- 탭 전환 시 애니메이션 재생 기능 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:07:59 +09:00

772 lines
29 KiB
Dart

/// 홈 화면 - 모바일 웹과 픽셀 단위까지 동일한 디자인 + 애니메이션
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<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
List<Member> _members = [];
List<Album> _albums = [];
List<Schedule> _schedules = [];
bool _isLoading = true;
bool _dataLoaded = false;
// 애니메이션 컨트롤러
late AnimationController _animController;
// 각 섹션별 애니메이션
late Animation<double> _heroOpacity;
late Animation<double> _heroContentOpacity;
late Animation<Offset> _heroContentSlide;
late Animation<double> _membersSectionOpacity;
late Animation<Offset> _membersSectionSlide;
late Animation<double> _albumsSectionOpacity;
late Animation<Offset> _albumsSectionSlide;
late Animation<double> _schedulesSectionOpacity;
late Animation<Offset> _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<double>(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<double>(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<Offset>(
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<double>(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<Offset>(
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<double>(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<Offset>(
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<double>(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<Offset>(
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<void> _loadData() async {
try {
final results = await Future.wait([
getActiveMembers(),
getRecentAlbums(2),
getUpcomingSchedules(3),
]);
setState(() {
_members = results[0] as List<Member>;
_albums = results[1] as List<Album>;
_schedules = results[2] as List<Schedule>;
_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(),
const SizedBox(height: 16),
],
),
);
},
);
}
/// 히어로 섹션 - 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<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
final itemScale = Tween<double>(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<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
final itemSlide = Tween<Offset>(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<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
final itemSlide = Tween<Offset>(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': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
};
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),
),
);
}
/// 섹션 헤더 - 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),
],
),
),
],
);
}
}