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 = '''''';
+
+ 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 '''''';
+ }
+
@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 =
+ '''''';
+
+ 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