diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 589ef93..542454b 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -41,5 +41,15 @@ + + + + + + + + + + diff --git a/app/assets/fonts/Pretendard-Black.otf b/app/assets/fonts/Pretendard-Black.otf new file mode 100644 index 0000000..a0d849e Binary files /dev/null and b/app/assets/fonts/Pretendard-Black.otf differ diff --git a/app/assets/fonts/Pretendard-Bold.otf b/app/assets/fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/app/assets/fonts/Pretendard-Bold.otf differ diff --git a/app/assets/fonts/Pretendard-ExtraBold.otf b/app/assets/fonts/Pretendard-ExtraBold.otf new file mode 100644 index 0000000..388f3ca Binary files /dev/null and b/app/assets/fonts/Pretendard-ExtraBold.otf differ diff --git a/app/assets/fonts/Pretendard-ExtraLight.otf b/app/assets/fonts/Pretendard-ExtraLight.otf new file mode 100644 index 0000000..40c8b69 Binary files /dev/null and b/app/assets/fonts/Pretendard-ExtraLight.otf differ diff --git a/app/assets/fonts/Pretendard-Light.otf b/app/assets/fonts/Pretendard-Light.otf new file mode 100644 index 0000000..228679e Binary files /dev/null and b/app/assets/fonts/Pretendard-Light.otf differ diff --git a/app/assets/fonts/Pretendard-Medium.otf b/app/assets/fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/app/assets/fonts/Pretendard-Medium.otf differ diff --git a/app/assets/fonts/Pretendard-Regular.otf b/app/assets/fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/app/assets/fonts/Pretendard-Regular.otf differ diff --git a/app/assets/fonts/Pretendard-SemiBold.otf b/app/assets/fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/app/assets/fonts/Pretendard-SemiBold.otf differ diff --git a/app/assets/fonts/Pretendard-Thin.otf b/app/assets/fonts/Pretendard-Thin.otf new file mode 100644 index 0000000..77e792d Binary files /dev/null and b/app/assets/fonts/Pretendard-Thin.otf differ diff --git a/app/assets/icons/calendar.svg b/app/assets/icons/calendar.svg new file mode 100644 index 0000000..9109053 --- /dev/null +++ b/app/assets/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/icons/disc-3.svg b/app/assets/icons/disc-3.svg new file mode 100644 index 0000000..d1413ae --- /dev/null +++ b/app/assets/icons/disc-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/icons/home.svg b/app/assets/icons/home.svg new file mode 100644 index 0000000..4e0ba9e --- /dev/null +++ b/app/assets/icons/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/icons/users.svg b/app/assets/icons/users.svg new file mode 100644 index 0000000..e99c86a --- /dev/null +++ b/app/assets/icons/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/lib/core/constants.dart b/app/lib/core/constants.dart index 73b541a..e1d1e46 100644 --- a/app/lib/core/constants.dart +++ b/app/lib/core/constants.dart @@ -6,12 +6,12 @@ import 'package:flutter/material.dart'; /// API 기본 URL const String apiBaseUrl = 'https://fromis9.caadiq.co.kr/api'; -/// 앱 테마 색상 +/// 앱 테마 색상 (웹과 동일) class AppColors { - // Primary 색상 (웹과 동일) - static const Color primary = Color(0xFF4B8B3B); - static const Color primaryLight = Color(0xFF6BA85A); - static const Color primaryDark = Color(0xFF3A6E2D); + // Primary 색상 (프로미스나인 팬덤 컬러) + static const Color primary = Color(0xFF548360); + static const Color primaryLight = Color(0xFF6A9A75); + static const Color primaryDark = Color(0xFF456E50); // 배경 색상 static const Color background = Color(0xFFFAFAFA); diff --git a/app/lib/main.dart b/app/lib/main.dart index 321c50c..66d22c1 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -65,6 +65,7 @@ class Fromis9App extends StatelessWidget { scrolledUnderElevation: 1, centerTitle: true, titleTextStyle: TextStyle( + fontFamily: 'Pretendard', color: AppColors.primary, fontSize: 20, fontWeight: FontWeight.bold, diff --git a/app/lib/models/album.dart b/app/lib/models/album.dart new file mode 100644 index 0000000..486a3bc --- /dev/null +++ b/app/lib/models/album.dart @@ -0,0 +1,46 @@ +/// 앨범 모델 +library; + +class Album { + final int id; + final String title; + final String? albumType; + final String? albumTypeShort; + final String? releaseDate; + final String? coverOriginalUrl; + final String? coverMediumUrl; + final String? coverThumbUrl; + final String? folderName; + final String? description; + + Album({ + required this.id, + required this.title, + this.albumType, + this.albumTypeShort, + this.releaseDate, + this.coverOriginalUrl, + this.coverMediumUrl, + this.coverThumbUrl, + this.folderName, + this.description, + }); + + factory Album.fromJson(Map json) { + return Album( + id: json['id'] as int, + title: json['title'] as String, + albumType: json['album_type'] as String?, + albumTypeShort: json['album_type_short'] as String?, + releaseDate: json['release_date'] as String?, + coverOriginalUrl: json['cover_original_url'] as String?, + coverMediumUrl: json['cover_medium_url'] as String?, + coverThumbUrl: json['cover_thumb_url'] as String?, + folderName: json['folder_name'] as String?, + description: json['description'] as String?, + ); + } + + /// 발매 년도 추출 + String? get releaseYear => releaseDate?.substring(0, 4); +} diff --git a/app/lib/models/member.dart b/app/lib/models/member.dart new file mode 100644 index 0000000..5f6387e --- /dev/null +++ b/app/lib/models/member.dart @@ -0,0 +1,34 @@ +/// 멤버 모델 +library; + +class Member { + final int id; + final String name; + final String? imageUrl; + final String? birthDate; + final String? position; + final String? instagram; + final bool isFormer; + + Member({ + required this.id, + required this.name, + this.imageUrl, + this.birthDate, + this.position, + this.instagram, + this.isFormer = false, + }); + + factory Member.fromJson(Map json) { + return Member( + id: json['id'] as int, + name: json['name'] as String, + imageUrl: json['image_url'] as String?, + birthDate: json['birth_date'] as String?, + position: json['position'] as String?, + instagram: json['instagram'] as String?, + isFormer: json['is_former'] == 1 || json['is_former'] == true, + ); + } +} diff --git a/app/lib/models/schedule.dart b/app/lib/models/schedule.dart new file mode 100644 index 0000000..6d837c0 --- /dev/null +++ b/app/lib/models/schedule.dart @@ -0,0 +1,61 @@ +/// 일정 모델 +library; + +class Schedule { + final int id; + final String title; + final String date; + final String? time; + final String? endDate; + final String? endTime; + final String? description; + final String? categoryName; + final String? categoryColor; + final String? memberNames; + final String? sourceUrl; + final String? sourceName; + + Schedule({ + required this.id, + required this.title, + required this.date, + this.time, + this.endDate, + this.endTime, + this.description, + this.categoryName, + this.categoryColor, + this.memberNames, + this.sourceUrl, + this.sourceName, + }); + + factory Schedule.fromJson(Map json) { + return Schedule( + id: json['id'] as int, + title: json['title'] as String, + date: json['date'] as String, + time: json['time'] as String?, + endDate: json['end_date'] as String?, + endTime: json['end_time'] as String?, + description: json['description'] as String?, + categoryName: json['category_name'] as String?, + categoryColor: json['category_color'] as String?, + memberNames: json['member_names'] as String?, + sourceUrl: json['source_url'] as String?, + sourceName: json['source_name'] as String?, + ); + } + + /// 멤버 리스트 반환 + List get memberList { + if (memberNames == null || memberNames!.isEmpty) return []; + return memberNames!.split(',').map((n) => n.trim()).where((n) => n.isNotEmpty).toList(); + } + + /// 시간 포맷 (HH:mm) + String? get formattedTime { + if (time == null) return null; + return time!.length >= 5 ? time!.substring(0, 5) : time; + } +} diff --git a/app/lib/services/albums_service.dart b/app/lib/services/albums_service.dart new file mode 100644 index 0000000..f559017 --- /dev/null +++ b/app/lib/services/albums_service.dart @@ -0,0 +1,18 @@ +/// 앨범 API 서비스 +library; + +import '../models/album.dart'; +import 'api_client.dart'; + +/// 앨범 목록 조회 +Future> getAlbums() async { + final response = await dio.get('/albums'); + final List data = response.data; + return data.map((json) => Album.fromJson(json)).toList(); +} + +/// 최신 앨범 N개 조회 +Future> getRecentAlbums(int count) async { + final albums = await getAlbums(); + return albums.take(count).toList(); +} diff --git a/app/lib/services/api_client.dart b/app/lib/services/api_client.dart new file mode 100644 index 0000000..f459fec --- /dev/null +++ b/app/lib/services/api_client.dart @@ -0,0 +1,17 @@ +/// API 클라이언트 설정 +library; + +import 'package:dio/dio.dart'; +import '../core/constants.dart'; + +/// Dio 인스턴스 (싱글톤) +final Dio dio = Dio( + BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + headers: { + 'Content-Type': 'application/json', + }, + ), +); diff --git a/app/lib/services/members_service.dart b/app/lib/services/members_service.dart new file mode 100644 index 0000000..1053fa5 --- /dev/null +++ b/app/lib/services/members_service.dart @@ -0,0 +1,18 @@ +/// 멤버 API 서비스 +library; + +import '../models/member.dart'; +import 'api_client.dart'; + +/// 멤버 목록 조회 +Future> getMembers() async { + final response = await dio.get('/members'); + final List data = response.data; + return data.map((json) => Member.fromJson(json)).toList(); +} + +/// 활동 중인 멤버만 조회 +Future> getActiveMembers() async { + final members = await getMembers(); + return members.where((m) => !m.isFormer).toList(); +} diff --git a/app/lib/services/schedules_service.dart b/app/lib/services/schedules_service.dart new file mode 100644 index 0000000..f603c6b --- /dev/null +++ b/app/lib/services/schedules_service.dart @@ -0,0 +1,33 @@ +/// 일정 API 서비스 +library; + +import 'package:intl/intl.dart'; +import '../models/schedule.dart'; +import 'api_client.dart'; + +/// 오늘 날짜 (KST) 반환 +String getTodayKST() { + final now = DateTime.now(); + return DateFormat('yyyy-MM-dd').format(now); +} + +/// 일정 목록 조회 (월별) +Future> getSchedules(int year, int month) async { + final response = await dio.get('/schedules', queryParameters: { + 'year': year.toString(), + 'month': month.toString(), + }); + final List data = response.data; + return data.map((json) => Schedule.fromJson(json)).toList(); +} + +/// 다가오는 일정 N개 조회 (오늘 이후) - 웹과 동일 +Future> getUpcomingSchedules(int limit) async { + final todayStr = getTodayKST(); + final response = await dio.get('/schedules', queryParameters: { + 'startDate': todayStr, + 'limit': limit.toString(), + }); + final List data = response.data; + return data.map((json) => Schedule.fromJson(json)).toList(); +} diff --git a/app/lib/views/home/home_view.dart b/app/lib/views/home/home_view.dart index 458c9b7..1366f05 100644 --- a/app/lib/views/home/home_view.dart +++ b/app/lib/views/home/home_view.dart @@ -1,42 +1,772 @@ -/// 홈 화면 +/// 홈 화면 - 모바일 웹과 픽셀 단위까지 동일한 디자인 + 애니메이션 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 StatelessWidget { +class HomeView extends StatefulWidget { const HomeView({super.key}); @override - Widget build(BuildContext context) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.home_outlined, - size: 64, - color: AppColors.textTertiary, - ), - SizedBox(height: 16), - Text( - '홈', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: AppColors.textSecondary, - ), - ), - SizedBox(height: 8), - Text( - '홈 화면 준비 중', - style: TextStyle( - fontSize: 14, - color: AppColors.textTertiary, - ), - ), - ], + State createState() => _HomeViewState(); +} + +class _HomeViewState extends State with TickerProviderStateMixin { + List _members = []; + List _albums = []; + List _schedules = []; + bool _isLoading = true; + bool _dataLoaded = false; + + // 애니메이션 컨트롤러 + late AnimationController _animController; + + // 각 섹션별 애니메이션 + late Animation _heroOpacity; + late Animation _heroContentOpacity; + late Animation _heroContentSlide; + late Animation _membersSectionOpacity; + late Animation _membersSectionSlide; + late Animation _albumsSectionOpacity; + late Animation _albumsSectionSlide; + late Animation _schedulesSectionOpacity; + late Animation _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(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(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( + 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(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( + 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(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( + 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(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( + 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 _loadData() async { + try { + final results = await Future.wait([ + getActiveMembers(), + getRecentAlbums(2), + getUpcomingSchedules(3), + ]); + + setState(() { + _members = results[0] as List; + _albums = results[1] as List; + _schedules = results[2] as List; + _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(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animController, + curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), + ), + ); + final itemScale = Tween(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(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animController, + curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), + ), + ); + final itemSlide = Tween(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(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animController, + curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut), + ), + ); + final itemSlide = Tween(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': '', + 'tag': '', + 'chevron-right': '', + }; + + final svg = '''${icons[name]}'''; + + 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), + ], + ), + ), + ], + ); + } } diff --git a/app/lib/views/main_shell.dart b/app/lib/views/main_shell.dart index 82bc71a..a11d120 100644 --- a/app/lib/views/main_shell.dart +++ b/app/lib/views/main_shell.dart @@ -3,7 +3,7 @@ library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../core/constants.dart'; /// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠) @@ -14,20 +14,42 @@ class MainShell extends StatelessWidget { @override Widget build(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + final isMembersPage = location == '/members'; + return Scaffold( backgroundColor: AppColors.background, - // 앱바 (툴바) - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - scrolledUnderElevation: 1, - centerTitle: true, - title: Text( - _getTitle(context), - style: const TextStyle( - color: AppColors.primary, - fontSize: 20, - fontWeight: FontWeight.bold, + // 앱바 (툴바) - 멤버 페이지에서는 그림자 제거 (인디케이터와 계단 효과 방지) + appBar: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: isMembersPage + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: SafeArea( + child: SizedBox( + height: 56, + child: Center( + child: Text( + _getTitle(context), + style: const TextStyle( + fontFamily: 'Pretendard', + color: AppColors.primary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), ), ), ), @@ -63,7 +85,7 @@ class _BottomNavBar extends StatelessWidget { @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.path; - + return Container( decoration: const BoxDecoration( color: Colors.white, @@ -78,25 +100,25 @@ class _BottomNavBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _NavItem( - icon: LucideIcons.home, + iconName: 'home', label: '홈', isActive: location == '/', onTap: () => context.go('/'), ), _NavItem( - icon: LucideIcons.users, + iconName: 'users', label: '멤버', isActive: location == '/members', onTap: () => context.go('/members'), ), _NavItem( - icon: LucideIcons.disc3, + iconName: 'disc-3', label: '앨범', isActive: location.startsWith('/album'), onTap: () => context.go('/album'), ), _NavItem( - icon: LucideIcons.calendar, + iconName: 'calendar', label: '일정', isActive: location.startsWith('/schedule'), onTap: () => context.go('/schedule'), @@ -109,34 +131,51 @@ class _BottomNavBar extends StatelessWidget { } } -/// 네비게이션 아이템 +/// 네비게이션 아이템 - SVG 아이콘 사용으로 strokeWidth 조절 가능 class _NavItem extends StatelessWidget { - final IconData icon; + final String iconName; final String label; final bool isActive; final VoidCallback onTap; const _NavItem({ - required this.icon, + required this.iconName, required this.label, required this.isActive, required this.onTap, }); + /// SVG 아이콘 문자열 생성 (strokeWidth 동적 조절) + String _getSvgString(String name, double strokeWidth) { + const icons = { + 'home': '', + 'users': '', + 'disc-3': '', + 'calendar': '', + }; + + return '''${icons[name]}'''; + } + @override Widget build(BuildContext context) { final color = isActive ? AppColors.primary : AppColors.textTertiary; - + // 웹과 동일: 활성화 시 strokeWidth=2.5, 비활성화 시 strokeWidth=2 + final strokeWidth = isActive ? 2.5 : 2.0; + return Expanded( child: InkWell( onTap: onTap, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - icon, - size: 22, - color: color, + SizedBox( + width: 22, + height: 22, + child: SvgPicture.string( + _getSvgString(iconName, strokeWidth), + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ), ), const SizedBox(height: 4), Text( diff --git a/app/lib/views/members/members_view.dart b/app/lib/views/members/members_view.dart index 0f8048c..2df96e6 100644 --- a/app/lib/views/members/members_view.dart +++ b/app/lib/views/members/members_view.dart @@ -1,42 +1,595 @@ -/// 멤버 화면 +/// 멤버 화면 - 카드 스와이프 스타일 library; import 'package:flutter/material.dart'; +import 'package:flutter/services.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 '../../services/members_service.dart'; -class MembersView extends StatelessWidget { +class MembersView extends StatefulWidget { const MembersView({super.key}); + @override + State createState() => _MembersViewState(); +} + +class _MembersViewState extends State with TickerProviderStateMixin { + List _members = []; + bool _isLoading = true; + int _currentIndex = 0; + late PageController _pageController; + late ScrollController _indicatorScrollController; + late AnimationController _animController; + + // 애니메이션 + late Animation _indicatorOpacity; + late Animation _indicatorSlide; + late Animation _cardOpacity; + late Animation _cardSlide; + + // 인디케이터 아이템 크기 (48px 썸네일 + 12px 마진) + static const double _indicatorItemWidth = 64.0; + + // 이전 경로 저장 (탭 전환 감지용) + String? _previousPath; + + @override + void initState() { + super.initState(); + _pageController = PageController(viewportFraction: 0.88); + _indicatorScrollController = ScrollController(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _setupAnimations(); + _loadData(); + } + + /// 애니메이션 설정 + void _setupAnimations() { + // 인디케이터 페이드인 (0~0.4) + _indicatorOpacity = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animController, + curve: const Interval(0, 0.4, curve: Curves.easeOut), + ), + ); + + // 인디케이터 슬라이드 (위에서 아래로) + _indicatorSlide = Tween(begin: -20, end: 0).animate( + CurvedAnimation( + parent: _animController, + curve: const Interval(0, 0.4, curve: Curves.easeOut), + ), + ); + + // 카드 페이드인 (0.2~0.7) + _cardOpacity = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animController, + curve: const Interval(0.2, 0.7, curve: Curves.easeOut), + ), + ); + + // 카드 슬라이드 (아래에서 위로) + _cardSlide = Tween(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; + final targetOffset = (index * _indicatorItemWidth) - (screenWidth / 2) + (_indicatorItemWidth / 2) + 16; + final maxOffset = _indicatorScrollController.position.maxScrollExtent; + + _indicatorScrollController.animateTo( + targetOffset.clamp(0.0, maxOffset), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + Future _loadData() async { + try { + final members = await getMembers(); + // 현재 멤버 먼저, 전 멤버 나중에 정렬 + members.sort((a, b) { + if (a.isFormer != b.isFormer) { + return a.isFormer ? 1 : -1; + } + return 0; + }); + + setState(() { + _members = members; + _isLoading = false; + }); + _animController.forward(); + } catch (e) { + setState(() => _isLoading = false); + } + } + + /// 나이 계산 + int? _calculateAge(String? birthDate) { + if (birthDate == null) return null; + final birth = DateTime.tryParse(birthDate); + if (birth == null) return null; + final today = DateTime.now(); + int age = today.year - birth.year; + if (today.month < birth.month || + (today.month == birth.month && today.day < birth.day)) { + age--; + } + return age; + } + + /// 생일 포맷팅 + String _formatBirthDate(String? birthDate) { + if (birthDate == null) return ''; + return birthDate.substring(0, 10).replaceAll('-', '.'); + } + + /// 인스타그램 열기 (딥링크 우선, 없으면 웹) + Future _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) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.people_outline, - size: 64, - color: AppColors.textTertiary, - ), - SizedBox(height: 16), - Text( - '멤버', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: AppColors.textSecondary, + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ); + } + + if (_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(), + ), ), - ), - SizedBox(height: 8), - Text( - '멤버 화면 준비 중', - style: TextStyle( - fontSize: 14, - color: AppColors.textTertiary, + + // 메인 카드 영역 (애니메이션 적용) + Expanded( + child: Transform.translate( + offset: Offset(0, _cardSlide.value), + child: Opacity( + opacity: _cardOpacity.value, + child: PageView.builder( + controller: _pageController, + itemCount: _members.length, + padEnds: true, + onPageChanged: (index) { + setState(() => _currentIndex = index); + HapticFeedback.selectionClick(); + _scrollIndicatorToIndex(index); + }, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _pageController, + builder: (context, child) { + double value = 1.0; + if (_pageController.position.haveDimensions) { + value = (_pageController.page! - index).abs(); + value = (1 - (value * 0.15)).clamp(0.0, 1.0); + } + return Transform.scale( + scale: Curves.easeOut.transform(value), + child: _buildMemberCard(_members[index], index), + ); + }, + ); + }, + ), + ), + ), ), + ], + ); + }, + ); + } + + /// 멤버 카드 + Widget _buildMemberCard(Member member, int index) { + final isFormer = member.isFormer; + final age = _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: (_, _) => 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( + _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() { + 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: _members.length, + itemBuilder: (context, index) { + final member = _members[index]; + final isSelected = index == _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: (_, _) => 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': '', + 'instagram': '', + }; + + final svg = + '''${icons[name]}'''; + + return SizedBox( + width: size, + height: size, + child: SvgPicture.string( + svg, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ), ); } } diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/app/linux/flutter/generated_plugins.cmake +++ b/app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 252c004..368554e 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import path_provider_foundation import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/app/pubspec.lock b/app/pubspec.lock index b596141..01dc705 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -400,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -448,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" photo_view: dependency: "direct main" description: @@ -685,6 +709,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: @@ -693,6 +781,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" vector_math: dependency: transitive description: @@ -757,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e687f15..6a9917c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: fromis9 description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -42,6 +42,8 @@ dependencies: photo_view: ^0.15.0 path_provider: ^2.1.5 lucide_icons: ^0.257.0 + flutter_svg: ^2.0.17 + url_launcher: ^6.3.1 dev_dependencies: flutter_test: @@ -59,16 +61,13 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/icons/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images @@ -95,3 +94,25 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + + fonts: + - family: Pretendard + fonts: + - asset: assets/fonts/Pretendard-Thin.otf + weight: 100 + - asset: assets/fonts/Pretendard-ExtraLight.otf + weight: 200 + - asset: assets/fonts/Pretendard-Light.otf + weight: 300 + - asset: assets/fonts/Pretendard-Regular.otf + weight: 400 + - asset: assets/fonts/Pretendard-Medium.otf + weight: 500 + - asset: assets/fonts/Pretendard-SemiBold.otf + weight: 600 + - asset: assets/fonts/Pretendard-Bold.otf + weight: 700 + - asset: assets/fonts/Pretendard-ExtraBold.otf + weight: 800 + - asset: assets/fonts/Pretendard-Black.otf + weight: 900 diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 8b6d468..4f78848 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index b93c4c3..88b22e5 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST