Compare commits

...

7 commits

Author SHA1 Message Date
3cf07a8214 chore(app): omni_video_player 패키지 추가에 따른 플러그인 등록 업데이트
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:10:03 +09:00
9498559f6b fix(x-bot): 리트윗 프로필/이미지 복구 및 원본 트윗 매칭
- getProfile: bot_x에 없는 계정도 Nitter에서 직접 조회 후 Redis 캐시
- refetch-retweets 스크립트: 원본 작성자 타임라인에서 매칭 트윗 찾아 이미지/내용 복구
- 기존 21건 리트윗 데이터 재수집 완료 (이미지 포함)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:07:43 +09:00
3ce8d7ec7d fix(x-bot): 리트윗 내용 잘림, Nitter 링크, 이미지 누락 수정
- extractTextFromHtml: Nitter 프록시 t.co URL을 원본 https://t.co/ URL로 변환
- parseTweets: 리트윗 원본 작성자(originalUsername) 추출, URL을 원본 작성자 기준으로 생성
- saveTweet: 리트윗인 경우 원본 작성자를 username으로 저장
- refetch-retweets 엔드포인트 및 스크립트 추가 (기존 잘못된 데이터 재수집)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:59:19 +09:00
c37d7e14af feat(app): 일정 상세 화면 새 API 대응 및 YouTube 앱 내 재생
- ScheduleDetail 모델: 새 API 형식 (category 중첩 객체, YouTube/X 전용 필드)
- YouTube 섹션: omni_video_player로 앱 내 재생, 예정 플레이스홀더 추가
- X 섹션: username, content, imageUrls, postUrl 직접 사용
- 숏츠 영상 16:9 통일, 날짜 형식 웹과 동일하게 변경
- 콘서트 섹션을 기본 섹션으로 통합 (API 변경 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:20:51 +09:00
6284d216bd feat(app): 일정 화면 새 API 대응 및 생일/데뷔 카드 추가
- Schedule 모델: dynamic id로 변경 (생일/기념일 문자열 ID 지원)
- 생일/데뷔/기념일 특별 필드 추가 (isBirthday, isDebut 등)
- BirthdayCard: 핑크-보라 그라데이션, 멤버 사진, 케이크 이모지
- DebutCard: 블루 그라데이션, DEBUT/N YEARS 아이콘, 별 장식
- 일정 목록에서 특별 일정 카드 자동 분기 렌더링
- 특별 일정 클릭 시 상세 라우팅 방지
- 달력 그리드 그림자 클리핑 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:18:57 +09:00
d6ef851b02 feat(app): 멤버 화면을 웹과 동일한 2열 그리드 + 모달 디자인으로 변경
- PageView 카드 스와이프 → 2열 그리드 레이아웃
- 상단 썸네일 인디케이터 제거
- 카드 탭 시 모달로 상세 정보 표시 (이미지, 이름, 생일, 인스타그램)
- 개별 카드 staggered 애니메이션 + 탭 scale 효과
- 컨트롤러에서 불필요한 currentIndex 상태 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:29:22 +09:00
0ddde32bed feat(app): 홈 화면 일정 API를 새 응답 형식에 맞게 업데이트
- Schedule 모델: category/source 중첩 객체, members 배열 파싱
- 일정 서비스: { schedules: [] } 래핑된 응답 처리
- 홈/웹 일정 카드: sourceName을 별도 줄로 분리하여 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:15:19 +09:00
20 changed files with 1536 additions and 1171 deletions

View file

@ -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;

View file

@ -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 {

View file

@ -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();
}
///

View file

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

View file

@ -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 01, y 200, 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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
volume_controller
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -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"))
}

View file

@ -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"

View file

@ -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:

View file

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

View file

@ -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

View 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);
});

View file

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

View file

@ -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;
}

View file

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

View file

@ -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">