홈, 멤버, 앨범 화면을 Riverpod 기반 MVCS 패턴으로 리팩토링. View는 UI와 애니메이션만, Controller는 상태와 비즈니스 로직 담당. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
748 lines
28 KiB
Dart
748 lines
28 KiB
Dart
/// 홈 화면 (MVCS의 View 레이어)
|
|
///
|
|
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.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/schedule.dart';
|
|
import '../../controllers/home_controller.dart';
|
|
|
|
class HomeView extends ConsumerStatefulWidget {
|
|
const HomeView({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomeView> createState() => _HomeViewState();
|
|
}
|
|
|
|
class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMixin {
|
|
// 애니메이션 컨트롤러
|
|
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;
|
|
bool _animationStarted = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_setupAnimations();
|
|
}
|
|
|
|
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;
|
|
final homeState = ref.read(homeProvider);
|
|
|
|
// 다른 탭에서 홈('/')으로 돌아왔을 때 애니메이션 재시작
|
|
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && homeState.dataLoaded) {
|
|
_startAnimations();
|
|
}
|
|
_previousPath = currentPath;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final homeState = ref.watch(homeProvider);
|
|
|
|
// 데이터 로드 완료 시 애니메이션 시작
|
|
if (homeState.dataLoaded && !_animationStarted) {
|
|
_animationStarted = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_startAnimations();
|
|
});
|
|
}
|
|
|
|
if (homeState.isLoading) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
);
|
|
}
|
|
|
|
return AnimatedBuilder(
|
|
animation: _animController,
|
|
builder: (context, child) {
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
_buildHeroSection(),
|
|
_buildMembersSection(homeState),
|
|
_buildAlbumsSection(homeState),
|
|
_buildSchedulesSection(homeState),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 히어로 섹션 - 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(HomeState homeState) {
|
|
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: homeState.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: (context, url) => 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(HomeState homeState) {
|
|
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: homeState.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: (context, url) => 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(HomeState homeState) {
|
|
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 (homeState.schedules.isEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
|
child: Text(
|
|
'다가오는 일정이 없습니다',
|
|
style: TextStyle(color: Colors.grey[400]),
|
|
),
|
|
)
|
|
else
|
|
// space-y-3(12px)
|
|
Column(
|
|
children: homeState.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 < homeState.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),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|