Flutter 앱: 멤버 화면 카드 스와이프 UI 및 홈 애니메이션 추가

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 00:07:59 +09:00
parent bf6d4567a3
commit 488e4094c8
33 changed files with 1808 additions and 92 deletions

View file

@ -41,5 +41,15 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- 인스타그램 앱 딥링크를 위한 쿼리 -->
<package android:name="com.instagram.android" />
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="instagram" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries> </queries>
</manifest> </manifest>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>

After

Width:  |  Height:  |  Size: 293 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="2"/><path d="M6 12c0-1.7.7-3.2 1.8-4.2"/><path d="M18 12c0 1.7-.7 3.2-1.8 4.2"/></svg>

After

Width:  |  Height:  |  Size: 326 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View file

@ -6,12 +6,12 @@ import 'package:flutter/material.dart';
/// API URL /// API URL
const String apiBaseUrl = 'https://fromis9.caadiq.co.kr/api'; const String apiBaseUrl = 'https://fromis9.caadiq.co.kr/api';
/// /// ( )
class AppColors { class AppColors {
// Primary ( ) // Primary ( )
static const Color primary = Color(0xFF4B8B3B); static const Color primary = Color(0xFF548360);
static const Color primaryLight = Color(0xFF6BA85A); static const Color primaryLight = Color(0xFF6A9A75);
static const Color primaryDark = Color(0xFF3A6E2D); static const Color primaryDark = Color(0xFF456E50);
// //
static const Color background = Color(0xFFFAFAFA); static const Color background = Color(0xFFFAFAFA);

View file

@ -65,6 +65,7 @@ class Fromis9App extends StatelessWidget {
scrolledUnderElevation: 1, scrolledUnderElevation: 1,
centerTitle: true, centerTitle: true,
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
fontFamily: 'Pretendard',
color: AppColors.primary, color: AppColors.primary,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

46
app/lib/models/album.dart Normal file
View file

@ -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<String, dynamic> 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);
}

View file

@ -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<String, dynamic> 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,
);
}
}

View file

@ -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<String, dynamic> 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<String> 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;
}
}

View file

@ -0,0 +1,18 @@
/// API
library;
import '../models/album.dart';
import 'api_client.dart';
///
Future<List<Album>> getAlbums() async {
final response = await dio.get('/albums');
final List<dynamic> data = response.data;
return data.map((json) => Album.fromJson(json)).toList();
}
/// N개
Future<List<Album>> getRecentAlbums(int count) async {
final albums = await getAlbums();
return albums.take(count).toList();
}

View file

@ -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',
},
),
);

View file

@ -0,0 +1,18 @@
/// API
library;
import '../models/member.dart';
import 'api_client.dart';
///
Future<List<Member>> getMembers() async {
final response = await dio.get('/members');
final List<dynamic> data = response.data;
return data.map((json) => Member.fromJson(json)).toList();
}
///
Future<List<Member>> getActiveMembers() async {
final members = await getMembers();
return members.where((m) => !m.isFormer).toList();
}

View file

@ -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<List<Schedule>> getSchedules(int year, int month) async {
final response = await dio.get('/schedules', queryParameters: {
'year': year.toString(),
'month': month.toString(),
});
final List<dynamic> data = response.data;
return data.map((json) => Schedule.fromJson(json)).toList();
}
/// N개 ( ) -
Future<List<Schedule>> getUpcomingSchedules(int limit) async {
final todayStr = getTodayKST();
final response = await dio.get('/schedules', queryParameters: {
'startDate': todayStr,
'limit': limit.toString(),
});
final List<dynamic> data = response.data;
return data.map((json) => Schedule.fromJson(json)).toList();
}

View file

@ -1,42 +1,772 @@
/// /// - +
library; library;
import 'package:flutter/material.dart'; 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 '../../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}); const HomeView({super.key});
@override @override
Widget build(BuildContext context) { State<HomeView> createState() => _HomeViewState();
return const Center( }
child: Column(
mainAxisAlignment: MainAxisAlignment.center, class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
children: [ List<Member> _members = [];
Icon( List<Album> _albums = [];
Icons.home_outlined, List<Schedule> _schedules = [];
size: 64, bool _isLoading = true;
color: AppColors.textTertiary, bool _dataLoaded = false;
),
SizedBox(height: 16), //
Text( late AnimationController _animController;
'',
style: TextStyle( //
fontSize: 24, late Animation<double> _heroOpacity;
fontWeight: FontWeight.bold, late Animation<double> _heroContentOpacity;
color: AppColors.textSecondary, late Animation<Offset> _heroContentSlide;
), late Animation<double> _membersSectionOpacity;
), late Animation<Offset> _membersSectionSlide;
SizedBox(height: 8), late Animation<double> _albumsSectionOpacity;
Text( late Animation<Offset> _albumsSectionSlide;
'홈 화면 준비 중', late Animation<double> _schedulesSectionOpacity;
style: TextStyle( late Animation<Offset> _schedulesSectionSlide;
fontSize: 14,
color: AppColors.textTertiary, // ( )
), 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 01, duration 0.5s (0~500ms)
_heroOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0, 0.42, curve: Curves.easeOut), // 0.5/1.2 0.42
),
);
// : delay 0.2s, duration 0.5s (200~700ms)
_heroContentOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.17, 0.58, curve: Curves.easeOut), // 0.2/1.2, 0.7/1.2
),
);
_heroContentSlide = Tween<Offset>(
begin: const Offset(0, 20),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.17, 0.58, curve: Curves.easeOut),
),
);
// : delay 0.3s, duration 0.5s (300~800ms)
_membersSectionOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.25, 0.67, curve: Curves.easeOut), // 0.3/1.2, 0.8/1.2
),
);
_membersSectionSlide = Tween<Offset>(
begin: const Offset(0, 20),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.25, 0.67, curve: Curves.easeOut),
),
);
// : delay 0.5s, duration 0.5s (500~1000ms)
_albumsSectionOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.42, 0.83, curve: Curves.easeOut), // 0.5/1.2, 1.0/1.2
),
);
_albumsSectionSlide = Tween<Offset>(
begin: const Offset(0, 20),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.42, 0.83, curve: Curves.easeOut),
),
);
// : delay 0.7s, duration 0.5s (700~1200ms)
_schedulesSectionOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.58, 1.0, curve: Curves.easeOut), // 0.7/1.2, 1.2/1.2
),
);
_schedulesSectionSlide = Tween<Offset>(
begin: const Offset(0, 20),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.58, 1.0, curve: Curves.easeOut),
), ),
); );
} }
/// ( )
void _startAnimations() {
_animController.reset();
_animController.forward();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// go_router에서
final currentPath = GoRouterState.of(context).uri.path;
// ('/')
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && _dataLoaded) {
_startAnimations();
}
_previousPath = currentPath;
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
Future<void> _loadData() async {
try {
final results = await Future.wait([
getActiveMembers(),
getRecentAlbums(2),
getUpcomingSchedules(3),
]);
setState(() {
_members = results[0] as List<Member>;
_albums = results[1] as List<Album>;
_schedules = results[2] as List<Schedule>;
_isLoading = false;
_dataLoaded = true;
});
//
_startAnimations();
} catch (e) {
setState(() {
_isLoading = false;
_dataLoaded = true;
});
_startAnimations();
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
return AnimatedBuilder(
animation: _animController,
builder: (context, child) {
return SingleChildScrollView(
child: Column(
children: [
_buildHeroSection(),
_buildMembersSection(),
_buildAlbumsSection(),
_buildSchedulesSection(),
const SizedBox(height: 16),
],
),
);
},
);
}
/// - py-12(48px) px-4(16px)
Widget _buildHeroSection() {
return Opacity(
opacity: _heroOpacity.value,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 16),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.primaryDark],
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
// 1 ( ) - w-32(128px)
Positioned(
right: -64,
top: -64,
child: Container(
width: 128,
height: 128,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
),
),
),
// 2 ( ) - w-24(96px)
Positioned(
left: -48,
bottom: -48,
child: Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.05),
),
),
),
// ( )
Center(
child: Opacity(
opacity: _heroContentOpacity.value,
child: Transform.translate(
offset: _heroContentSlide.value,
child: Column(
children: [
// text-3xl(30px) font-bold mb-1(4px)
const Text(
'fromis_9',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
// text-lg(18px) font-light mb-3(12px)
const Text(
'프로미스나인',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w300,
color: Colors.white,
),
),
const SizedBox(height: 12),
// text-sm(14px) opacity-80
Text(
'인사드리겠습니다. 둘, 셋!\n이제는 약속해 소중히 간직해,\n당신의 아이돌로 성장하겠습니다!',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.8),
height: 1.5,
),
),
],
),
),
),
),
],
),
),
);
}
/// - px-4(16px) py-6(24px)
Widget _buildMembersSection() {
return Opacity(
opacity: _membersSectionOpacity.value,
child: Transform.translate(
offset: _membersSectionSlide.value,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
children: [
// mb-4(16px)
_buildSectionHeader('멤버', () => context.go('/members')),
const SizedBox(height: 16),
// grid-cols-5 gap-2(8px)
Row(
children: _members.asMap().entries.map((entry) {
final index = entry.key;
final member = entry.value;
// : delay 0.4+index*0.05s, duration 0.3s
final itemDelay = 0.33 + index * 0.04; // 0.4/1.2 + index * 0.05/1.2
final itemEnd = itemDelay + 0.25; // 0.3/1.2
final itemOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
final itemScale = Tween<double>(begin: 0.8, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Opacity(
opacity: itemOpacity.value,
child: Transform.scale(
scale: itemScale.value,
child: Column(
children: [
// mb-1(4px)
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey[200],
),
clipBehavior: Clip.antiAlias,
child: member.imageUrl != null
? CachedNetworkImage(
imageUrl: member.imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
)
: null,
),
),
const SizedBox(height: 4),
// text-xs(12px) font-medium
Text(
member.name,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
);
}).toList(),
),
],
),
),
),
);
}
/// - px-4(16px) py-6(24px)
Widget _buildAlbumsSection() {
return Opacity(
opacity: _albumsSectionOpacity.value,
child: Transform.translate(
offset: _albumsSectionSlide.value,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
children: [
// mb-4(16px)
_buildSectionHeader('앨범', () => context.go('/album')),
const SizedBox(height: 16),
// grid-cols-2 gap-3(12px)
Row(
children: _albums.asMap().entries.map((entry) {
final index = entry.key;
final album = entry.value;
// : delay 0.6+index*0.1s, duration 0.3s
final itemDelay = 0.5 + index * 0.08; // 0.6/1.2 + index * 0.1/1.2
final itemEnd = itemDelay + 0.25; // 0.3/1.2
final itemOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
final itemSlide = Tween<Offset>(begin: const Offset(0, 20), end: Offset.zero).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
return Expanded(
child: Padding(
padding: EdgeInsets.only(
left: index == 0 ? 0 : 6,
right: index == 1 ? 0 : 6,
),
child: Opacity(
opacity: itemOpacity.value,
child: Transform.translate(
offset: itemSlide.value,
child: GestureDetector(
onTap: () => context.go('/album/${album.folderName}'),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: album.coverThumbUrl != null
? CachedNetworkImage(
imageUrl: album.coverThumbUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: Colors.grey[200]),
)
: Container(color: Colors.grey[200]),
),
// p-3(12px)
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// font-medium text-sm(14px)
Text(
album.title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// text-xs(12px) text-gray-400
Text(
album.releaseYear ?? '',
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
],
),
),
],
),
),
),
),
),
),
);
}).toList(),
),
],
),
),
),
);
}
/// - px-4(16px) py-4(16px)
Widget _buildSchedulesSection() {
return Opacity(
opacity: _schedulesSectionOpacity.value,
child: Transform.translate(
offset: _schedulesSectionSlide.value,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// mb-4(16px)
_buildSectionHeader('다가오는 일정', () => context.go('/schedule')),
const SizedBox(height: 16),
if (_schedules.isEmpty)
Container(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
'다가오는 일정이 없습니다',
style: TextStyle(color: Colors.grey[400]),
),
)
else
// space-y-3(12px)
Column(
children: _schedules.asMap().entries.map((entry) {
final index = entry.key;
final schedule = entry.value;
// : delay 0.8+index*0.1s, duration 0.3s, x -200
final itemDelay = 0.67 + index * 0.08; // 0.8/1.2 + index * 0.1/1.2
final itemEnd = itemDelay + 0.25; // 0.3/1.2
final itemOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
final itemSlide = Tween<Offset>(begin: const Offset(-20, 0), end: Offset.zero).animate(
CurvedAnimation(
parent: _animController,
curve: Interval(itemDelay.clamp(0, 1), itemEnd.clamp(0, 1), curve: Curves.easeOut),
),
);
return Padding(
padding: EdgeInsets.only(bottom: index < _schedules.length - 1 ? 12 : 0),
child: Opacity(
opacity: itemOpacity.value,
child: Transform.translate(
offset: itemSlide.value,
child: _buildScheduleCard(schedule),
),
),
);
}).toList(),
),
],
),
),
),
);
}
/// - flex gap-4(16px) p-4(16px)
Widget _buildScheduleCard(Schedule schedule) {
final scheduleDate = DateTime.parse(schedule.date);
final now = DateTime.now();
final isCurrentYear = scheduleDate.year == now.year;
final isCurrentMonth = isCurrentYear && scheduleDate.month == now.month;
final weekdays = ['', '', '', '', '', '', ''];
final memberList = schedule.memberList;
return GestureDetector(
onTap: () => context.go('/schedule'),
child: Container(
// p-4(16px) rounded-xl(12px)
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFF3F4F6)), // border-gray-100
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: IntrinsicHeight(
child: Row(
children: [
// - min-w-[50px]
SizedBox(
width: 50,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// . - text-[10px]
if (!isCurrentYear)
Text(
'${scheduleDate.year}.${scheduleDate.month}',
style: TextStyle(
fontSize: 10,
color: Colors.grey[400],
fontWeight: FontWeight.w500,
),
)
else if (!isCurrentMonth)
Text(
'${scheduleDate.month}',
style: TextStyle(
fontSize: 10,
color: Colors.grey[400],
fontWeight: FontWeight.w500,
),
),
// - text-2xl(24px) font-bold text-primary
Text(
'${scheduleDate.day}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
// - text-xs(12px) text-gray-400
Text(
weekdays[scheduleDate.weekday % 7],
style: TextStyle(
fontSize: 12,
color: Colors.grey[400],
fontWeight: FontWeight.w500,
),
),
],
),
),
// gap-4(16px) + +
const SizedBox(width: 16),
// - w-px bg-gray-100
Container(width: 1, color: const Color(0xFFF3F4F6)),
const SizedBox(width: 16),
// - flex-1 min-w-0
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// font-semibold text-sm(14px) text-gray-800 line-clamp-2 leading-snug
Text(
schedule.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937), // text-gray-800
height: 1.375, // leading-snug
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// mt-2(8px) text-xs(12px) text-gray-400
const SizedBox(height: 8),
Row(
children: [
// gap-3(12px)
if (schedule.formattedTime != null) ...[
// gap-1(4px)
_buildIcon('clock', 12, Colors.grey[400]!),
const SizedBox(width: 4),
Text(
schedule.formattedTime!,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
const SizedBox(width: 12),
],
if (schedule.categoryName != null) ...[
_buildIcon('tag', 12, Colors.grey[400]!),
const SizedBox(width: 4),
Text(
schedule.categoryName!,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
],
],
),
// mt-2(8px)
if (memberList.isNotEmpty) ...[
const SizedBox(height: 8),
// gap-1(4px)
Wrap(
spacing: 4,
runSpacing: 4,
children: (memberList.length >= 5 ? ['프로미스나인'] : memberList)
.map((name) => Container(
// px-2(8px) py-0.5(2px) rounded-full text-[10px]
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1), // bg-primary/10
borderRadius: BorderRadius.circular(12),
),
child: Text(
name,
style: const TextStyle(
fontSize: 10,
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
))
.toList(),
),
],
],
),
),
],
),
),
),
);
}
/// SVG
Widget _buildIcon(String name, double size, Color color) {
const icons = {
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
};
final svg = '''<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>''';
return SizedBox(
width: size,
height: size,
child: SvgPicture.string(
svg,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
);
}
/// - text-lg(18px) font-bold
Widget _buildSectionHeader(String title, VoidCallback onTap) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
GestureDetector(
onTap: onTap,
child: Row(
children: [
// text-sm(14px) text-primary gap-1(4px)
const Text(
'전체보기',
style: TextStyle(fontSize: 14, color: AppColors.primary),
),
const SizedBox(width: 4),
_buildIcon('chevron-right', 16, AppColors.primary),
],
),
),
],
);
}
} }

View file

@ -3,7 +3,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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'; import '../core/constants.dart';
/// ( + + ) /// ( + + )
@ -14,20 +14,42 @@ class MainShell extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final isMembersPage = location == '/members';
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
// () // () - ( )
appBar: AppBar( appBar: PreferredSize(
backgroundColor: Colors.white, preferredSize: const Size.fromHeight(56),
elevation: 0, child: Container(
scrolledUnderElevation: 1, decoration: BoxDecoration(
centerTitle: true, color: Colors.white,
title: Text( boxShadow: isMembersPage
_getTitle(context), ? null
style: const TextStyle( : [
color: AppColors.primary, BoxShadow(
fontSize: 20, color: Colors.black.withValues(alpha: 0.05),
fontWeight: FontWeight.bold, 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,
),
),
),
),
), ),
), ),
), ),
@ -78,25 +100,25 @@ class _BottomNavBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_NavItem( _NavItem(
icon: LucideIcons.home, iconName: 'home',
label: '', label: '',
isActive: location == '/', isActive: location == '/',
onTap: () => context.go('/'), onTap: () => context.go('/'),
), ),
_NavItem( _NavItem(
icon: LucideIcons.users, iconName: 'users',
label: '멤버', label: '멤버',
isActive: location == '/members', isActive: location == '/members',
onTap: () => context.go('/members'), onTap: () => context.go('/members'),
), ),
_NavItem( _NavItem(
icon: LucideIcons.disc3, iconName: 'disc-3',
label: '앨범', label: '앨범',
isActive: location.startsWith('/album'), isActive: location.startsWith('/album'),
onTap: () => context.go('/album'), onTap: () => context.go('/album'),
), ),
_NavItem( _NavItem(
icon: LucideIcons.calendar, iconName: 'calendar',
label: '일정', label: '일정',
isActive: location.startsWith('/schedule'), isActive: location.startsWith('/schedule'),
onTap: () => context.go('/schedule'), onTap: () => context.go('/schedule'),
@ -109,23 +131,37 @@ class _BottomNavBar extends StatelessWidget {
} }
} }
/// /// - SVG strokeWidth
class _NavItem extends StatelessWidget { class _NavItem extends StatelessWidget {
final IconData icon; final String iconName;
final String label; final String label;
final bool isActive; final bool isActive;
final VoidCallback onTap; final VoidCallback onTap;
const _NavItem({ const _NavItem({
required this.icon, required this.iconName,
required this.label, required this.label,
required this.isActive, required this.isActive,
required this.onTap, required this.onTap,
}); });
/// SVG (strokeWidth )
String _getSvgString(String name, double strokeWidth) {
const icons = {
'home': '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'users': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'disc-3': '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="2"/><path d="M6 12c0-1.7.7-3.2 1.8-4.2"/><path d="M18 12c0 1.7-.7 3.2-1.8 4.2"/>',
'calendar': '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
};
return '''<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>''';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = isActive ? AppColors.primary : AppColors.textTertiary; final color = isActive ? AppColors.primary : AppColors.textTertiary;
// : strokeWidth=2.5, strokeWidth=2
final strokeWidth = isActive ? 2.5 : 2.0;
return Expanded( return Expanded(
child: InkWell( child: InkWell(
@ -133,10 +169,13 @@ class _NavItem extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( SizedBox(
icon, width: 22,
size: 22, height: 22,
color: color, child: SvgPicture.string(
_getSvgString(iconName, strokeWidth),
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(

View file

@ -1,42 +1,595 @@
/// /// -
library; library;
import 'package:flutter/material.dart'; 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 '../../core/constants.dart';
import '../../models/member.dart';
import '../../services/members_service.dart';
class MembersView extends StatelessWidget { class MembersView extends StatefulWidget {
const MembersView({super.key}); const MembersView({super.key});
@override
State<MembersView> createState() => _MembersViewState();
}
class _MembersViewState extends State<MembersView> with TickerProviderStateMixin {
List<Member> _members = [];
bool _isLoading = true;
int _currentIndex = 0;
late PageController _pageController;
late ScrollController _indicatorScrollController;
late AnimationController _animController;
//
late Animation<double> _indicatorOpacity;
late Animation<double> _indicatorSlide;
late Animation<double> _cardOpacity;
late Animation<double> _cardSlide;
// (48px + 12px )
static const double _indicatorItemWidth = 64.0;
// ( )
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<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0, 0.4, curve: Curves.easeOut),
),
);
// ( )
_indicatorSlide = Tween<double>(begin: -20, end: 0).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0, 0.4, curve: Curves.easeOut),
),
);
// (0.2~0.7)
_cardOpacity = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.2, 0.7, curve: Curves.easeOut),
),
);
// ( )
_cardSlide = Tween<double>(begin: 40, end: 0).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.2, 0.7, curve: Curves.easeOut),
),
);
}
@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<void> _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<void> _openInstagram(String? url) async {
if (url == null) return;
// URL에서 username (instagram.com/username )
String? username;
final uri = Uri.tryParse(url);
if (uri != null && uri.pathSegments.isNotEmpty) {
username = uri.pathSegments.first;
}
//
if (username != null) {
final deepLink = Uri.parse('instagram://user?username=$username');
if (await canLaunchUrl(deepLink)) {
await launchUrl(deepLink);
return;
}
}
//
final webUri = Uri.parse(url);
if (await canLaunchUrl(webUri)) {
await launchUrl(webUri, mode: LaunchMode.externalApplication);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Center( if (_isLoading) {
child: Column( return const Center(
mainAxisAlignment: MainAxisAlignment.center, child: CircularProgressIndicator(color: AppColors.primary),
children: [ );
Icon( }
Icons.people_outline,
size: 64, if (_members.isEmpty) {
color: AppColors.textTertiary, return const Center(
), child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)),
SizedBox(height: 16), );
Text( }
'멤버',
style: TextStyle( return AnimatedBuilder(
fontSize: 24, animation: _animController,
fontWeight: FontWeight.bold, builder: (context, child) {
color: AppColors.textSecondary, return Column(
children: [
// ( )
Transform.translate(
offset: Offset(0, _indicatorSlide.value),
child: Opacity(
opacity: _indicatorOpacity.value,
child: _buildThumbnailIndicator(),
),
), ),
),
SizedBox(height: 8), // ( )
Text( Expanded(
'멤버 화면 준비 중', child: Transform.translate(
style: TextStyle( offset: Offset(0, _cardSlide.value),
fontSize: 14, child: Opacity(
color: AppColors.textTertiary, 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': '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
'instagram': '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
};
final svg =
'''<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>''';
return SizedBox(
width: size,
height: size,
child: SvgPicture.string(
svg,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
); );
} }
} }

View file

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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);
} }

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -7,8 +7,10 @@ import Foundation
import path_provider_foundation import path_provider_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View file

@ -206,6 +206,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -400,6 +408,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -448,6 +464,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
photo_view: photo_view:
dependency: "direct main" dependency: "direct main"
description: description:
@ -685,6 +709,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: uuid:
dependency: transitive dependency: transitive
description: description:
@ -693,6 +781,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.2" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -757,6 +869,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View file

@ -2,7 +2,7 @@ name: fromis9
description: "A new Flutter project." description: "A new Flutter project."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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. # The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43 # A version number is three numbers separated by dots, like 1.2.43
@ -42,6 +42,8 @@ dependencies:
photo_view: ^0.15.0 photo_view: ^0.15.0
path_provider: ^2.1.5 path_provider: ^2.1.5
lucide_icons: ^0.257.0 lucide_icons: ^0.257.0
flutter_svg: ^2.0.17
url_launcher: ^6.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -59,16 +61,13 @@ dev_dependencies:
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: assets:
# assets: - assets/icons/
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images
@ -95,3 +94,25 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # 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

View file

@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST