Compare commits
7 commits
9d18449d3a
...
3cf07a8214
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cf07a8214 | |||
| 9498559f6b | |||
| 3ce8d7ec7d | |||
| c37d7e14af | |||
| 6284d216bd | |||
| d6ef851b02 | |||
| 0ddde32bed |
20 changed files with 1536 additions and 1171 deletions
|
|
@ -11,13 +11,11 @@ import '../services/members_service.dart';
|
|||
/// 멤버 상태
|
||||
class MembersState {
|
||||
final List<Member> members;
|
||||
final int currentIndex;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const MembersState({
|
||||
this.members = const [],
|
||||
this.currentIndex = 0,
|
||||
this.isLoading = true,
|
||||
this.error,
|
||||
});
|
||||
|
|
@ -25,21 +23,15 @@ class MembersState {
|
|||
/// 상태 복사 (불변성 유지)
|
||||
MembersState copyWith({
|
||||
List<Member>? members,
|
||||
int? currentIndex,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return MembersState(
|
||||
members: members ?? this.members,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 선택된 멤버
|
||||
Member? get currentMember =>
|
||||
members.isNotEmpty ? members[currentIndex] : null;
|
||||
}
|
||||
|
||||
/// 멤버 컨트롤러
|
||||
|
|
@ -77,13 +69,6 @@ class MembersController extends Notifier<MembersState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 현재 인덱스 변경
|
||||
void setCurrentIndex(int index) {
|
||||
if (index >= 0 && index < state.members.length) {
|
||||
state = state.copyWith(currentIndex: index);
|
||||
}
|
||||
}
|
||||
|
||||
/// 나이 계산
|
||||
int? calculateAge(String? birthDate) {
|
||||
if (birthDate == null) return null;
|
||||
|
|
|
|||
|
|
@ -39,68 +39,70 @@ class ScheduleDetail {
|
|||
final String title;
|
||||
final String date;
|
||||
final String? time;
|
||||
final String? description;
|
||||
final int categoryId;
|
||||
final int? categoryId;
|
||||
final String? categoryName;
|
||||
final String? categoryColor;
|
||||
final String? sourceUrl;
|
||||
final String? sourceName;
|
||||
final String? imageUrl;
|
||||
final List<String> images;
|
||||
final String? locationName;
|
||||
final String? locationAddress;
|
||||
final String? locationLat;
|
||||
final String? locationLng;
|
||||
final List<ScheduleMember> members;
|
||||
final List<RelatedDate> relatedDates;
|
||||
// YouTube 관련
|
||||
final String? channelName;
|
||||
final String? videoId;
|
||||
final String? videoType;
|
||||
final String? videoUrl;
|
||||
final String? bannerUrl;
|
||||
// X 관련
|
||||
final String? postId;
|
||||
final String? username;
|
||||
final String? content;
|
||||
final List<String> imageUrls;
|
||||
final String? postUrl;
|
||||
|
||||
ScheduleDetail({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.date,
|
||||
this.time,
|
||||
this.description,
|
||||
required this.categoryId,
|
||||
this.categoryId,
|
||||
this.categoryName,
|
||||
this.categoryColor,
|
||||
this.sourceUrl,
|
||||
this.sourceName,
|
||||
this.imageUrl,
|
||||
this.images = const [],
|
||||
this.locationName,
|
||||
this.locationAddress,
|
||||
this.locationLat,
|
||||
this.locationLng,
|
||||
this.members = const [],
|
||||
this.relatedDates = const [],
|
||||
this.channelName,
|
||||
this.videoId,
|
||||
this.videoType,
|
||||
this.videoUrl,
|
||||
this.bannerUrl,
|
||||
this.postId,
|
||||
this.username,
|
||||
this.content,
|
||||
this.imageUrls = const [],
|
||||
this.postUrl,
|
||||
});
|
||||
|
||||
factory ScheduleDetail.fromJson(Map<String, dynamic> json) {
|
||||
// category 중첩 객체 파싱
|
||||
final category = json['category'] as Map<String, dynamic>?;
|
||||
|
||||
return ScheduleDetail(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String,
|
||||
date: json['date'] as String,
|
||||
time: json['time'] as String?,
|
||||
description: json['description'] as String?,
|
||||
categoryId: json['category_id'] as int,
|
||||
categoryName: json['category_name'] as String?,
|
||||
categoryColor: json['category_color'] as String?,
|
||||
sourceUrl: json['source_url'] as String?,
|
||||
sourceName: json['source_name'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
images: (json['images'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
locationName: json['location_name'] as String?,
|
||||
locationAddress: json['location_address'] as String?,
|
||||
locationLat: json['location_lat'] as String?,
|
||||
locationLng: json['location_lng'] as String?,
|
||||
categoryId: category?['id'] as int?,
|
||||
categoryName: category?['name'] as String?,
|
||||
categoryColor: category?['color'] as String?,
|
||||
members: (json['members'] as List<dynamic>?)
|
||||
?.map((m) => ScheduleMember.fromJson(m))
|
||||
.toList() ??
|
||||
[],
|
||||
relatedDates: (json['related_dates'] as List<dynamic>?)
|
||||
?.map((r) => RelatedDate.fromJson(r))
|
||||
.toList() ??
|
||||
[],
|
||||
channelName: json['channelName'] as String?,
|
||||
videoId: json['videoId'] as String?,
|
||||
videoType: json['videoType'] as String?,
|
||||
videoUrl: json['videoUrl'] as String?,
|
||||
bannerUrl: json['bannerUrl'] as String?,
|
||||
postId: json['postId'] as String?,
|
||||
username: json['username'] as String?,
|
||||
content: json['content'] as String?,
|
||||
imageUrls: (json['imageUrls'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
postUrl: json['postUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,56 +114,81 @@ class ScheduleDetail {
|
|||
}
|
||||
|
||||
class Schedule {
|
||||
final int id;
|
||||
/// ID (일반 일정: int, 생일/기념일: String)
|
||||
final dynamic id;
|
||||
final String title;
|
||||
final String date;
|
||||
final String? time;
|
||||
final String? endDate;
|
||||
final String? endTime;
|
||||
final String? description;
|
||||
final int? categoryId;
|
||||
final String? categoryName;
|
||||
final String? categoryColor;
|
||||
final String? memberNames;
|
||||
final String? sourceUrl;
|
||||
final List<String> members;
|
||||
final String? sourceName;
|
||||
final String? sourceUrl;
|
||||
// 특별 일정 필드
|
||||
final bool isBirthday;
|
||||
final bool isDebut;
|
||||
final bool isAnniversary;
|
||||
final String? memberImage;
|
||||
final int? anniversaryYear;
|
||||
|
||||
Schedule({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.date,
|
||||
this.time,
|
||||
this.endDate,
|
||||
this.endTime,
|
||||
this.description,
|
||||
this.categoryId,
|
||||
this.categoryName,
|
||||
this.categoryColor,
|
||||
this.memberNames,
|
||||
this.sourceUrl,
|
||||
this.members = const [],
|
||||
this.sourceName,
|
||||
this.sourceUrl,
|
||||
this.isBirthday = false,
|
||||
this.isDebut = false,
|
||||
this.isAnniversary = false,
|
||||
this.memberImage,
|
||||
this.anniversaryYear,
|
||||
});
|
||||
|
||||
factory Schedule.fromJson(Map<String, dynamic> json) {
|
||||
// category 중첩 객체 파싱
|
||||
final category = json['category'] as Map<String, dynamic>?;
|
||||
|
||||
// members 배열 파싱
|
||||
final membersList = (json['members'] as List<dynamic>?)
|
||||
?.map((m) => m is String ? m : m.toString())
|
||||
.toList() ?? [];
|
||||
|
||||
// source 중첩 객체 파싱
|
||||
final source = json['source'] as Map<String, dynamic>?;
|
||||
|
||||
return Schedule(
|
||||
id: json['id'] as int,
|
||||
id: json['id'], // int 또는 String (생일/기념일)
|
||||
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?,
|
||||
categoryId: category?['id'] as int?,
|
||||
categoryName: category?['name'] as String?,
|
||||
categoryColor: category?['color'] as String?,
|
||||
members: membersList,
|
||||
sourceName: source?['name'] as String?,
|
||||
sourceUrl: source?['url'] as String?,
|
||||
isBirthday: json['is_birthday'] == true,
|
||||
isDebut: json['is_debut'] == true,
|
||||
isAnniversary: json['is_anniversary'] == true,
|
||||
memberImage: json['member_image'] as String?,
|
||||
anniversaryYear: (json['anniversary_year'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 특별 일정 여부 (생일, 데뷔, 기념일)
|
||||
bool get isSpecial => isBirthday || isDebut || isAnniversary;
|
||||
|
||||
/// 일반 일정의 int ID (특별 일정은 null)
|
||||
int? get numericId => id is int ? id : null;
|
||||
|
||||
/// 멤버 리스트 반환
|
||||
List<String> get memberList {
|
||||
if (memberNames == null || memberNames!.isEmpty) return [];
|
||||
return memberNames!.split(',').map((n) => n.trim()).where((n) => n.isNotEmpty).toList();
|
||||
}
|
||||
List<String> get memberList => members;
|
||||
|
||||
/// 시간 포맷 (HH:mm)
|
||||
String? get formattedTime {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ Future<List<Schedule>> getSchedules(int year, int month) async {
|
|||
'year': year.toString(),
|
||||
'month': month.toString(),
|
||||
});
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => Schedule.fromJson(json)).toList();
|
||||
final Map<String, dynamic> data = response.data;
|
||||
final List<dynamic> schedulesJson = data['schedules'] ?? [];
|
||||
return schedulesJson.map((json) => Schedule.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
/// 다가오는 일정 N개 조회 (오늘 이후) - 웹과 동일
|
||||
|
|
@ -28,8 +29,9 @@ Future<List<Schedule>> getUpcomingSchedules(int limit) async {
|
|||
'startDate': todayStr,
|
||||
'limit': limit.toString(),
|
||||
});
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => Schedule.fromJson(json)).toList();
|
||||
final Map<String, dynamic> data = response.data;
|
||||
final List<dynamic> schedulesJson = data['schedules'] ?? [];
|
||||
return schedulesJson.map((json) => Schedule.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
/// 일정 검색 결과
|
||||
|
|
|
|||
|
|
@ -637,13 +637,11 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
|||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// mt-2(8px) text-xs(12px) text-gray-400
|
||||
// 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(
|
||||
|
|
@ -662,6 +660,20 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
|||
],
|
||||
],
|
||||
),
|
||||
// 소스 (별도 줄)
|
||||
if (schedule.sourceName != null && schedule.sourceName!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_buildIcon('link', 12, Colors.grey[400]!),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
schedule.sourceName!,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// mt-2(8px)
|
||||
if (memberList.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
|
@ -704,6 +716,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
|||
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"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
/// 멤버 화면 (MVCS의 View 레이어)
|
||||
///
|
||||
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||
/// 웹과 동일한 2열 그리드 + 모달 디자인
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
|
@ -21,121 +20,31 @@ class MembersView extends ConsumerStatefulWidget {
|
|||
ConsumerState<MembersView> createState() => _MembersViewState();
|
||||
}
|
||||
|
||||
class _MembersViewState extends ConsumerState<MembersView> with TickerProviderStateMixin {
|
||||
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;
|
||||
|
||||
// 이전 경로 저장 (탭 전환 감지용)
|
||||
class _MembersViewState extends ConsumerState<MembersView> {
|
||||
String? _previousPath;
|
||||
bool _animationStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(viewportFraction: 0.88);
|
||||
_indicatorScrollController = ScrollController();
|
||||
_animController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_setupAnimations();
|
||||
}
|
||||
|
||||
/// 애니메이션 설정
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 탭 전환 시 애니메이션 재생을 위한 키
|
||||
Key _gridKey = UniqueKey();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 경로 변경 감지하여 애니메이션 재생
|
||||
final currentPath = GoRouterState.of(context).uri.path;
|
||||
if (_previousPath != null && _previousPath != currentPath && currentPath == '/members') {
|
||||
_animController.reset();
|
||||
_animController.forward();
|
||||
setState(() => _gridKey = UniqueKey());
|
||||
}
|
||||
_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;
|
||||
// 아이템 중심 위치: 왼쪽패딩(16) + index * 아이템너비(64) + 아이템반지름(26)
|
||||
const itemRadius = 26.0; // 52 / 2
|
||||
final targetOffset = (index * _indicatorItemWidth) + 16 + itemRadius - (screenWidth / 2);
|
||||
final maxOffset = _indicatorScrollController.position.maxScrollExtent;
|
||||
|
||||
_indicatorScrollController.animateTo(
|
||||
targetOffset.clamp(0.0, maxOffset),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 인스타그램 열기 (딥링크 우선, 없으면 웹)
|
||||
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)) {
|
||||
|
|
@ -144,25 +53,184 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
|
|||
}
|
||||
}
|
||||
|
||||
// 앱이 없으면 웹으로 열기
|
||||
final webUri = Uri.parse(url);
|
||||
if (await canLaunchUrl(webUri)) {
|
||||
await launchUrl(webUri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
/// 멤버 모달 표시
|
||||
void _showMemberModal(Member member) {
|
||||
final controller = ref.read(membersProvider.notifier);
|
||||
final age = controller.calculateAge(member.birthDate);
|
||||
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: '닫기',
|
||||
barrierColor: Colors.black.withValues(alpha: 0.6),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return Center(
|
||||
child: ScaleTransition(
|
||||
scale: CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 264,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 이미지 (3:4 비율)
|
||||
AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (member.imageUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: member.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Colors.grey[200],
|
||||
child: Center(
|
||||
child: Text(
|
||||
member.name[0],
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 닫기 버튼
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 정보 영역
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 이름
|
||||
Text(
|
||||
member.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
// 생일
|
||||
if (member.birthDate != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildIcon('cake', 14, Colors.grey[500]!),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
controller.formatBirthDate(member.birthDate),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
if (age != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'($age세)',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
// 인스타그램 버튼
|
||||
if (!member.isFormer && member.instagram != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () => _openInstagram(member.instagram),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF833AB4), Color(0xFFE1306C), Color(0xFFF77737)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildIcon('instagram', 16, Colors.white),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Instagram',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final membersState = ref.watch(membersProvider);
|
||||
final controller = ref.read(membersProvider.notifier);
|
||||
|
||||
// 데이터 로드 완료 시 애니메이션 시작
|
||||
if (!membersState.isLoading && !_animationStarted && membersState.members.isNotEmpty) {
|
||||
_animationStarted = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_animController.forward();
|
||||
});
|
||||
}
|
||||
|
||||
if (membersState.isLoading) {
|
||||
return const Center(
|
||||
|
|
@ -176,375 +244,26 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
|
|||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animController,
|
||||
builder: (context, child) {
|
||||
return Column(
|
||||
children: [
|
||||
// 상단 썸네일 인디케이터 (애니메이션 적용)
|
||||
Transform.translate(
|
||||
offset: Offset(0, _indicatorSlide.value),
|
||||
child: Opacity(
|
||||
opacity: _indicatorOpacity.value,
|
||||
child: _buildThumbnailIndicator(membersState),
|
||||
),
|
||||
),
|
||||
// 현재 멤버만 표시
|
||||
final currentMembers = membersState.members.where((m) => !m.isFormer).toList();
|
||||
|
||||
// 메인 카드 영역 (애니메이션 적용)
|
||||
Expanded(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _cardSlide.value),
|
||||
child: Opacity(
|
||||
opacity: _cardOpacity.value,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: membersState.members.length,
|
||||
padEnds: true,
|
||||
onPageChanged: (index) {
|
||||
controller.setCurrentIndex(index);
|
||||
HapticFeedback.selectionClick();
|
||||
_scrollIndicatorToIndex(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
// child를 캐싱하여 매 프레임 rebuild 방지
|
||||
// RepaintBoundary로 리페인트 범위 제한
|
||||
child: RepaintBoundary(
|
||||
child: _buildMemberCard(membersState.members[index], controller),
|
||||
),
|
||||
builder: (context, child) {
|
||||
double value = 1.0;
|
||||
if (_pageController.position.haveDimensions) {
|
||||
value = (_pageController.page! - index).abs();
|
||||
value = (1 - (value * 0.25)).clamp(0.0, 1.0);
|
||||
}
|
||||
return Transform.scale(
|
||||
scale: Curves.easeOut.transform(value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 멤버 카드
|
||||
Widget _buildMemberCard(Member member, MembersController controller) {
|
||||
final isFormer = member.isFormer;
|
||||
final age = controller.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: (context, url) => 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(
|
||||
controller.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(MembersState membersState) {
|
||||
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: membersState.members.length,
|
||||
color: const Color(0xFFF9FAFB), // bg-gray-50
|
||||
child: GridView.builder(
|
||||
key: _gridKey,
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 3 / 4,
|
||||
),
|
||||
itemCount: currentMembers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = membersState.members[index];
|
||||
final isSelected = index == membersState.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: (context, url) => 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return _AnimatedMemberCard(
|
||||
index: index,
|
||||
member: currentMembers[index],
|
||||
onTap: () => _showMemberModal(currentMembers[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -554,7 +273,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
|
|||
/// 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"/>',
|
||||
'cake': '<path d="M20 21v-8a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8"/><path d="M4 16s.5-1 2-1 2.5 2 4 2 2.5-2 4-2 2.5 2 4 2 2-1 2-1"/><path d="M2 21h20"/><path d="M7 8v3"/><path d="M12 8v3"/><path d="M17 8v3"/><path d="M7 4h.01"/><path d="M12 4h.01"/><path d="M17 4h.01"/>',
|
||||
'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"/>',
|
||||
};
|
||||
|
||||
|
|
@ -571,3 +290,157 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 애니메이션이 적용된 멤버 카드
|
||||
/// Framer Motion과 동일: delay index*50ms, opacity 0→1, y 20→0, tap scale 0.97
|
||||
class _AnimatedMemberCard extends StatefulWidget {
|
||||
final int index;
|
||||
final Member member;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AnimatedMemberCard({
|
||||
required this.index,
|
||||
required this.member,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AnimatedMemberCard> createState() => _AnimatedMemberCardState();
|
||||
}
|
||||
|
||||
class _AnimatedMemberCardState extends State<_AnimatedMemberCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _opacity;
|
||||
late Animation<Offset> _slide;
|
||||
double _scale = 1.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_opacity = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
_slide = Tween<Offset>(begin: const Offset(0, 20), end: Offset.zero).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// 개별 딜레이 (index * 50ms)
|
||||
Future.delayed(Duration(milliseconds: widget.index * 50), () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _opacity.value,
|
||||
child: Transform.translate(
|
||||
offset: _slide.value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) => setState(() => _scale = 0.97),
|
||||
onTapUp: (_) {
|
||||
setState(() => _scale = 1.0);
|
||||
widget.onTap();
|
||||
},
|
||||
onTapCancel: () => setState(() => _scale = 1.0),
|
||||
child: AnimatedScale(
|
||||
scale: _scale,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 이미지
|
||||
if (widget.member.imageUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.member.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Colors.grey[200]!, Colors.grey[300]!],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.member.name[0],
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 하단 그라데이션 + 이름
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.45),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.member.name,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -779,7 +779,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
bottom: index < searchState.results.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/schedule/${schedule.id}'),
|
||||
onTap: schedule.isSpecial ? null : () => context.push('/schedule/${schedule.id}'),
|
||||
child: SearchScheduleCard(
|
||||
schedule: schedule,
|
||||
categoryColor: parseColor(schedule.categoryColor),
|
||||
|
|
@ -1370,7 +1370,8 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
}) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
clipBehavior: Clip.none,
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
|
|
@ -1409,9 +1410,8 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.4),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
|
|
@ -1581,11 +1581,15 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
itemCount: state.selectedDateSchedules.length,
|
||||
itemBuilder: (context, index) {
|
||||
final schedule = state.selectedDateSchedules[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: GestureDetector(
|
||||
|
||||
// 특별 일정 카드 (생일/데뷔/기념일)
|
||||
Widget card;
|
||||
if (schedule.isBirthday) {
|
||||
card = BirthdayCard(schedule: schedule);
|
||||
} else if (schedule.isDebut || schedule.isAnniversary) {
|
||||
card = DebutCard(schedule: schedule);
|
||||
} else {
|
||||
card = GestureDetector(
|
||||
onTap: () => context.push('/schedule/${schedule.id}'),
|
||||
child: AnimatedScheduleCard(
|
||||
key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'),
|
||||
|
|
@ -1593,7 +1597,14 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
|||
schedule: schedule,
|
||||
categoryColor: parseColor(schedule.categoryColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0,
|
||||
),
|
||||
child: card,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/// 일정 카드 위젯
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../models/schedule.dart';
|
||||
|
|
@ -250,3 +251,257 @@ class ScheduleCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 생일 카드 위젯
|
||||
class BirthdayCard extends StatelessWidget {
|
||||
final Schedule schedule;
|
||||
|
||||
const BirthdayCard({super.key, required this.schedule});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFF472B6), Color(0xFFA855F7), Color(0xFF6366F1)],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFA855F7).withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 배경 장식
|
||||
Positioned(
|
||||
top: -12, right: -12,
|
||||
child: Container(
|
||||
width: 64, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -16, left: -16,
|
||||
child: Container(
|
||||
width: 80, height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
bottom: 12, left: 32,
|
||||
child: Text('🎉', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
// 내용
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 멤버 사진
|
||||
if (schedule.memberImage != null)
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: schedule.memberImage!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(color: Colors.white),
|
||||
),
|
||||
),
|
||||
// 제목
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('🎂', style: TextStyle(fontSize: 24)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
decodeHtmlEntities(schedule.title),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 데뷔/기념일 카드 위젯
|
||||
class DebutCard extends StatelessWidget {
|
||||
final Schedule schedule;
|
||||
|
||||
const DebutCard({super.key, required this.schedule});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7a99c8), Color(0xFF98b0d8), Color(0xFFb8c8e8)],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7a99c8).withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 배경 장식
|
||||
Positioned(
|
||||
top: -24, left: -24,
|
||||
child: Container(
|
||||
width: 80, height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -32, right: -32,
|
||||
child: Container(
|
||||
width: 96, height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 반짝이는 별
|
||||
Positioned(
|
||||
top: 8, right: 16,
|
||||
child: Text('✦', style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.6))),
|
||||
),
|
||||
Positioned(
|
||||
top: 16, right: 48,
|
||||
child: Text('✦', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.4))),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12, right: 24,
|
||||
child: Text('✦', style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.5))),
|
||||
),
|
||||
// 내용
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 아이콘 영역
|
||||
Container(
|
||||
width: 56, height: 56,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
child: Center(
|
||||
child: schedule.isDebut
|
||||
? const Text(
|
||||
'DEBUT',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${schedule.anniversaryYear}',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'YEARS',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 제목
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
schedule.isDebut ? '🍀' : '☘️',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
schedule.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <volume_controller/volume_controller_plugin.h>
|
||||
|
||||
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);
|
||||
g_autoptr(FlPluginRegistrar) volume_controller_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
|
||||
volume_controller_plugin_register_with_registrar(volume_controller_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
volume_controller
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -5,20 +5,24 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_inappwebview_macos
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import video_player_avfoundation
|
||||
import volume_controller
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
|
|
|
|||
144
app/pubspec.lock
144
app/pubspec.lock
|
|
@ -17,6 +17,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.4.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -230,6 +238,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
flutter_inappwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_android
|
||||
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_internal_annotations
|
||||
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
flutter_inappwebview_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_ios
|
||||
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_macos
|
||||
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_platform_interface
|
||||
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0+1"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_web
|
||||
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_windows
|
||||
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -264,6 +336,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -344,6 +424,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -456,6 +544,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
omni_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: omni_video_player
|
||||
sha256: e01ce74413c2eb1cfe042c81507ef2573af66e7ee2984b9ee45808d35a3ea9da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.7.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -632,6 +728,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -752,6 +856,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
simple_sparse_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_sparse_list
|
||||
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -901,6 +1013,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1045,6 +1165,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
visibility_detector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: visibility_detector
|
||||
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0+2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1053,6 +1181,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
volume_controller:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: volume_controller
|
||||
sha256: "109c31a8d4f8cb0e18a1a231b1db97cdbc7084cb4f43928051e9fad59916dd09"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.3"
|
||||
wakelock_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1141,6 +1277,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
youtube_explode_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: youtube_explode_dart
|
||||
sha256: "3d731d71df9901b1915bae806781df519cff32517e36db279f844ae619669e45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.5"
|
||||
sdks:
|
||||
dart: ">=3.10.7 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ dependencies:
|
|||
video_player: ^2.9.2
|
||||
chewie: ^1.8.5
|
||||
expandable_page_view: ^1.0.17
|
||||
omni_video_player: ^3.1.6
|
||||
shared_preferences: ^2.3.5
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
VolumeControllerPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_inappwebview_windows
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
volume_controller
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
156
backend/scripts/refetch-retweets.js
Normal file
156
backend/scripts/refetch-retweets.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* 리트윗 데이터 재수집 스크립트
|
||||
* 원본 작성자의 타임라인에서 매칭되는 트윗을 찾아 이미지와 내용을 복구합니다.
|
||||
*
|
||||
* 사용법: node scripts/refetch-retweets.js [scheduleId1,scheduleId2,...]
|
||||
*/
|
||||
import mysql from 'mysql2/promise';
|
||||
import { fetchAllTweets, fetchSingleTweet, extractTitle } from '../src/services/x/scraper.js';
|
||||
|
||||
const NITTER_URL = process.env.NITTER_URL || 'http://nitter:8080';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'mariadb',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER || 'fromis9',
|
||||
password: process.env.DB_PASSWORD || 'fromis9',
|
||||
database: process.env.DB_NAME || 'fromis9',
|
||||
});
|
||||
|
||||
// 간단한 로거
|
||||
const log = {
|
||||
info: (msg) => console.log(msg),
|
||||
error: (msg) => console.error(msg),
|
||||
};
|
||||
|
||||
// 타임라인 캐시 (같은 작성자의 반복 조회 방지)
|
||||
const timelineCache = new Map();
|
||||
|
||||
/**
|
||||
* 원본 작성자의 타임라인에서 매칭되는 트윗 찾기
|
||||
*/
|
||||
async function findOriginalTweet(username, content, date) {
|
||||
// 캐시 확인
|
||||
if (!timelineCache.has(username)) {
|
||||
console.log(` @${username} 타임라인 수집 중...`);
|
||||
const tweets = await fetchAllTweets(NITTER_URL, username, log, { includeRetweets: false });
|
||||
timelineCache.set(username, tweets);
|
||||
console.log(` -> ${tweets.length}개 트윗 수집 완료`);
|
||||
}
|
||||
|
||||
const tweets = timelineCache.get(username);
|
||||
|
||||
// 내용의 첫 30자로 매칭 (완전 일치는 포맷 차이로 어려움)
|
||||
const contentStart = content.substring(0, 30).trim();
|
||||
|
||||
for (const tweet of tweets) {
|
||||
if (tweet.text.startsWith(contentStart)) {
|
||||
return tweet;
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 기반 유사 매칭 시도
|
||||
const targetDate = date?.split('T')[0];
|
||||
if (targetDate) {
|
||||
for (const tweet of tweets) {
|
||||
const tweetDate = tweet.time?.toISOString().split('T')[0];
|
||||
if (tweetDate === targetDate && tweet.text.includes(contentStart.substring(0, 15))) {
|
||||
return tweet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argIds = process.argv[2]?.split(',').map(Number).filter(Boolean);
|
||||
|
||||
let rows;
|
||||
if (argIds && argIds.length > 0) {
|
||||
[rows] = await pool.query(
|
||||
`SELECT sx.schedule_id, sx.post_id, sx.username, sx.content, sx.image_urls, s.date
|
||||
FROM schedule_x sx JOIN schedules s ON sx.schedule_id = s.id
|
||||
WHERE sx.schedule_id IN (?)`,
|
||||
[argIds]
|
||||
);
|
||||
} else {
|
||||
// 이미지가 없는 리트윗 또는 content에 문제가 있는 것
|
||||
[rows] = await pool.query(
|
||||
`SELECT sx.schedule_id, sx.post_id, sx.username, sx.content, sx.image_urls, s.date
|
||||
FROM schedule_x sx JOIN schedules s ON sx.schedule_id = s.id
|
||||
WHERE sx.content LIKE 'RT @%'
|
||||
OR sx.content LIKE '%nitter%t.co%'
|
||||
OR (sx.image_urls IS NULL AND sx.username != 'realfromis_9')`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`대상: ${rows.length}건\n`);
|
||||
if (rows.length === 0) {
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const username = row.username || 'realfromis_9';
|
||||
console.log(`[${row.schedule_id}] @${username} post_id=${row.post_id}`);
|
||||
|
||||
// 1단계: 먼저 개별 페이지에서 시도 (RT prefix 제거)
|
||||
let newContent = row.content || '';
|
||||
let newImageUrls = null;
|
||||
let newPostId = row.post_id;
|
||||
|
||||
// RT @ 프리픽스가 있으면 제거
|
||||
const rtPrefixMatch = newContent.match(/^RT @\w+:\s*/);
|
||||
if (rtPrefixMatch) {
|
||||
newContent = newContent.slice(rtPrefixMatch[0].length);
|
||||
}
|
||||
newContent = newContent.replace(/…$/, '').trim();
|
||||
|
||||
// nitter t.co 링크 수정
|
||||
newContent = newContent.replace(/https?:\/\/nitter[^/]*\/t\.co\/(\S+)/g, 'https://t.co/$1');
|
||||
|
||||
// 2단계: 이미지가 없으면 원본 작성자 타임라인에서 찾기
|
||||
if (!row.image_urls) {
|
||||
const original = await findOriginalTweet(username, newContent, row.date);
|
||||
if (original) {
|
||||
newContent = original.text;
|
||||
newPostId = original.id;
|
||||
if (original.imageUrls.length > 0) {
|
||||
newImageUrls = JSON.stringify(original.imageUrls);
|
||||
}
|
||||
console.log(` -> 원본 발견! id=${original.id}, images=${original.imageUrls.length}`);
|
||||
} else {
|
||||
console.log(` -> 원본 미발견, 텍스트만 수정`);
|
||||
}
|
||||
}
|
||||
|
||||
const newTitle = extractTitle(newContent);
|
||||
|
||||
// DB 업데이트
|
||||
await pool.query('UPDATE schedules SET title = ? WHERE id = ?', [newTitle, row.schedule_id]);
|
||||
await pool.query(
|
||||
'UPDATE schedule_x SET post_id = ?, username = ?, content = ?, image_urls = ? WHERE schedule_id = ?',
|
||||
[newPostId, username, newContent, newImageUrls, row.schedule_id]
|
||||
);
|
||||
|
||||
console.log(` -> title: ${newTitle.substring(0, 60)} | images: ${newImageUrls ? JSON.parse(newImageUrls).length : 0}`);
|
||||
updated++;
|
||||
} catch (err) {
|
||||
console.error(` -> 실패: ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n완료: ${updated}건 수정, ${failed}건 실패`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
|
||||
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||
import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js';
|
||||
import { formatDate, formatTime } from '../../utils/date.js';
|
||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||
import {
|
||||
|
|
@ -161,4 +161,122 @@ export default async function xRoutes(fastify) {
|
|||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/x/refetch-retweets
|
||||
* 리트윗 데이터 재수집 (잘못된 content/image_urls 수정)
|
||||
*/
|
||||
fastify.post('/refetch-retweets', {
|
||||
schema: {
|
||||
tags: ['admin/x'],
|
||||
summary: '리트윗 데이터 재수집',
|
||||
description: '잘못 저장된 리트윗 일정을 Nitter에서 다시 가져와 수정합니다.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scheduleIds: {
|
||||
type: 'array',
|
||||
items: { type: 'integer' },
|
||||
description: '재수집할 일정 ID 목록 (비어있으면 전체 리트윗 대상)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
let rows;
|
||||
const { scheduleIds } = request.body || {};
|
||||
|
||||
if (scheduleIds && scheduleIds.length > 0) {
|
||||
// 특정 일정만
|
||||
[rows] = await db.query(
|
||||
`SELECT sx.schedule_id, sx.post_id, sx.username, sx.content
|
||||
FROM schedule_x sx
|
||||
WHERE sx.schedule_id IN (?)`,
|
||||
[scheduleIds]
|
||||
);
|
||||
} else {
|
||||
// content가 "RT @"로 시작하거나, image_urls가 NULL이면서 nitter 링크가 있는 일정
|
||||
[rows] = await db.query(
|
||||
`SELECT sx.schedule_id, sx.post_id, sx.username, sx.content
|
||||
FROM schedule_x sx
|
||||
WHERE sx.content LIKE 'RT @%'
|
||||
OR (sx.content LIKE '%nitter%t.co%')
|
||||
OR (sx.image_urls IS NULL AND sx.content LIKE 'RT @%')`
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { success: true, message: '재수집 대상이 없습니다.', updated: 0 };
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
// content에서 원본 작성자 추출 (RT @username: 형식)
|
||||
let fetchUsername = row.username || DEFAULT_USERNAME;
|
||||
const rtMatch = row.content?.match(/^RT @(\w+):/);
|
||||
if (rtMatch) {
|
||||
fetchUsername = rtMatch[1];
|
||||
}
|
||||
|
||||
// 원본 작성자의 개별 트윗 페이지에서 가져오기
|
||||
const tweet = await fetchSingleTweet(NITTER_URL, fetchUsername, row.post_id);
|
||||
|
||||
// fetchSingleTweet이 RT @ 형식을 반환하면 RT 프리픽스 제거
|
||||
let newContent = tweet.text;
|
||||
const rtPrefixMatch = newContent.match(/^RT @\w+:\s*/);
|
||||
if (rtPrefixMatch) {
|
||||
newContent = newContent.slice(rtPrefixMatch[0].length);
|
||||
}
|
||||
// 끝의 … 제거
|
||||
newContent = newContent.replace(/…$/, '').trim();
|
||||
|
||||
const newTitle = extractTitle(newContent);
|
||||
const newImageUrls = tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null;
|
||||
|
||||
// schedules 테이블 업데이트
|
||||
await db.query(
|
||||
'UPDATE schedules SET title = ? WHERE id = ?',
|
||||
[newTitle, row.schedule_id]
|
||||
);
|
||||
|
||||
// schedule_x 테이블 업데이트 (원본 작성자 username도 수정)
|
||||
await db.query(
|
||||
'UPDATE schedule_x SET username = ?, content = ?, image_urls = ? WHERE schedule_id = ?',
|
||||
[fetchUsername, newContent, newImageUrls, row.schedule_id]
|
||||
);
|
||||
|
||||
// Meilisearch 동기화
|
||||
await syncScheduleById(meilisearch, db, row.schedule_id);
|
||||
|
||||
updated++;
|
||||
fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`);
|
||||
|
||||
// Nitter 부하 방지
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
} catch (err) {
|
||||
errors.push({ scheduleId: row.schedule_id, postId: row.post_id, error: err.message });
|
||||
fastify.log.error(`리트윗 재수집 실패 (${row.schedule_id}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logActivity(db, {
|
||||
actor: 'admin',
|
||||
action: 'update',
|
||||
category: 'schedule',
|
||||
targetType: 'x_schedule',
|
||||
summary: `리트윗 재수집: ${updated}/${rows.length}건 완료`,
|
||||
});
|
||||
|
||||
return { success: true, total: rows.length, updated, errors };
|
||||
} catch (err) {
|
||||
fastify.log.error(`리트윗 재수집 오류: ${err.message}`);
|
||||
return serverError(reply, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||
import { fetchTweets, fetchAllTweets, fetchProfile as fetchNitterProfile, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||
import { fetchVideoInfo } from '../youtube/api.js';
|
||||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
||||
import { withTransaction } from '../../utils/transaction.js';
|
||||
|
|
@ -65,6 +65,9 @@ async function xBotPlugin(fastify, opts) {
|
|||
const time = formatTime(tweet.time);
|
||||
const title = extractTitle(tweet.text);
|
||||
|
||||
// 리트윗인 경우 원본 작성자를 username으로 사용
|
||||
const tweetUsername = tweet.originalUsername || username;
|
||||
|
||||
// 트랜잭션으로 INSERT 작업 수행
|
||||
return withTransaction(fastify.db, async (connection) => {
|
||||
// schedules 테이블에 저장
|
||||
|
|
@ -80,7 +83,7 @@ async function xBotPlugin(fastify, opts) {
|
|||
[
|
||||
scheduleId,
|
||||
tweet.id,
|
||||
username,
|
||||
tweetUsername,
|
||||
tweet.text,
|
||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||
]
|
||||
|
|
@ -244,7 +247,7 @@ async function xBotPlugin(fastify, opts) {
|
|||
}
|
||||
|
||||
/**
|
||||
* X 프로필 조회 (Redis 캐시 → bot_x 테이블)
|
||||
* X 프로필 조회 (Redis 캐시 → bot_x 테이블 → Nitter 직접 조회)
|
||||
*/
|
||||
async function getProfile(username) {
|
||||
// Redis 캐시 확인
|
||||
|
|
@ -266,7 +269,6 @@ async function xBotPlugin(fastify, opts) {
|
|||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_url,
|
||||
};
|
||||
// Redis 캐시에 저장
|
||||
await fastify.redis.setex(
|
||||
`${PROFILE_CACHE_PREFIX}${username}`,
|
||||
PROFILE_TTL,
|
||||
|
|
@ -275,6 +277,28 @@ async function xBotPlugin(fastify, opts) {
|
|||
return data;
|
||||
}
|
||||
|
||||
// bot_x에 없으면 Nitter에서 직접 조회 (리트윗 원본 작성자 등)
|
||||
try {
|
||||
const nitterUrl = fastify.config?.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
||||
const profile = await fetchNitterProfile(nitterUrl, username);
|
||||
if (profile) {
|
||||
const data = {
|
||||
username: profile.username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
// Redis 캐시에 저장
|
||||
await fastify.redis.setex(
|
||||
`${PROFILE_CACHE_PREFIX}${username}`,
|
||||
PROFILE_TTL,
|
||||
JSON.stringify(data)
|
||||
);
|
||||
return data;
|
||||
}
|
||||
} catch (err) {
|
||||
fastify.log.error(`Nitter 프로필 조회 실패 (${username}): ${err.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,11 @@ function extractTextFromHtml(html) {
|
|||
.replace(/<br\s*\/?>/g, '\n')
|
||||
// <a> 태그: href에서 원본 URL 추출 (외부 링크만)
|
||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => {
|
||||
// t.co 링크: Nitter가 프록시한 URL을 원본 t.co URL로 변환
|
||||
const tcoMatch = href.match(/\/t\.co\/([^\s"?]+)/);
|
||||
if (tcoMatch) {
|
||||
return `https://t.co/${tcoMatch[1]}`;
|
||||
}
|
||||
// Nitter 내부 링크 (/search, /hashtag 등)는 표시 텍스트 사용
|
||||
if (href.startsWith('/')) {
|
||||
return text;
|
||||
|
|
@ -146,6 +151,22 @@ export function parseTweets(html, username, options = {}) {
|
|||
const isRetweet = container.includes('class="retweet-header"');
|
||||
if (isRetweet && !includeRetweets) continue;
|
||||
|
||||
// 리트윗인 경우 원본 작성자 추출 (data-username 또는 tweet-header에서)
|
||||
let originalUsername = null;
|
||||
if (isRetweet) {
|
||||
const dataUserMatch = containers[i - 1]?.match(/data-username="([^"]+)"/) ||
|
||||
container.match(/data-username="([^"]+)"/);
|
||||
if (dataUserMatch) {
|
||||
originalUsername = dataUserMatch[1];
|
||||
} else {
|
||||
// tweet-header의 username 링크에서 추출
|
||||
const headerUserMatch = container.match(/class="username"[^>]*href="\/([^"]+)"/);
|
||||
if (headerUserMatch) {
|
||||
originalUsername = headerUserMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 트윗 ID
|
||||
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||
if (!idMatch) continue;
|
||||
|
|
@ -171,7 +192,11 @@ export function parseTweets(html, username, options = {}) {
|
|||
time,
|
||||
text,
|
||||
imageUrls,
|
||||
url: `https://x.com/${username}/status/${id}`,
|
||||
isRetweet,
|
||||
originalUsername,
|
||||
url: isRetweet && originalUsername
|
||||
? `https://x.com/${originalUsername}/status/${id}`
|
||||
: `https://x.com/${username}/status/${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className =
|
|||
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</p>
|
||||
{/* 시간 + 카테고리 + 소스 */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
{/* 시간 + 카테고리 */}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
|
|
@ -72,13 +72,14 @@ const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className =
|
|||
{categoryInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{sourceName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={12} />
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 소스 */}
|
||||
{sourceName && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
||||
<Link2 size={12} />
|
||||
{sourceName}
|
||||
</div>
|
||||
)}
|
||||
{/* 멤버 */}
|
||||
{displayMembers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue