Compare commits
No commits in common. "3cf07a82144214e87cf1a4bcc00be5926480b6f8" and "9d18449d3a4c25e9021c91914795ed917cdf6bd3" have entirely different histories.
3cf07a8214
...
9d18449d3a
20 changed files with 1172 additions and 1537 deletions
|
|
@ -11,11 +11,13 @@ import '../services/members_service.dart';
|
||||||
/// 멤버 상태
|
/// 멤버 상태
|
||||||
class MembersState {
|
class MembersState {
|
||||||
final List<Member> members;
|
final List<Member> members;
|
||||||
|
final int currentIndex;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|
||||||
const MembersState({
|
const MembersState({
|
||||||
this.members = const [],
|
this.members = const [],
|
||||||
|
this.currentIndex = 0,
|
||||||
this.isLoading = true,
|
this.isLoading = true,
|
||||||
this.error,
|
this.error,
|
||||||
});
|
});
|
||||||
|
|
@ -23,15 +25,21 @@ class MembersState {
|
||||||
/// 상태 복사 (불변성 유지)
|
/// 상태 복사 (불변성 유지)
|
||||||
MembersState copyWith({
|
MembersState copyWith({
|
||||||
List<Member>? members,
|
List<Member>? members,
|
||||||
|
int? currentIndex,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
}) {
|
}) {
|
||||||
return MembersState(
|
return MembersState(
|
||||||
members: members ?? this.members,
|
members: members ?? this.members,
|
||||||
|
currentIndex: currentIndex ?? this.currentIndex,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 현재 선택된 멤버
|
||||||
|
Member? get currentMember =>
|
||||||
|
members.isNotEmpty ? members[currentIndex] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 멤버 컨트롤러
|
/// 멤버 컨트롤러
|
||||||
|
|
@ -69,6 +77,13 @@ 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) {
|
int? calculateAge(String? birthDate) {
|
||||||
if (birthDate == null) return null;
|
if (birthDate == null) return null;
|
||||||
|
|
|
||||||
|
|
@ -39,70 +39,68 @@ class ScheduleDetail {
|
||||||
final String title;
|
final String title;
|
||||||
final String date;
|
final String date;
|
||||||
final String? time;
|
final String? time;
|
||||||
final int? categoryId;
|
final String? description;
|
||||||
|
final int categoryId;
|
||||||
final String? categoryName;
|
final String? categoryName;
|
||||||
final String? categoryColor;
|
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<ScheduleMember> members;
|
||||||
// YouTube 관련
|
final List<RelatedDate> relatedDates;
|
||||||
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({
|
ScheduleDetail({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.date,
|
required this.date,
|
||||||
this.time,
|
this.time,
|
||||||
this.categoryId,
|
this.description,
|
||||||
|
required this.categoryId,
|
||||||
this.categoryName,
|
this.categoryName,
|
||||||
this.categoryColor,
|
this.categoryColor,
|
||||||
|
this.sourceUrl,
|
||||||
|
this.sourceName,
|
||||||
|
this.imageUrl,
|
||||||
|
this.images = const [],
|
||||||
|
this.locationName,
|
||||||
|
this.locationAddress,
|
||||||
|
this.locationLat,
|
||||||
|
this.locationLng,
|
||||||
this.members = const [],
|
this.members = const [],
|
||||||
this.channelName,
|
this.relatedDates = const [],
|
||||||
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) {
|
factory ScheduleDetail.fromJson(Map<String, dynamic> json) {
|
||||||
// category 중첩 객체 파싱
|
|
||||||
final category = json['category'] as Map<String, dynamic>?;
|
|
||||||
|
|
||||||
return ScheduleDetail(
|
return ScheduleDetail(
|
||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
date: json['date'] as String,
|
date: json['date'] as String,
|
||||||
time: json['time'] as String?,
|
time: json['time'] as String?,
|
||||||
categoryId: category?['id'] as int?,
|
description: json['description'] as String?,
|
||||||
categoryName: category?['name'] as String?,
|
categoryId: json['category_id'] as int,
|
||||||
categoryColor: category?['color'] as String?,
|
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?,
|
||||||
members: (json['members'] as List<dynamic>?)
|
members: (json['members'] as List<dynamic>?)
|
||||||
?.map((m) => ScheduleMember.fromJson(m))
|
?.map((m) => ScheduleMember.fromJson(m))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
channelName: json['channelName'] as String?,
|
relatedDates: (json['related_dates'] as List<dynamic>?)
|
||||||
videoId: json['videoId'] as String?,
|
?.map((r) => RelatedDate.fromJson(r))
|
||||||
videoType: json['videoType'] as String?,
|
.toList() ??
|
||||||
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?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,81 +112,56 @@ class ScheduleDetail {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Schedule {
|
class Schedule {
|
||||||
/// ID (일반 일정: int, 생일/기념일: String)
|
final int id;
|
||||||
final dynamic id;
|
|
||||||
final String title;
|
final String title;
|
||||||
final String date;
|
final String date;
|
||||||
final String? time;
|
final String? time;
|
||||||
final int? categoryId;
|
final String? endDate;
|
||||||
|
final String? endTime;
|
||||||
|
final String? description;
|
||||||
final String? categoryName;
|
final String? categoryName;
|
||||||
final String? categoryColor;
|
final String? categoryColor;
|
||||||
final List<String> members;
|
final String? memberNames;
|
||||||
final String? sourceName;
|
|
||||||
final String? sourceUrl;
|
final String? sourceUrl;
|
||||||
// 특별 일정 필드
|
final String? sourceName;
|
||||||
final bool isBirthday;
|
|
||||||
final bool isDebut;
|
|
||||||
final bool isAnniversary;
|
|
||||||
final String? memberImage;
|
|
||||||
final int? anniversaryYear;
|
|
||||||
|
|
||||||
Schedule({
|
Schedule({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.date,
|
required this.date,
|
||||||
this.time,
|
this.time,
|
||||||
this.categoryId,
|
this.endDate,
|
||||||
|
this.endTime,
|
||||||
|
this.description,
|
||||||
this.categoryName,
|
this.categoryName,
|
||||||
this.categoryColor,
|
this.categoryColor,
|
||||||
this.members = const [],
|
this.memberNames,
|
||||||
this.sourceName,
|
|
||||||
this.sourceUrl,
|
this.sourceUrl,
|
||||||
this.isBirthday = false,
|
this.sourceName,
|
||||||
this.isDebut = false,
|
|
||||||
this.isAnniversary = false,
|
|
||||||
this.memberImage,
|
|
||||||
this.anniversaryYear,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Schedule.fromJson(Map<String, dynamic> json) {
|
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(
|
return Schedule(
|
||||||
id: json['id'], // int 또는 String (생일/기념일)
|
id: json['id'] as int,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
date: json['date'] as String,
|
date: json['date'] as String,
|
||||||
time: json['time'] as String?,
|
time: json['time'] as String?,
|
||||||
categoryId: category?['id'] as int?,
|
endDate: json['end_date'] as String?,
|
||||||
categoryName: category?['name'] as String?,
|
endTime: json['end_time'] as String?,
|
||||||
categoryColor: category?['color'] as String?,
|
description: json['description'] as String?,
|
||||||
members: membersList,
|
categoryName: json['category_name'] as String?,
|
||||||
sourceName: source?['name'] as String?,
|
categoryColor: json['category_color'] as String?,
|
||||||
sourceUrl: source?['url'] as String?,
|
memberNames: json['member_names'] as String?,
|
||||||
isBirthday: json['is_birthday'] == true,
|
sourceUrl: json['source_url'] as String?,
|
||||||
isDebut: json['is_debut'] == true,
|
sourceName: json['source_name'] as String?,
|
||||||
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 => members;
|
List<String> get memberList {
|
||||||
|
if (memberNames == null || memberNames!.isEmpty) return [];
|
||||||
|
return memberNames!.split(',').map((n) => n.trim()).where((n) => n.isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// 시간 포맷 (HH:mm)
|
/// 시간 포맷 (HH:mm)
|
||||||
String? get formattedTime {
|
String? get formattedTime {
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,8 @@ Future<List<Schedule>> getSchedules(int year, int month) async {
|
||||||
'year': year.toString(),
|
'year': year.toString(),
|
||||||
'month': month.toString(),
|
'month': month.toString(),
|
||||||
});
|
});
|
||||||
final Map<String, dynamic> data = response.data;
|
final List<dynamic> data = response.data;
|
||||||
final List<dynamic> schedulesJson = data['schedules'] ?? [];
|
return data.map((json) => Schedule.fromJson(json)).toList();
|
||||||
return schedulesJson.map((json) => Schedule.fromJson(json)).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 다가오는 일정 N개 조회 (오늘 이후) - 웹과 동일
|
/// 다가오는 일정 N개 조회 (오늘 이후) - 웹과 동일
|
||||||
|
|
@ -29,9 +28,8 @@ Future<List<Schedule>> getUpcomingSchedules(int limit) async {
|
||||||
'startDate': todayStr,
|
'startDate': todayStr,
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
});
|
});
|
||||||
final Map<String, dynamic> data = response.data;
|
final List<dynamic> data = response.data;
|
||||||
final List<dynamic> schedulesJson = data['schedules'] ?? [];
|
return data.map((json) => Schedule.fromJson(json)).toList();
|
||||||
return schedulesJson.map((json) => Schedule.fromJson(json)).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 일정 검색 결과
|
/// 일정 검색 결과
|
||||||
|
|
|
||||||
|
|
@ -637,11 +637,13 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
// gap-3(12px) 사이
|
||||||
if (schedule.formattedTime != null) ...[
|
if (schedule.formattedTime != null) ...[
|
||||||
|
// gap-1(4px)
|
||||||
_buildIcon('clock', 12, Colors.grey[400]!),
|
_buildIcon('clock', 12, Colors.grey[400]!),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -660,20 +662,6 @@ 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)
|
// mt-2(8px)
|
||||||
if (memberList.isNotEmpty) ...[
|
if (memberList.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
@ -716,7 +704,6 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
|
||||||
const icons = {
|
const icons = {
|
||||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
'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"/>',
|
'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"/>',
|
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
/// 멤버 화면 (MVCS의 View 레이어)
|
/// 멤버 화면 (MVCS의 View 레이어)
|
||||||
///
|
///
|
||||||
/// 웹과 동일한 2열 그리드 + 모달 디자인
|
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
@ -20,31 +21,121 @@ class MembersView extends ConsumerStatefulWidget {
|
||||||
ConsumerState<MembersView> createState() => _MembersViewState();
|
ConsumerState<MembersView> createState() => _MembersViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MembersViewState extends ConsumerState<MembersView> {
|
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;
|
||||||
|
|
||||||
|
// 이전 경로 저장 (탭 전환 감지용)
|
||||||
String? _previousPath;
|
String? _previousPath;
|
||||||
// 탭 전환 시 애니메이션 재생을 위한 키
|
bool _animationStarted = false;
|
||||||
Key _gridKey = UniqueKey();
|
|
||||||
|
@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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
// 경로 변경 감지하여 애니메이션 재생
|
||||||
final currentPath = GoRouterState.of(context).uri.path;
|
final currentPath = GoRouterState.of(context).uri.path;
|
||||||
if (_previousPath != null && _previousPath != currentPath && currentPath == '/members') {
|
if (_previousPath != null && _previousPath != currentPath && currentPath == '/members') {
|
||||||
setState(() => _gridKey = UniqueKey());
|
_animController.reset();
|
||||||
|
_animController.forward();
|
||||||
}
|
}
|
||||||
_previousPath = currentPath;
|
_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 {
|
Future<void> _openInstagram(String? url) async {
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
|
|
||||||
|
// URL에서 username 추출 (instagram.com/username 형태)
|
||||||
String? username;
|
String? username;
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
if (uri != null && uri.pathSegments.isNotEmpty) {
|
if (uri != null && uri.pathSegments.isNotEmpty) {
|
||||||
username = uri.pathSegments.first;
|
username = uri.pathSegments.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 인스타그램 앱 딥링크 시도
|
||||||
if (username != null) {
|
if (username != null) {
|
||||||
final deepLink = Uri.parse('instagram://user?username=$username');
|
final deepLink = Uri.parse('instagram://user?username=$username');
|
||||||
if (await canLaunchUrl(deepLink)) {
|
if (await canLaunchUrl(deepLink)) {
|
||||||
|
|
@ -53,184 +144,25 @@ class _MembersViewState extends ConsumerState<MembersView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 앱이 없으면 웹으로 열기
|
||||||
final webUri = Uri.parse(url);
|
final webUri = Uri.parse(url);
|
||||||
if (await canLaunchUrl(webUri)) {
|
if (await canLaunchUrl(webUri)) {
|
||||||
await launchUrl(webUri, mode: LaunchMode.externalApplication);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final membersState = ref.watch(membersProvider);
|
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) {
|
if (membersState.isLoading) {
|
||||||
return const Center(
|
return const Center(
|
||||||
|
|
@ -244,26 +176,375 @@ class _MembersViewState extends ConsumerState<MembersView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 멤버만 표시
|
return AnimatedBuilder(
|
||||||
final currentMembers = membersState.members.where((m) => !m.isFormer).toList();
|
animation: _animController,
|
||||||
|
builder: (context, child) {
|
||||||
return Container(
|
return Column(
|
||||||
color: const Color(0xFFF9FAFB), // bg-gray-50
|
children: [
|
||||||
child: GridView.builder(
|
// 상단 썸네일 인디케이터 (애니메이션 적용)
|
||||||
key: _gridKey,
|
Transform.translate(
|
||||||
padding: const EdgeInsets.all(16),
|
offset: Offset(0, _indicatorSlide.value),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
child: Opacity(
|
||||||
crossAxisCount: 2,
|
opacity: _indicatorOpacity.value,
|
||||||
crossAxisSpacing: 16,
|
child: _buildThumbnailIndicator(membersState),
|
||||||
mainAxisSpacing: 16,
|
|
||||||
childAspectRatio: 3 / 4,
|
|
||||||
),
|
),
|
||||||
itemCount: currentMembers.length,
|
),
|
||||||
|
|
||||||
|
// 메인 카드 영역 (애니메이션 적용)
|
||||||
|
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) {
|
itemBuilder: (context, index) {
|
||||||
return _AnimatedMemberCard(
|
return AnimatedBuilder(
|
||||||
index: index,
|
animation: _pageController,
|
||||||
member: currentMembers[index],
|
// child를 캐싱하여 매 프레임 rebuild 방지
|
||||||
onTap: () => _showMemberModal(currentMembers[index]),
|
// 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,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -273,7 +554,7 @@ class _MembersViewState extends ConsumerState<MembersView> {
|
||||||
/// SVG 아이콘 빌더
|
/// SVG 아이콘 빌더
|
||||||
Widget _buildIcon(String name, double size, Color color) {
|
Widget _buildIcon(String name, double size, Color color) {
|
||||||
const icons = {
|
const icons = {
|
||||||
'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"/>',
|
'calendar': '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||||
'instagram': '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
|
'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"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -290,157 +571,3 @@ class _MembersViewState extends ConsumerState<MembersView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 개별 애니메이션이 적용된 멤버 카드
|
|
||||||
/// 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:omni_video_player/omni_video_player.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../models/schedule.dart';
|
import '../../models/schedule.dart';
|
||||||
|
|
@ -47,7 +46,14 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
_currentScheduleId = widget.scheduleId;
|
_currentScheduleId = widget.scheduleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL 열기 (외부 앱)
|
/// 회차 변경
|
||||||
|
void _changeSchedule(int newId) {
|
||||||
|
setState(() {
|
||||||
|
_currentScheduleId = newId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL 열기
|
||||||
Future<void> _launchUrl(String url) async {
|
Future<void> _launchUrl(String url) async {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
|
|
@ -55,7 +61,26 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 유튜브 비디오 ID 추출
|
||||||
|
String? _extractYoutubeVideoId(String? url) {
|
||||||
|
if (url == null) return null;
|
||||||
|
final shortMatch = RegExp(r'youtu\.be/([a-zA-Z0-9_-]{11})').firstMatch(url);
|
||||||
|
if (shortMatch != null) return shortMatch.group(1);
|
||||||
|
final watchMatch =
|
||||||
|
RegExp(r'youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})').firstMatch(url);
|
||||||
|
if (watchMatch != null) return watchMatch.group(1);
|
||||||
|
final shortsMatch =
|
||||||
|
RegExp(r'youtube\.com/shorts/([a-zA-Z0-9_-]{11})').firstMatch(url);
|
||||||
|
if (shortsMatch != null) return shortsMatch.group(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X URL에서 username 추출
|
||||||
|
String? _extractXUsername(String? url) {
|
||||||
|
if (url == null) return null;
|
||||||
|
final match = RegExp(r'(?:twitter\.com|x\.com)/([^/]+)').firstMatch(url);
|
||||||
|
return match?.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
/// 날짜 포맷팅 (2026. 1. 15. (수))
|
/// 날짜 포맷팅 (2026. 1. 15. (수))
|
||||||
String _formatFullDate(String dateStr) {
|
String _formatFullDate(String dateStr) {
|
||||||
|
|
@ -64,10 +89,25 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
return '${date.year}. ${date.month}. ${date.day}. (${dayNames[date.weekday % 7]})';
|
return '${date.year}. ${date.month}. ${date.day}. (${dayNames[date.weekday % 7]})';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 단일 날짜 포맷팅 (1월 15일 (수) 19:00)
|
||||||
|
String _formatSingleDate(String dateStr, String? timeStr) {
|
||||||
|
final date = DateTime.parse(dateStr);
|
||||||
|
final dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
var result = '${date.month}월 ${date.day}일 (${dayNames[date.weekday % 7]})';
|
||||||
|
if (timeStr != null && timeStr.length >= 5) {
|
||||||
|
result += ' ${timeStr.substring(0, 5)}';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
|
/// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
|
||||||
String _formatXDateTime(String dateStr, String? timeStr) {
|
String _formatXDateTime(String dateStr, String? timeStr) {
|
||||||
final date = DateTime.parse(dateStr);
|
final date = DateTime.parse(dateStr);
|
||||||
var result = '${date.year}년 ${date.month}월 ${date.day}일';
|
final year = date.year;
|
||||||
|
final month = date.month;
|
||||||
|
final day = date.day;
|
||||||
|
|
||||||
|
var result = '$year년 $month월 $day일';
|
||||||
|
|
||||||
if (timeStr != null && timeStr.length >= 5) {
|
if (timeStr != null && timeStr.length >= 5) {
|
||||||
final parts = timeStr.split(':');
|
final parts = timeStr.split(':');
|
||||||
|
|
@ -189,6 +229,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
return _buildYoutubeSection(schedule);
|
return _buildYoutubeSection(schedule);
|
||||||
case CategoryId.x:
|
case CategoryId.x:
|
||||||
return _buildXSection(schedule);
|
return _buildXSection(schedule);
|
||||||
|
case CategoryId.concert:
|
||||||
|
return _buildConcertSection(schedule);
|
||||||
default:
|
default:
|
||||||
return _buildDefaultSection(schedule);
|
return _buildDefaultSection(schedule);
|
||||||
}
|
}
|
||||||
|
|
@ -196,234 +238,112 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
|
|
||||||
/// 유튜브 섹션
|
/// 유튜브 섹션
|
||||||
Widget _buildYoutubeSection(ScheduleDetail schedule) {
|
Widget _buildYoutubeSection(ScheduleDetail schedule) {
|
||||||
final videoId = schedule.videoId;
|
final videoId = _extractYoutubeVideoId(schedule.sourceUrl);
|
||||||
final isScheduled = videoId == null;
|
final isShorts = schedule.sourceUrl?.contains('/shorts/') ?? false;
|
||||||
final members = schedule.members;
|
|
||||||
final isFullGroup = members.length >= 5;
|
if (videoId == null) return _buildDefaultSection(schedule);
|
||||||
|
|
||||||
|
// 썸네일 URL
|
||||||
|
final thumbnailUrl = 'https://img.youtube.com/vi/$videoId/maxresdefault.jpg';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 영상 썸네일 또는 예정 플레이스홀더
|
// 썸네일 + 재생 버튼 (클릭시 YouTube로 이동)
|
||||||
if (isScheduled)
|
GestureDetector(
|
||||||
_buildScheduledPlaceholder(schedule.bannerUrl)
|
onTap: () => _launchUrl(schedule.sourceUrl!),
|
||||||
else
|
child: isShorts
|
||||||
ClipRRect(
|
? Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 9 / 16,
|
||||||
child: OmniVideoPlayer(
|
child: Stack(
|
||||||
configuration: VideoPlayerConfiguration(
|
alignment: Alignment.center,
|
||||||
videoSourceConfiguration: VideoSourceConfiguration.youtube(
|
|
||||||
videoUrl: Uri.parse('https://www.youtube.com/watch?v=$videoId'),
|
|
||||||
preferredQualities: [OmniVideoQuality.high720],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
callbacks: const VideoPlayerCallbacks(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// 영상 정보 카드
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Colors.grey[100]!, Colors.grey[200]!.withValues(alpha: 0.8)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// 제목 + 예정 뱃지
|
CachedNetworkImage(
|
||||||
Row(
|
imageUrl: thumbnailUrl,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fit: BoxFit.cover,
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
decodeHtmlEntities(schedule.title),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isScheduled) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFEF3C7), // amber-100
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'예정',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFFB45309), // amber-700
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// 메타 정보
|
|
||||||
Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 6,
|
|
||||||
children: [
|
|
||||||
_buildMetaItem(LucideIcons.calendar, _formatXDateTime(schedule.date, schedule.time)),
|
|
||||||
if (schedule.channelName != null)
|
|
||||||
_buildMetaItem(LucideIcons.link2, schedule.channelName!),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// 멤버
|
|
||||||
if (members.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(
|
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 6,
|
|
||||||
children: isFullGroup
|
|
||||||
? [const MemberChip(name: '프로미스나인')]
|
|
||||||
: members.map((m) => MemberChip(name: m.name)).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// YouTube에서 보기 버튼
|
|
||||||
if (!isScheduled) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.only(top: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(color: Colors.grey[300]!.withValues(alpha: 0.5)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
height: double.infinity,
|
||||||
onPressed: () => _launchUrl(schedule.videoUrl!),
|
placeholder: (_, _) => Container(
|
||||||
style: ElevatedButton.styleFrom(
|
color: Colors.grey[900],
|
||||||
backgroundColor: Colors.red[500],
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
elevation: 0,
|
errorWidget: (_, _, _) => Container(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
||||||
),
|
),
|
||||||
child: const Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(LucideIcons.youtube, size: 20),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'YouTube에서 보기',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
|
||||||
),
|
),
|
||||||
|
_buildYoutubePlayButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
)
|
||||||
],
|
: ClipRRect(
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 예정 일정 플레이스홀더
|
|
||||||
Widget _buildScheduledPlaceholder(String? bannerUrl) {
|
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
alignment: Alignment.center,
|
||||||
children: [
|
|
||||||
// 배경
|
|
||||||
if (bannerUrl != null)
|
|
||||||
Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
children: [
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: bannerUrl,
|
imageUrl: thumbnailUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, _) => Container(color: Colors.grey[900]),
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
placeholder: (_, _) => Container(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
),
|
||||||
errorWidget: (_, _, _) => Container(
|
errorWidget: (_, _, _) => Container(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildYoutubePlayButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 정보 카드 (버튼 없음)
|
||||||
|
_buildInfoCard(schedule, bottomButton: null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 유튜브 재생 버튼 (실제 유튜브 아이콘)
|
||||||
|
Widget _buildYoutubePlayButton() {
|
||||||
|
return Container(
|
||||||
|
width: 68,
|
||||||
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Colors.red,
|
||||||
begin: Alignment.topLeft,
|
borderRadius: BorderRadius.circular(12),
|
||||||
end: Alignment.bottomRight,
|
boxShadow: [
|
||||||
colors: [Colors.grey[800]!, Colors.grey[900]!],
|
BoxShadow(
|
||||||
),
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
),
|
blurRadius: 12,
|
||||||
),
|
offset: const Offset(0, 4),
|
||||||
),
|
|
||||||
// 그라데이션 오버레이
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
Colors.transparent,
|
|
||||||
Colors.black.withValues(alpha: 0.7),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Colors.grey[800]!, Colors.grey[900]!],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 하단 텍스트
|
|
||||||
Positioned(
|
|
||||||
left: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
LucideIcons.clock,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.amber[400],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'업로드 예정',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
child: Center(
|
||||||
],
|
child: CustomPaint(
|
||||||
|
size: const Size(24, 24),
|
||||||
|
painter: _YoutubePlayIconPainter(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// X 섹션
|
/// X 섹션 (웹과 동일)
|
||||||
Widget _buildXSection(ScheduleDetail schedule) {
|
Widget _buildXSection(ScheduleDetail schedule) {
|
||||||
final displayName = schedule.username ?? 'Unknown';
|
final username = _extractXUsername(schedule.sourceUrl);
|
||||||
|
final displayName = schedule.sourceName ?? username ?? 'Unknown';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -480,11 +400,13 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
// 인증 배지 (웹과 동일한 SVG 형태)
|
||||||
_buildVerifiedBadge(),
|
_buildVerifiedBadge(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (username != null)
|
||||||
Text(
|
Text(
|
||||||
'@$displayName',
|
'@$username',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Colors.grey[500],
|
color: Colors.grey[500],
|
||||||
|
|
@ -500,7 +422,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
decodeHtmlEntities(schedule.content ?? schedule.title),
|
decodeHtmlEntities(schedule.description ?? schedule.title),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
|
|
@ -509,38 +431,19 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 이미지
|
// 이미지
|
||||||
if (schedule.imageUrls.isNotEmpty) ...[
|
if (schedule.imageUrl != null) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: schedule.imageUrls.length == 1
|
child: CachedNetworkImage(
|
||||||
? CachedNetworkImage(
|
imageUrl: schedule.imageUrl!,
|
||||||
imageUrl: schedule.imageUrls[0],
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, _) => Container(
|
placeholder: (_, _) => Container(
|
||||||
height: 200,
|
height: 200,
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
),
|
),
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||||
)
|
|
||||||
: GridView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
crossAxisSpacing: 4,
|
|
||||||
mainAxisSpacing: 4,
|
|
||||||
),
|
|
||||||
itemCount: schedule.imageUrls.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return CachedNetworkImage(
|
|
||||||
imageUrl: schedule.imageUrls[index],
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, _) => Container(color: Colors.grey[200]),
|
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -560,7 +463,6 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// X에서 보기 버튼
|
// X에서 보기 버튼
|
||||||
if (schedule.postUrl != null)
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -570,7 +472,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => _launchUrl(schedule.postUrl!),
|
onPressed: () => _launchUrl(schedule.sourceUrl ?? ''),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.grey[900],
|
backgroundColor: Colors.grey[900],
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -582,6 +484,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
// X 로고
|
||||||
_buildXLogo(14),
|
_buildXLogo(14),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
const Text(
|
||||||
|
|
@ -598,18 +501,364 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 인증 배지 (웹과 동일한 SVG)
|
||||||
|
Widget _buildVerifiedBadge() {
|
||||||
|
return SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _VerifiedBadgePainter(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X 로고
|
||||||
|
Widget _buildXLogo(double size) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _XLogoPainter(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 콘서트 섹션
|
||||||
|
Widget _buildConcertSection(ScheduleDetail schedule) {
|
||||||
|
final hasLocation =
|
||||||
|
schedule.locationLat != null && schedule.locationLng != null;
|
||||||
|
final hasPoster = schedule.images.isNotEmpty;
|
||||||
|
final hasMultipleDates = schedule.relatedDates.length > 1;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 헤더 (포스터 여백 제거)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [AppColors.primary, AppColors.primary.withValues(alpha: 0.8)],
|
||||||
|
),
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (hasPoster)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12)),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: schedule.images[0],
|
||||||
|
width: 56,
|
||||||
|
height: 72,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, _) => Container(
|
||||||
|
width: 56,
|
||||||
|
height: 72,
|
||||||
|
color: Colors.white24,
|
||||||
|
),
|
||||||
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
decodeHtmlEntities(schedule.title),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 정보 목록
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 공연 일정
|
||||||
|
_buildSectionLabel(LucideIcons.calendar, '공연 일정'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (hasMultipleDates)
|
||||||
|
...schedule.relatedDates.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final item = entry.value;
|
||||||
|
final isCurrent = item.id == schedule.id;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: isCurrent ? null : () => _changeSchedule(item.id),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrent
|
||||||
|
? AppColors.primary
|
||||||
|
: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}회차 ${_formatSingleDate(item.date, item.time)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
// 볼드체 제거
|
||||||
|
color: isCurrent
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
_formatSingleDate(schedule.date, schedule.time),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 장소
|
||||||
|
if (schedule.locationName != null) ...[
|
||||||
|
_buildSectionLabel(LucideIcons.mapPin, '장소'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
schedule.locationName!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (schedule.locationAddress != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
schedule.locationAddress!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
// 위치 (지도)
|
||||||
|
if (hasLocation) ...[
|
||||||
|
_buildSectionLabel(LucideIcons.navigation, '위치'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 지도 플레이스홀더 (탭시 카카오맵으로 이동)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _launchUrl(
|
||||||
|
'https://map.kakao.com/link/map/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}',
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
height: 160,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// 지도 이미지 (카카오 Static Map API)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: 'https://map.kakao.com/link/map/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
placeholder: (_, _) => Container(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(LucideIcons.map, size: 32, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, _, _) => Container(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.navigation, size: 28, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
schedule.locationName!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'탭하여 지도에서 보기',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
// 설명
|
||||||
|
if (schedule.description != null &&
|
||||||
|
schedule.description!.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: AppColors.divider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
decodeHtmlEntities(schedule.description!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 버튼 영역
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 길찾기 버튼 (파란색)
|
||||||
|
if (hasLocation)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => _launchUrl(
|
||||||
|
'https://map.kakao.com/link/to/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF3B82F6), // blue-500
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(LucideIcons.navigation, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('길찾기'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasLocation && schedule.sourceUrl != null)
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 상세 정보 버튼
|
||||||
|
if (schedule.sourceUrl != null)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => _launchUrl(schedule.sourceUrl!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(LucideIcons.externalLink, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('상세 정보 보기'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 기본 섹션
|
/// 기본 섹션
|
||||||
Widget _buildDefaultSection(ScheduleDetail schedule) {
|
Widget _buildDefaultSection(ScheduleDetail schedule) {
|
||||||
return _buildInfoCard(schedule);
|
return _buildInfoCard(
|
||||||
|
schedule,
|
||||||
|
bottomButton: schedule.sourceUrl != null
|
||||||
|
? SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => _launchUrl(schedule.sourceUrl!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(LucideIcons.externalLink, size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('원본 보기'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 정보 카드
|
/// 정보 카드
|
||||||
Widget _buildInfoCard(ScheduleDetail schedule) {
|
Widget _buildInfoCard(ScheduleDetail schedule, {Widget? bottomButton}) {
|
||||||
final isFullGroup = schedule.members.length >= 5;
|
final isFullGroup = schedule.members.length >= 5;
|
||||||
|
|
||||||
// 채널명 또는 소스 정보
|
|
||||||
final sourceName = schedule.channelName ?? schedule.username;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|
@ -645,8 +894,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
_buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
|
_buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
|
||||||
if (schedule.formattedTime != null)
|
if (schedule.formattedTime != null)
|
||||||
_buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
|
_buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
|
||||||
if (sourceName != null)
|
if (schedule.sourceName != null)
|
||||||
_buildMetaItem(LucideIcons.link2, sourceName),
|
_buildMetaItem(LucideIcons.link2, schedule.sourceName!),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 멤버
|
// 멤버
|
||||||
|
|
@ -668,6 +917,24 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
// 설명
|
||||||
|
if (schedule.description != null &&
|
||||||
|
schedule.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
decodeHtmlEntities(schedule.description!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// 하단 버튼
|
||||||
|
if (bottomButton != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
bottomButton,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -691,41 +958,60 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 인증 배지
|
/// 섹션 라벨
|
||||||
Widget _buildVerifiedBadge() {
|
Widget _buildSectionLabel(IconData icon, String text) {
|
||||||
return SizedBox(
|
return Row(
|
||||||
width: 16,
|
children: [
|
||||||
height: 16,
|
Icon(icon, size: 14, color: AppColors.textSecondary),
|
||||||
child: CustomPaint(
|
const SizedBox(width: 6),
|
||||||
painter: _VerifiedBadgePainter(),
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// X 로고
|
|
||||||
Widget _buildXLogo(double size) {
|
|
||||||
return SizedBox(
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
child: CustomPaint(
|
|
||||||
painter: _XLogoPainter(),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 인증 배지 페인터
|
/// 유튜브 재생 아이콘 페인터
|
||||||
|
class _YoutubePlayIconPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(size.width * 0.35, size.height * 0.25)
|
||||||
|
..lineTo(size.width * 0.35, size.height * 0.75)
|
||||||
|
..lineTo(size.width * 0.75, size.height * 0.5)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인증 배지 페인터 (웹과 동일한 디자인)
|
||||||
class _VerifiedBadgePainter extends CustomPainter {
|
class _VerifiedBadgePainter extends CustomPainter {
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = const Color(0xFF3B82F6)
|
..color = const Color(0xFF3B82F6) // blue-500
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
// 배지 배경 (둥근 별 모양)
|
||||||
final center = Offset(size.width / 2, size.height / 2);
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
final radius = size.width / 2;
|
final radius = size.width / 2;
|
||||||
canvas.drawCircle(center, radius, paint);
|
canvas.drawCircle(center, radius, paint);
|
||||||
|
|
||||||
|
// 체크마크
|
||||||
final checkPaint = Paint()
|
final checkPaint = Paint()
|
||||||
..color = Colors.white
|
..color = Colors.white
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
|
|
@ -754,6 +1040,7 @@ class _XLogoPainter extends CustomPainter {
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
|
// X 로고 경로 (간소화)
|
||||||
path.moveTo(size.width * 0.76, size.height * 0.09);
|
path.moveTo(size.width * 0.76, size.height * 0.09);
|
||||||
path.lineTo(size.width * 0.9, size.height * 0.09);
|
path.lineTo(size.width * 0.9, size.height * 0.09);
|
||||||
path.lineTo(size.width * 0.6, size.height * 0.44);
|
path.lineTo(size.width * 0.6, size.height * 0.44);
|
||||||
|
|
|
||||||
|
|
@ -779,7 +779,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
bottom: index < searchState.results.length - 1 ? 12 : 0,
|
bottom: index < searchState.results.length - 1 ? 12 : 0,
|
||||||
),
|
),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: schedule.isSpecial ? null : () => context.push('/schedule/${schedule.id}'),
|
onTap: () => context.push('/schedule/${schedule.id}'),
|
||||||
child: SearchScheduleCard(
|
child: SearchScheduleCard(
|
||||||
schedule: schedule,
|
schedule: schedule,
|
||||||
categoryColor: parseColor(schedule.categoryColor),
|
categoryColor: parseColor(schedule.categoryColor),
|
||||||
|
|
@ -1370,8 +1370,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
}) {
|
}) {
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
clipBehavior: Clip.none,
|
padding: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.only(top: 4),
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 7,
|
crossAxisCount: 7,
|
||||||
|
|
@ -1410,8 +1409,9 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
boxShadow: isSelected
|
boxShadow: isSelected
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primary.withValues(alpha: 0.3),
|
color: AppColors.primary.withValues(alpha: 0.4),
|
||||||
blurRadius: 4,
|
blurRadius: 8,
|
||||||
|
spreadRadius: 1,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -1581,15 +1581,11 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
itemCount: state.selectedDateSchedules.length,
|
itemCount: state.selectedDateSchedules.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final schedule = state.selectedDateSchedules[index];
|
final schedule = state.selectedDateSchedules[index];
|
||||||
|
return Padding(
|
||||||
// 특별 일정 카드 (생일/데뷔/기념일)
|
padding: EdgeInsets.only(
|
||||||
Widget card;
|
bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0,
|
||||||
if (schedule.isBirthday) {
|
),
|
||||||
card = BirthdayCard(schedule: schedule);
|
child: GestureDetector(
|
||||||
} else if (schedule.isDebut || schedule.isAnniversary) {
|
|
||||||
card = DebutCard(schedule: schedule);
|
|
||||||
} else {
|
|
||||||
card = GestureDetector(
|
|
||||||
onTap: () => context.push('/schedule/${schedule.id}'),
|
onTap: () => context.push('/schedule/${schedule.id}'),
|
||||||
child: AnimatedScheduleCard(
|
child: AnimatedScheduleCard(
|
||||||
key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'),
|
key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'),
|
||||||
|
|
@ -1597,14 +1593,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
schedule: schedule,
|
schedule: schedule,
|
||||||
categoryColor: parseColor(schedule.categoryColor),
|
categoryColor: parseColor(schedule.categoryColor),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0,
|
|
||||||
),
|
),
|
||||||
child: card,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
/// 일정 카드 위젯
|
/// 일정 카드 위젯
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../models/schedule.dart';
|
import '../../../models/schedule.dart';
|
||||||
|
|
@ -251,257 +250,3 @@ 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,13 +7,9 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
#include <volume_controller/volume_controller_plugin.h>
|
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
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,7 +4,6 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
volume_controller
|
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,20 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import flutter_inappwebview_macos
|
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import video_player_avfoundation
|
import video_player_avfoundation
|
||||||
import volume_controller
|
|
||||||
import wakelock_plus
|
import wakelock_plus
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
|
|
||||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
app/pubspec.lock
144
app/pubspec.lock
|
|
@ -17,14 +17,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.4.1"
|
version: "8.4.1"
|
||||||
archive:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: archive
|
|
||||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.0.9"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -238,70 +230,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -336,14 +264,6 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -424,14 +344,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -544,14 +456,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -728,14 +632,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "1.5.2"
|
||||||
posix:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: posix
|
|
||||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.0"
|
|
||||||
provider:
|
provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -856,14 +752,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -1013,14 +901,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
unicode:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: unicode
|
|
||||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.9"
|
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1165,14 +1045,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
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:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1181,14 +1053,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
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:
|
wakelock_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1277,14 +1141,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
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:
|
sdks:
|
||||||
dart: ">=3.10.7 <4.0.0"
|
dart: ">=3.10.7 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.35.0"
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ dependencies:
|
||||||
video_player: ^2.9.2
|
video_player: ^2.9.2
|
||||||
chewie: ^1.8.5
|
chewie: ^1.8.5
|
||||||
expandable_page_view: ^1.0.17
|
expandable_page_view: ^1.0.17
|
||||||
omni_video_player: ^3.1.6
|
|
||||||
shared_preferences: ^2.3.5
|
shared_preferences: ^2.3.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,12 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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 <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
VolumeControllerPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_inappwebview_windows
|
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
volume_controller
|
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
/**
|
|
||||||
* 리트윗 데이터 재수집 스크립트
|
|
||||||
* 원본 작성자의 타임라인에서 매칭되는 트윗을 찾아 이미지와 내용을 복구합니다.
|
|
||||||
*
|
|
||||||
* 사용법: 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 { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
|
||||||
import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js';
|
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||||
import { formatDate, formatTime } from '../../utils/date.js';
|
import { formatDate, formatTime } from '../../utils/date.js';
|
||||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -161,122 +161,4 @@ export default async function xRoutes(fastify) {
|
||||||
return serverError(reply, err.message);
|
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 fp from 'fastify-plugin';
|
||||||
import { fetchTweets, fetchAllTweets, fetchProfile as fetchNitterProfile, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||||
import { fetchVideoInfo } from '../youtube/api.js';
|
import { fetchVideoInfo } from '../youtube/api.js';
|
||||||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
|
|
@ -65,9 +65,6 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const time = formatTime(tweet.time);
|
const time = formatTime(tweet.time);
|
||||||
const title = extractTitle(tweet.text);
|
const title = extractTitle(tweet.text);
|
||||||
|
|
||||||
// 리트윗인 경우 원본 작성자를 username으로 사용
|
|
||||||
const tweetUsername = tweet.originalUsername || username;
|
|
||||||
|
|
||||||
// 트랜잭션으로 INSERT 작업 수행
|
// 트랜잭션으로 INSERT 작업 수행
|
||||||
return withTransaction(fastify.db, async (connection) => {
|
return withTransaction(fastify.db, async (connection) => {
|
||||||
// schedules 테이블에 저장
|
// schedules 테이블에 저장
|
||||||
|
|
@ -83,7 +80,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
[
|
[
|
||||||
scheduleId,
|
scheduleId,
|
||||||
tweet.id,
|
tweet.id,
|
||||||
tweetUsername,
|
username,
|
||||||
tweet.text,
|
tweet.text,
|
||||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||||
]
|
]
|
||||||
|
|
@ -247,7 +244,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* X 프로필 조회 (Redis 캐시 → bot_x 테이블 → Nitter 직접 조회)
|
* X 프로필 조회 (Redis 캐시 → bot_x 테이블)
|
||||||
*/
|
*/
|
||||||
async function getProfile(username) {
|
async function getProfile(username) {
|
||||||
// Redis 캐시 확인
|
// Redis 캐시 확인
|
||||||
|
|
@ -269,24 +266,6 @@ async function xBotPlugin(fastify, opts) {
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
avatarUrl: row.avatar_url,
|
avatarUrl: row.avatar_url,
|
||||||
};
|
};
|
||||||
await fastify.redis.setex(
|
|
||||||
`${PROFILE_CACHE_PREFIX}${username}`,
|
|
||||||
PROFILE_TTL,
|
|
||||||
JSON.stringify(data)
|
|
||||||
);
|
|
||||||
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 캐시에 저장
|
// Redis 캐시에 저장
|
||||||
await fastify.redis.setex(
|
await fastify.redis.setex(
|
||||||
`${PROFILE_CACHE_PREFIX}${username}`,
|
`${PROFILE_CACHE_PREFIX}${username}`,
|
||||||
|
|
@ -295,9 +274,6 @@ async function xBotPlugin(fastify, opts) {
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`Nitter 프로필 조회 실패 (${username}): ${err.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,11 +112,6 @@ function extractTextFromHtml(html) {
|
||||||
.replace(/<br\s*\/?>/g, '\n')
|
.replace(/<br\s*\/?>/g, '\n')
|
||||||
// <a> 태그: href에서 원본 URL 추출 (외부 링크만)
|
// <a> 태그: href에서 원본 URL 추출 (외부 링크만)
|
||||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => {
|
.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 등)는 표시 텍스트 사용
|
// Nitter 내부 링크 (/search, /hashtag 등)는 표시 텍스트 사용
|
||||||
if (href.startsWith('/')) {
|
if (href.startsWith('/')) {
|
||||||
return text;
|
return text;
|
||||||
|
|
@ -151,22 +146,6 @@ export function parseTweets(html, username, options = {}) {
|
||||||
const isRetweet = container.includes('class="retweet-header"');
|
const isRetweet = container.includes('class="retweet-header"');
|
||||||
if (isRetweet && !includeRetweets) continue;
|
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
|
// 트윗 ID
|
||||||
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
|
||||||
if (!idMatch) continue;
|
if (!idMatch) continue;
|
||||||
|
|
@ -192,11 +171,7 @@ export function parseTweets(html, username, options = {}) {
|
||||||
time,
|
time,
|
||||||
text,
|
text,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
isRetweet,
|
url: `https://x.com/${username}/status/${id}`,
|
||||||
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">
|
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
|
||||||
{decodeHtmlEntities(schedule.title)}
|
{decodeHtmlEntities(schedule.title)}
|
||||||
</p>
|
</p>
|
||||||
{/* 시간 + 카테고리 */}
|
{/* 시간 + 카테고리 + 소스 */}
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
|
||||||
{timeStr && (
|
{timeStr && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
|
|
@ -72,14 +72,13 @@ const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className =
|
||||||
{categoryInfo.name}
|
{categoryInfo.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{/* 소스 */}
|
|
||||||
{sourceName && (
|
{sourceName && (
|
||||||
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
|
<span className="flex items-center gap-1">
|
||||||
<Link2 size={12} />
|
<Link2 size={12} />
|
||||||
{sourceName}
|
{sourceName}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* 멤버 */}
|
{/* 멤버 */}
|
||||||
{displayMembers.length > 0 && (
|
{displayMembers.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue