Initial commit: SubManager Flutter App

주요 구현 완료 기능:
- 구독 관리 (추가/편집/삭제/카테고리 분류)
- 이벤트 할인 시스템 (기본값 자동 설정)
- SMS 자동 스캔 및 구독 정보 추출
- 알림 시스템 (타임존 처리 안정화)
- 환율 변환 지원 (KRW/USD)
- 반응형 UI 및 애니메이션
- 다국어 지원 (한국어/영어)

버그 수정:
- NotificationService tz.local 초기화 오류 해결
- MainScreenSummaryCard 레이아웃 오버플로우 수정
- 구독 추가 시 LateInitializationError 완전 해결

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-09 14:29:53 +09:00
commit 8619e96739
177 changed files with 23085 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
///
/// [controller]와 [pulseController]를 통해 애니메이션을 제어합니다.
class AnimatedWaveBackground extends StatelessWidget {
final AnimationController controller;
final AnimationController pulseController;
const AnimatedWaveBackground({
Key? key,
required this.controller,
required this.pulseController,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
AnimatedBuilder(
animation: controller,
builder: (context, child) {
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
final angle = controller.value * 2 * math.pi;
// 사인 함수를 사용하여 부드러운 움직임 생성
final xOffset = 20 * math.sin(angle);
final yOffset = 10 * math.cos(angle);
return Positioned(
right: -40 + xOffset,
top: -60 + yOffset,
child: Transform.rotate(
// 회전도 선형적으로 변화하도록 수정
angle: 0.2 * math.sin(angle * 0.5),
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(100),
),
),
),
);
},
),
AnimatedBuilder(
animation: controller,
builder: (context, child) {
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
final xOffset = 20 * math.cos(angle);
final yOffset = 10 * math.sin(angle);
return Positioned(
left: -80 + xOffset,
bottom: -70 + yOffset,
child: Transform.rotate(
// 반대 방향으로 회전하도록 설정
angle: -0.3 * math.sin(angle * 0.5),
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(110),
),
),
),
);
},
),
// 배경에 추가적인 작은 원 추가하여 깊이감 증가
AnimatedBuilder(
animation: controller,
builder: (context, child) {
// 세 번째 원은 다른 위상으로 움직이도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
final xOffset = 15 * math.sin(angle * 0.7);
final yOffset = 8 * math.cos(angle * 0.7);
return Positioned(
right: 40 + xOffset,
bottom: -40 + yOffset,
child: Transform.rotate(
angle: 0.4 * math.cos(angle * 0.5),
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(60),
),
),
),
);
},
),
// 숨쉬는 효과가 있는 작은 원
AnimatedBuilder(
animation: pulseController,
builder: (context, child) {
return Positioned(
top: 10,
left: 200,
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white.withOpacity(
0.1 + 0.1 * pulseController.value,
),
borderRadius: BorderRadius.circular(15),
),
),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// 카테고리별 구독 그룹의 헤더 위젯
///
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
/// 참고: 여러 통화 단위가 혼합된 경우 간단히 원화 표시 형식을 사용합니다.
class CategoryHeaderWidget extends StatelessWidget {
final String categoryName;
final int subscriptionCount;
final double totalCost;
const CategoryHeaderWidget({
Key? key,
required this.categoryName,
required this.subscriptionCount,
required this.totalCost,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
categoryName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0xFF374151),
),
),
Text(
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '', decimalDigits: 0).format(totalCost)}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF6B7280),
),
),
],
),
const SizedBox(height: 8),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFFEEEEEE),
),
],
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
/// 구독이 없을 때 표시되는 빈 화면 위젯
///
/// 애니메이션 효과와 함께 사용자에게 구독 추가를 유도합니다.
class EmptyStateWidget extends StatelessWidget {
final AnimationController fadeController;
final AnimationController rotateController;
final AnimationController slideController;
final VoidCallback onAddPressed;
const EmptyStateWidget({
Key? key,
required this.fadeController,
required this.rotateController,
required this.slideController,
required this.onAddPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
child: Center(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutBack)),
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.08),
spreadRadius: 0,
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: rotateController,
builder: (context, child) {
return Transform.rotate(
angle: rotateController.value * 2 * math.pi,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF2563EB)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF3B82F6).withOpacity(0.3),
spreadRadius: 0,
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.subscriptions_outlined,
size: 48,
color: Colors.white,
),
),
);
},
),
const SizedBox(height: 32),
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF0EA5E9)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Text(
'등록된 구독이 없습니다',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 8),
const Text(
'새로운 구독을 추가해보세요',
style: TextStyle(
fontSize: 16,
color: Color(0xFF64748B),
),
),
const SizedBox(height: 32),
MouseRegion(
onEnter: (_) => {},
onExit: (_) => {},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
backgroundColor: const Color(0xFF3B82F6),
),
onPressed: () {
HapticFeedback.mediumImpact();
onAddPressed();
},
child: const Text(
'구독 추가하기',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import '../services/exchange_rate_service.dart';
/// 환율 정보를 표시하는 위젯
/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다.
class ExchangeRateWidget extends StatefulWidget {
/// 달러 금액 변화 감지용 TextEditingController
final TextEditingController costController;
/// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true)
final bool showExchangeRate;
const ExchangeRateWidget({
Key? key,
required this.costController,
required this.showExchangeRate,
}) : super(key: key);
@override
State<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
}
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
final ExchangeRateService _exchangeRateService = ExchangeRateService();
String _exchangeRateInfo = '';
String _convertedAmount = '';
@override
void initState() {
super.initState();
_loadExchangeRate();
widget.costController.addListener(_updateConvertedAmount);
}
@override
void dispose() {
widget.costController.removeListener(_updateConvertedAmount);
super.dispose();
}
@override
void didUpdateWidget(ExchangeRateWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록
if (oldWidget.showExchangeRate != widget.showExchangeRate) {
oldWidget.costController.removeListener(_updateConvertedAmount);
if (widget.showExchangeRate) {
widget.costController.addListener(_updateConvertedAmount);
_loadExchangeRate();
_updateConvertedAmount();
} else {
setState(() {
_exchangeRateInfo = '';
_convertedAmount = '';
});
}
}
}
/// 환율 정보 로드
Future<void> _loadExchangeRate() async {
if (!widget.showExchangeRate) return;
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
if (mounted) {
setState(() {
_exchangeRateInfo = rateInfo;
});
}
}
/// 달러 금액이 변경될 때 원화 금액 업데이트
Future<void> _updateConvertedAmount() async {
if (!widget.showExchangeRate) return;
try {
// 금액 입력값에서 콤마 제거 후 숫자로 변환
final text = widget.costController.text.replaceAll(',', '');
if (text.isEmpty) {
setState(() {
_convertedAmount = '';
});
return;
}
final amount = double.tryParse(text);
if (amount != null) {
final converted =
await _exchangeRateService.getFormattedKrwAmount(amount);
if (mounted) {
setState(() {
_convertedAmount = converted;
});
}
}
} catch (e) {
// 오류 발생 시 빈 문자열 표시
setState(() {
_convertedAmount = '';
});
}
}
/// 환율 정보 텍스트 위젯 생성
Widget buildExchangeRateInfo() {
if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink();
return Text(
_exchangeRateInfo,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
);
}
/// 환산 금액 텍스트 위젯 생성
Widget buildConvertedAmount() {
if (_convertedAmount.isEmpty) return const SizedBox.shrink();
return Text(
_convertedAmount,
style: const TextStyle(
fontSize: 14,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
);
}
@override
Widget build(BuildContext context) {
if (!widget.showExchangeRate) {
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
}
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
],
);
}
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
String get exchangeRateInfo => _exchangeRateInfo;
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
String get convertedAmount => _convertedAmount;
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart';
import '../theme/app_colors.dart';
import '../utils/format_helper.dart';
import 'animated_wave_background.dart';
/// 메인 화면 상단에 표시되는 요약 카드 위젯
///
/// 총 구독 수와 월별 총 지출을 표시하며, 분석 화면으로 이동하는 기능을 제공합니다.
class MainScreenSummaryCard extends StatelessWidget {
final SubscriptionProvider provider;
final AnimationController fadeController;
final AnimationController pulseController;
final AnimationController waveController;
final AnimationController slideController;
final VoidCallback onTap;
const MainScreenSummaryCard({
Key? key,
required this.provider,
required this.fadeController,
required this.pulseController,
required this.waveController,
required this.slideController,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final double monthlyCost = provider.totalMonthlyExpense;
final double yearlyCost = monthlyCost * 12;
final int totalSubscriptions = provider.subscriptions.length;
final double eventSavings = provider.totalEventSavings;
final int activeEvents = provider.activeEventSubscriptions.length;
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: GestureDetector(
onTap: () {
HapticFeedback.mediumImpact();
onTap();
},
child: Card(
elevation: 4,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
minHeight: 180,
maxHeight: activeEvents > 0 ? 300 : 240,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primaryColor,
AppColors.primaryColor.withBlue(
(AppColors.primaryColor.blue * 1.3)
.clamp(0, 255)
.toInt()),
],
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Stack(
children: [
// 애니메이션 웨이브 배경
Positioned.fill(
child: AnimatedWaveBackground(
controller: waveController,
pulseController: pulseController,
),
),
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'이번 달 총 구독 비용',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(monthlyCost),
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: -1,
),
),
const SizedBox(width: 4),
Text(
'',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
_buildInfoBox(
context,
title: '연간 구독 비용',
value: '${NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(yearlyCost)}',
),
const SizedBox(width: 16),
_buildInfoBox(
context,
title: '총 구독 서비스',
value: '$totalSubscriptions개',
),
],
),
// 이벤트 절약액 표시
if (activeEvents > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withOpacity(0.2),
Colors.white.withOpacity(0.15),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
shape: BoxShape.circle,
),
child: const Icon(
Icons.local_offer_rounded,
size: 14,
color: Colors.white,
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'이벤트 할인 중',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Row(
children: [
Text(
NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(eventSavings),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
' 절약 ($activeEvents개)',
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
],
),
),
],
],
),
),
Positioned(
right: 16,
top: 16,
child: Icon(
Icons.arrow_forward_ios,
color: Colors.white.withOpacity(0.7),
size: 16,
),
),
],
),
),
),
),
),
),
);
}
Widget _buildInfoBox(BuildContext context,
{required String title, required String value}) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform;
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
/// SRP에 따라 광고 전용 위젯으로 분리
class NativeAdWidget extends StatefulWidget {
const NativeAdWidget({Key? key}) : super(key: key);
@override
State<NativeAdWidget> createState() => _NativeAdWidgetState();
}
class _NativeAdWidgetState extends State<NativeAdWidget> {
NativeAd? _nativeAd;
bool _isLoaded = false;
String? _error;
bool _isAdLoading = false; // 광고 로드 중복 방지 플래그
@override
void initState() {
super.initState();
// initState에서는 Theme.of(context)와 같은 InheritedWidget에 의존하지 않음
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 위젯이 완전히 초기화된 후 광고 로드
if (!_isAdLoading && !kIsWeb) {
_loadAd();
_isAdLoading = true; // 중복 로드 방지
}
}
/// 네이티브 광고 로드 함수
void _loadAd() {
// 웹 또는 Android/iOS가 아닌 경우 광고 로드 방지
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
return;
}
_nativeAd = NativeAd(
adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
request: const AdRequest(),
listener: NativeAdListener(
onAdLoaded: (ad) {
setState(() {
_isLoaded = true;
});
},
onAdFailedToLoad: (ad, error) {
ad.dispose();
setState(() {
_error = error.message;
});
},
),
)..load();
}
/// 테스트 광고 단위 ID 반환 함수
/// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용
String _testAdUnitId() {
if (Platform.isAndroid) {
// Android 테스트 네이티브 광고 ID
return 'ca-app-pub-3940256099942544/2247696110';
} else if (Platform.isIOS) {
// iOS 테스트 네이티브 광고 ID
return 'ca-app-pub-3940256099942544/3986624511';
}
return '';
}
@override
void dispose() {
_nativeAd?.dispose();
super.dispose();
}
/// 웹용 광고 플레이스홀더 위젯
Widget _buildWebPlaceholder() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Icon(
Icons.ad_units,
color: Colors.grey,
size: 32,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
height: 14,
width: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
height: 10,
width: 180,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
Container(
width: 60,
height: 24,
decoration: BoxDecoration(
color: Colors.blue[100],
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'광고영역',
style: TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
// 웹 환경인 경우 플레이스홀더 표시
if (kIsWeb) {
return _buildWebPlaceholder();
}
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
if (!(Platform.isAndroid || Platform.isIOS)) {
return const SizedBox.shrink();
}
if (_error != null) {
// 광고 로드 실패 시 빈 공간 반환
return const SizedBox.shrink();
}
if (!_isLoaded) {
// 광고 로딩 중 로딩 인디케이터 표시
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
);
}
// 광고 정상 노출
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SizedBox(
height: 80, // 네이티브 광고 높이 조정
child: AdWidget(ad: _nativeAd!),
),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
class SkeletonLoading extends StatelessWidget {
const SkeletonLoading({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
// 요약 카드 스켈레톤
Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 24,
color: Colors.grey[300],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSkeletonColumn(),
_buildSkeletonColumn(),
],
),
],
),
),
),
// 구독 목록 스켈레톤
Expanded(
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Container(
width: 200,
height: 24,
color: Colors.grey[300],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Container(
width: 150,
height: 16,
color: Colors.grey[300],
),
const SizedBox(height: 4),
Container(
width: 180,
height: 16,
color: Colors.grey[300],
),
],
),
),
);
},
),
),
],
);
}
Widget _buildSkeletonColumn() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 16,
color: Colors.grey[300],
),
const SizedBox(height: 4),
Container(
width: 100,
height: 24,
color: Colors.grey[300],
),
],
);
}
}

View File

@@ -0,0 +1,618 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dart:math' as math;
import '../models/subscription_model.dart';
import '../screens/detail_screen.dart';
import 'website_icon.dart';
import '../theme/app_colors.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
class SubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription;
const SubscriptionCard({
super.key,
required this.subscription,
});
@override
State<SubscriptionCard> createState() => _SubscriptionCardState();
}
class _SubscriptionCardState extends State<SubscriptionCard>
with SingleTickerProviderStateMixin {
late AnimationController _hoverController;
bool _isHovering = false;
final double _initialElevation = 1.0;
final double _hoveredElevation = 3.0;
late SubscriptionProvider _subscriptionProvider;
@override
void initState() {
super.initState();
_hoverController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_subscriptionProvider =
Provider.of<SubscriptionProvider>(context, listen: false);
}
@override
void dispose() {
_hoverController.dispose();
super.dispose();
}
void _onHover(bool isHovering) {
setState(() {
_isHovering = isHovering;
if (isHovering) {
_hoverController.forward();
} else {
_hoverController.reverse();
}
});
}
// 다음 결제 예정일 정보를 생성
String _getNextBillingText() {
final now = DateTime.now();
final nextBillingDate = widget.subscription.nextBillingDate;
// 날짜 비교를 위해 시간 제거 (날짜만 비교)
final dateOnlyNow = DateTime(now.year, now.month, now.day);
final dateOnlyBilling = DateTime(
nextBillingDate.year, nextBillingDate.month, nextBillingDate.day);
// 오늘이 결제일인 경우
if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) {
return '오늘 결제 예정';
}
// 미래 날짜인 경우 남은 일수 계산
if (dateOnlyBilling.isAfter(dateOnlyNow)) {
final difference = dateOnlyBilling.difference(dateOnlyNow).inDays;
return '$difference일 후 결제 예정';
}
// 과거 날짜인 경우, 다음 결제일 계산
final billingCycle = widget.subscription.billingCycle;
// 월간 구독인 경우
if (billingCycle == '월간') {
// 결제일에 해당하는 날짜 가져오기
int day = nextBillingDate.day;
int nextMonth = now.month;
int nextYear = now.year;
// 해당 월의 마지막 날짜 확인 (예: 31일이 없는 달)
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDayOfMonth) {
day = lastDayOfMonth;
}
// 결제일이 이번 달에서 이미 지났으면 다음 달로 설정
if (now.day > day) {
nextMonth++;
if (nextMonth > 12) {
nextMonth = 1;
nextYear++;
}
// 다음 달의 마지막 날짜 확인
final lastDayOfNextMonth = DateTime(nextYear, nextMonth + 1, 0).day;
if (day > lastDayOfNextMonth) {
day = lastDayOfNextMonth;
}
}
final nextDate = DateTime(nextYear, nextMonth, day);
final days = nextDate.difference(dateOnlyNow).inDays;
if (days == 0) return '오늘 결제 예정';
return '$days일 후 결제 예정';
}
// 연간 구독인 경우
if (billingCycle == '연간') {
// 결제일에 해당하는 날짜와 월 가져오기
int day = nextBillingDate.day;
int month = nextBillingDate.month;
int year = now.year;
// 해당 월의 마지막 날짜 확인
final lastDayOfMonth = DateTime(year, month + 1, 0).day;
if (day > lastDayOfMonth) {
day = lastDayOfMonth;
}
// 올해의 결제일
final thisYearDate = DateTime(year, month, day);
// 올해 결제일이 이미 지났으면 내년으로 계산
if (thisYearDate.isBefore(dateOnlyNow) ||
thisYearDate.isAtSameMomentAs(dateOnlyNow)) {
year++;
// 내년 해당 월의 마지막 날짜 확인
final lastDayOfNextYear = DateTime(year, month + 1, 0).day;
if (day > lastDayOfNextYear) {
day = lastDayOfNextYear;
}
final nextYearDate = DateTime(year, month, day);
final days = nextYearDate.difference(dateOnlyNow).inDays;
if (days == 0) return '오늘 결제 예정';
return '$days일 후 결제 예정';
} else {
final days = thisYearDate.difference(dateOnlyNow).inDays;
if (days == 0) return '오늘 결제 예정';
return '$days일 후 결제 예정';
}
}
// 주간 구독인 경우
if (billingCycle == '주간') {
// 결제 요일 가져오기
final billingWeekday = nextBillingDate.weekday;
// 현재 요일
final currentWeekday = now.weekday;
// 다음 같은 요일까지 남은 일수 계산
int daysUntilNext;
if (currentWeekday < billingWeekday) {
daysUntilNext = billingWeekday - currentWeekday;
} else if (currentWeekday > billingWeekday) {
daysUntilNext = 7 - (currentWeekday - billingWeekday);
} else {
// 같은 요일
daysUntilNext = 7; // 다음 주 같은 요일
}
if (daysUntilNext == 0) return '오늘 결제 예정';
return '$daysUntilNext일 후 결제 예정';
}
// 기본값 - 예상할 수 없는 경우
return '결제일 정보 필요';
}
// 결제일이 가까운지 확인 (7일 이내)
bool _isNearBilling() {
final text = _getNextBillingText();
if (text == '오늘 결제 예정') return true;
final regex = RegExp(r'(\d+)일 후');
final match = regex.firstMatch(text);
if (match != null) {
final days = int.parse(match.group(1) ?? '0');
return days <= 7;
}
return false;
}
Color _getCardColor() {
return Colors.white;
}
@override
Widget build(BuildContext context) {
final isNearBilling = _isNearBilling();
final Color cardColor = _getCardColor();
return Hero(
tag: 'subscription_${widget.subscription.id}',
child: MouseRegion(
onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false),
child: AnimatedBuilder(
animation: _hoverController,
builder: (context, child) {
final elevation = _initialElevation +
(_hoveredElevation - _initialElevation) *
_hoverController.value;
final scale = 1.0 + (0.02 * _hoverController.value);
return Transform.scale(
scale: scale,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () async {
final result = await Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
DetailScreen(subscription: widget.subscription),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 0.05);
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end)
.chain(CurveTween(curve: curve));
var fadeAnimation =
Tween<double>(begin: 0.6, end: 1.0)
.chain(CurveTween(curve: curve))
.animate(animation);
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: animation.drive(tween),
child: child,
),
);
},
),
);
if (result == true) {
// 변경 사항이 있을 경우 미리 저장된 Provider 참조를 사용하여 구독 목록 갱신
await _subscriptionProvider.refreshSubscriptions();
// 메인 화면의 State를 갱신하기 위해 미세한 지연 후 다시 한번 알림
// mounted 상태를 확인하여 dispose된 위젯에서 Provider를 참조하지 않도록 합니다.
Future.delayed(const Duration(milliseconds: 100), () {
// 위젯이 아직 마운트 상태인지 확인하고, 미리 저장된 Provider 참조 사용
if (mounted) {
_subscriptionProvider.notifyListeners();
}
});
}
},
splashColor: AppColors.primaryColor.withOpacity(0.1),
highlightColor: AppColors.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isHovering
? AppColors.primaryColor.withOpacity(0.3)
: AppColors.borderColor,
width: _isHovering ? 1.5 : 0.5,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor.withOpacity(
0.03 + (0.05 * _hoverController.value)),
blurRadius: 8 + (8 * _hoverController.value),
spreadRadius: 0,
offset: Offset(0, 4 + (2 * _hoverController.value)),
),
],
),
child: Column(
children: [
// 그라데이션 상단 바 효과
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 4,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: widget.subscription.isCurrentlyInEvent
? [
const Color(0xFFFF6B6B),
const Color(0xFFFF8787),
]
: isNearBilling
? AppColors.amberGradient
: AppColors.blueGradient,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 서비스 아이콘
WebsiteIcon(
key: ValueKey(
'subscription_icon_${widget.subscription.id}'),
url: widget.subscription.websiteUrl,
serviceName: widget.subscription.serviceName,
size: 48,
isHovered: _isHovering,
),
const SizedBox(width: 16),
// 서비스 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
// 서비스명
Flexible(
child: Text(
widget.subscription.serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Color(0xFF1E293B),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 배지들
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 이벤트 배지
if (widget.subscription.isCurrentlyInEvent) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFFF6B6B),
const Color(0xFFFF8787),
],
),
borderRadius:
BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.local_offer_rounded,
size: 11,
color: Colors.white,
),
const SizedBox(width: 3),
Text(
'이벤트',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
const SizedBox(width: 6),
],
// 결제 주기 배지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius:
BorderRadius.circular(12),
border: Border.all(
color: AppColors.borderColor,
width: 0.5,
),
),
child: Text(
widget.subscription.billingCycle,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
],
),
],
),
const SizedBox(height: 6),
// 가격 정보
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
// 가격 표시 (이벤트 가격 반영)
Row(
children: [
// 이벤트 중인 경우 원래 가격을 취소선으로 표시
if (widget.subscription.isCurrentlyInEvent) ...[
Text(
widget.subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(widget
.subscription.monthlyCost)
: NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(widget
.subscription.monthlyCost),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
],
// 현재 가격 (이벤트 또는 정상 가격)
Text(
widget.subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(widget
.subscription.currentPrice)
: NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(widget
.subscription.currentPrice),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: widget.subscription.isCurrentlyInEvent
? const Color(0xFFFF6B6B)
: AppColors.primaryColor,
),
),
],
),
// 결제 예정일 정보
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: isNearBilling
? AppColors.warningColor
.withOpacity(0.1)
: AppColors.successColor
.withOpacity(0.1),
borderRadius:
BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isNearBilling
? Icons
.access_time_filled_rounded
: Icons
.check_circle_rounded,
size: 12,
color: isNearBilling
? AppColors.warningColor
: AppColors.successColor,
),
const SizedBox(width: 4),
Text(
_getNextBillingText(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isNearBilling
? AppColors.warningColor
: AppColors.successColor,
),
),
],
),
),
],
),
// 이벤트 절약액 표시
if (widget.subscription.isCurrentlyInEvent &&
widget.subscription.eventSavings > 0) ...[
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.savings_rounded,
size: 14,
color: Color(0xFFFF6B6B),
),
const SizedBox(width: 4),
Text(
widget.subscription.currency == 'USD'
? '${NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(widget.subscription.eventSavings)} 절약'
: '${NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(widget.subscription.eventSavings)} 절약',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFFFF6B6B),
),
),
],
),
),
const SizedBox(width: 8),
// 이벤트 종료일까지 남은 일수
if (widget.subscription.eventEndDate != null) ...[
Text(
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
style: TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
],
],
),
],
],
),
),
],
),
),
],
),
),
),
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../widgets/subscription_card.dart';
import '../widgets/category_header_widget.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions;
final AnimationController fadeController;
const SubscriptionListWidget({
Key? key,
required this.categorizedSubscriptions,
required this.fadeController,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// 카테고리 키 목록 (정렬된)
final categories = categorizedSubscriptions.keys.toList();
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final category = categories[index];
final subscriptions = categorizedSubscriptions[category]!;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 카테고리 헤더
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: CategoryHeaderWidget(
categoryName: category,
subscriptionCount: subscriptions.length,
totalCost: subscriptions.fold(
0.0,
(sum, sub) => sum + sub.monthlyCost,
),
),
),
// 카테고리별 구독 목록
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: fadeController, curve: Curves.easeIn)),
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: subscriptions.length,
itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex;
final animationBegin = 0.2;
final animationEnd = 1.0;
final intervalStart = delay;
final intervalEnd = intervalStart + 0.4;
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized =
intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
return FadeTransition(
opacity: Tween<double>(
begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation(
parent: fadeController,
curve: Interval(intervalStartNormalized,
intervalEndNormalized,
curve: Curves.easeOut))),
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: SubscriptionCard(
subscription: subscriptions[subIndex],
),
),
);
},
),
),
],
),
);
},
childCount: categories.length,
),
);
}
}
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯
class MultiSliver extends StatelessWidget {
final List<Widget> children;
const MultiSliver({
Key? key,
required this.children,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= children.length) return null;
return children[index];
},
childCount: children.length,
),
);
}
}

View File

@@ -0,0 +1,748 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:octo_image/octo_image.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart' as html_dom;
import '../theme/app_colors.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
// 파비콘 캐시 관리 클래스
class FaviconCache {
// 메모리 캐시 (앱 전체에서 공유)
static final Map<String, String> _memoryCache = {};
// 로딩 상태 추적 (동시에 동일한 URL에 대한 중복 요청 방지)
static final Set<String> _loadingKeys = {};
// 메모리 캐시에서 파비콘 URL 가져오기
static String? getFromMemory(String serviceKey) {
return _memoryCache[serviceKey];
}
// 메모리 캐시에 파비콘 URL 저장
static void saveToMemory(String serviceKey, String logoUrl) {
_memoryCache[serviceKey] = logoUrl;
// 로딩 완료 표시
_loadingKeys.remove(serviceKey);
}
// 로딩 시작 표시 (중복 요청 방지용)
static bool markAsLoading(String serviceKey) {
if (_loadingKeys.contains(serviceKey)) {
// 이미 로딩 중이면 false 반환
return false;
}
_loadingKeys.add(serviceKey);
return true;
}
// 로딩 취소 표시
static void cancelLoading(String serviceKey) {
_loadingKeys.remove(serviceKey);
}
// SharedPreferences에서 파비콘 URL 로드
static Future<String?> getFromPrefs(String serviceKey) async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('favicon_$serviceKey');
} catch (e) {
print('파비콘 캐시 로드 오류: $e');
return null;
}
}
// SharedPreferences에 파비콘 URL 저장
static Future<void> saveToPrefs(String serviceKey, String logoUrl) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('favicon_$serviceKey', logoUrl);
} catch (e) {
print('파비콘 캐시 저장 오류: $e');
}
}
// 파비콘 캐시 삭제
static Future<void> remove(String serviceKey) async {
_memoryCache.remove(serviceKey);
_loadingKeys.remove(serviceKey);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('favicon_$serviceKey');
} catch (e) {
print('파비콘 캐시 삭제 오류: $e');
}
}
// 앱에서 로컬 파비콘 파일 경로 가져오기
static Future<String?> getLocalFaviconPath(String serviceKey) async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('local_favicon_$serviceKey');
} catch (e) {
print('로컬 파비콘 경로 로드 오류: $e');
return null;
}
}
// 앱에서 로컬 파비콘 파일 경로 저장
static Future<void> saveLocalFaviconPath(
String serviceKey, String filePath) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('local_favicon_$serviceKey', filePath);
} catch (e) {
print('로컬 파비콘 경로 저장 오류: $e');
}
}
}
// 구글 파비콘 API 서비스
class GoogleFaviconService {
// CORS 프록시 서버 목록
static final List<String> _corsProxies = [
'https://corsproxy.io/?',
'https://api.allorigins.win/raw?url=',
'https://cors-anywhere.herokuapp.com/',
];
// 현재 사용 중인 프록시 인덱스
static int _currentProxyIndex = 0;
// 프록시를 사용하여 URL 생성
static String _getProxiedUrl(String url) {
// 앱 환경에서는 프록시 없이 직접 URL 반환
if (!kIsWeb) {
return url;
}
// 웹 환경에서는 CORS 프록시 사용
final proxy = _corsProxies[_currentProxyIndex];
_currentProxyIndex =
(_currentProxyIndex + 1) % _corsProxies.length; // 다음 요청은 다른 프록시 사용
// URL 인코딩
final encodedUrl = Uri.encodeComponent(url);
return '$proxy$encodedUrl';
}
// 구글 파비콘 API URL 생성
static String getFaviconUrl(String domain, int size) {
final directUrl =
'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://$domain&size=$size';
return directUrl;
}
// DuckDuckGo 파비콘 API URL 생성 (CORS 친화적)
static String getDuckDuckGoFaviconUrl(String domain) {
return 'https://icons.duckduckgo.com/ip3/$domain.ico';
}
// 웹 환경용 이미지 URL 생성 (다양한 파비콘 서비스 시도)
static String getWebFaviconUrl(String domain, int size) {
// 다양한 파비콘 서비스 URL 목록
final List<String> faviconServices = [
// DuckDuckGo의 파비콘 서비스 (CORS 친화적)
'https://icons.duckduckgo.com/ip3/$domain.ico',
// Google의 S2 파비콘 서비스
'https://www.google.com/s2/favicons?domain=$domain&sz=$size',
];
// 첫 번째 서비스 사용 (DuckDuckGo 파비콘)
return faviconServices[0];
}
// Base64로 인코딩된 기본 파비콘 (웹 환경 CORS 문제 완전 우회용)
static String getBase64PlaceholderIcon(String serviceName, Color color) {
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?';
final colorHex = color.value.toRadixString(16).padLeft(8, '0').substring(2);
// 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
final svgContent =
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
'<rect width="64" height="64" rx="12" fill="#$colorHex"/>'
'<text x="32" y="40" font-family="Arial, sans-serif" font-size="32" font-weight="bold" text-anchor="middle" fill="white">$initial</text>'
'</svg>';
// SVG를 Base64로 인코딩
final base64 = base64Encode(utf8.encode(svgContent));
return 'data:image/svg+xml;base64,$base64';
}
}
class WebsiteIcon extends StatefulWidget {
final String? url;
final String serviceName;
final double size;
final bool isHovered;
const WebsiteIcon({
super.key,
this.url,
required this.serviceName,
required this.size,
this.isHovered = false,
});
@override
State<WebsiteIcon> createState() => _WebsiteIconState();
}
class _WebsiteIconState extends State<WebsiteIcon>
with SingleTickerProviderStateMixin {
String? _logoUrl; // 웹에서 사용할 로고 URL
String? _localLogoPath; // 앱에서 사용할 로컬 파일 경로
bool _isLoading = true;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
// 각 인스턴스에 대한 고유 식별자 추가
final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString();
// 서비스와 URL 조합으로 캐시 키 생성
String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}';
// 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용)
String? _previousServiceKey;
// 로드 시작된 시점
DateTime? _loadStartTime;
@override
void initState() {
super.initState();
// 애니메이션 컨트롤러 초기화
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
CurvedAnimation(
parent: _animationController, curve: Curves.easeOutCubic));
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut));
// 초기 _previousServiceKey 설정
_previousServiceKey = _serviceKey;
// 로드 시작 시간 기록
_loadStartTime = DateTime.now();
// 최초 로딩
_loadFaviconWithCache();
}
// 캐시를 활용해 파비콘 로드
Future<void> _loadFaviconWithCache() async {
setState(() {
_isLoading = true;
});
if (kIsWeb) {
// 웹 환경: 메모리 캐시 확인 후 Google API 사용
String? cachedLogo = FaviconCache.getFromMemory(_serviceKey);
if (cachedLogo != null) {
setState(() {
_logoUrl = cachedLogo;
_isLoading = false;
});
return;
}
// 이미 로딩 중인지 확인
if (!FaviconCache.markAsLoading(_serviceKey)) {
await Future.delayed(Duration(milliseconds: 500));
cachedLogo = FaviconCache.getFromMemory(_serviceKey);
if (cachedLogo != null) {
setState(() {
_logoUrl = cachedLogo;
_isLoading = false;
});
} else {
FaviconCache.cancelLoading(_serviceKey);
_fetchLogoForWeb();
}
return;
}
// SharedPreferences 확인
cachedLogo = await FaviconCache.getFromPrefs(_serviceKey);
if (cachedLogo != null) {
FaviconCache.saveToMemory(_serviceKey, cachedLogo);
if (mounted) {
setState(() {
_logoUrl = cachedLogo;
_isLoading = false;
});
}
return;
}
// 웹용 로고 가져오기
_fetchLogoForWeb();
} else {
// 앱 환경: 로컬 파일 확인 후 다운로드
// 1. 로컬 파일 경로 확인
String? localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
if (localPath != null) {
final file = File(localPath);
if (await file.exists()) {
if (mounted) {
setState(() {
_localLogoPath = localPath;
_isLoading = false;
});
}
return;
}
}
// 2. 이미 로딩 중인지 확인
if (!FaviconCache.markAsLoading(_serviceKey)) {
await Future.delayed(Duration(milliseconds: 500));
localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
if (localPath != null) {
final file = File(localPath);
if (await file.exists()) {
setState(() {
_localLogoPath = localPath;
_isLoading = false;
});
} else {
FaviconCache.cancelLoading(_serviceKey);
_fetchLogoForApp();
}
} else {
FaviconCache.cancelLoading(_serviceKey);
_fetchLogoForApp();
}
return;
}
// 3. 앱용 로고 다운로드
_fetchLogoForApp();
}
}
@override
void didUpdateWidget(WebsiteIcon oldWidget) {
super.didUpdateWidget(oldWidget);
// 서비스명이나 URL이 변경된 경우에만 다시 로드
final currentServiceKey = _serviceKey;
if (_previousServiceKey != currentServiceKey) {
print('서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey');
_previousServiceKey = currentServiceKey;
// 로드 시작 시간 기록
_loadStartTime = DateTime.now();
// 변경된 서비스 정보로 파비콘 로드
_loadFaviconWithCache();
}
// 호버 상태 변경 처리
if (widget.isHovered != oldWidget.isHovered) {
if (widget.isHovered) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
// 서비스 이름에서 초기 문자 추출
String _getInitials() {
if (widget.serviceName.isEmpty) return '?';
final words = widget.serviceName.split(' ');
if (words.length == 1) {
return words[0][0].toUpperCase();
} else if (words.length > 1) {
return (words[0][0] + words[1][0]).toUpperCase();
} else {
return widget.serviceName[0].toUpperCase();
}
}
// 서비스 이름을 기반으로 색상 선택
Color _getColorFromName() {
final int hash = widget.serviceName.hashCode.abs();
final List<Color> colors = [
AppColors.primaryColor,
AppColors.successColor,
AppColors.infoColor,
AppColors.warningColor,
AppColors.dangerColor,
];
return colors[hash % colors.length];
}
// 도메인 추출 메서드
String? _extractDomain() {
if (widget.url == null || widget.url!.isEmpty) return null;
// URL 형식 처리 개선
String processedUrl = widget.url!;
// URL에 http:// 또는 https:// 접두사가 없는 경우 추가
if (!processedUrl.startsWith('http://') &&
!processedUrl.startsWith('https://')) {
processedUrl = 'https://$processedUrl';
}
try {
final uri = Uri.parse(processedUrl);
if (uri.host.isEmpty) return processedUrl; // 파싱 실패 시 원본 URL 반환
return uri.host;
} catch (e) {
// URL 파싱 실패 시 도메인만 있는 경우를 처리
if (processedUrl.contains('.')) {
// 간단한 도메인 형식 검사 (예: netflix.com)
final domainPattern = RegExp(
r'^(https?:\/\/)?(www\.)?([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)',
);
final match = domainPattern.firstMatch(processedUrl);
if (match != null && match.group(3) != null) {
return match.group(3);
}
}
return null;
}
}
// 웹 환경용 파비콘 가져오기
Future<void> _fetchLogoForWeb() async {
try {
final domain = _extractDomain();
if (domain == null) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
FaviconCache.cancelLoading(_serviceKey);
return;
}
// 로딩 시작 시간 기록
final loadStartTime = DateTime.now();
// 1. DuckDuckGo Favicon API 시도 (CORS 친화적)
final ddgFaviconUrl =
GoogleFaviconService.getDuckDuckGoFaviconUrl(domain);
try {
// 이미지 존재 여부 확인을 위한 HEAD 요청
final response = await http.head(Uri.parse(ddgFaviconUrl));
if (response.statusCode == 200) {
// DuckDuckGo로부터 파비콘을 성공적으로 가져옴
if (mounted) {
setState(() {
_logoUrl = ddgFaviconUrl;
_isLoading = false;
});
}
// 캐시에 저장
FaviconCache.saveToMemory(_serviceKey, ddgFaviconUrl);
FaviconCache.saveToPrefs(_serviceKey, ddgFaviconUrl);
FaviconCache.cancelLoading(_serviceKey);
return;
}
} catch (e) {
print('DuckDuckGo 파비콘 API 요청 실패: $e');
// 실패 시 백업 방법으로 진행
}
// 2. DuckDuckGo API가 실패하면 Base64 인코딩된 SVG 이미지를 로고로 사용 (CORS 회피)
final color = _getColorFromName();
final base64Logo = GoogleFaviconService.getBase64PlaceholderIcon(
widget.serviceName, color);
// 최소 로딩 시간 보장 (깜박임 방지)
final processingTime =
DateTime.now().difference(loadStartTime).inMilliseconds;
if (processingTime < 300) {
await Future.delayed(Duration(milliseconds: 300 - processingTime));
}
if (mounted) {
setState(() {
_logoUrl = base64Logo;
_isLoading = false;
});
// 캐시에 저장
FaviconCache.saveToMemory(_serviceKey, base64Logo);
FaviconCache.saveToPrefs(_serviceKey, base64Logo);
}
FaviconCache.cancelLoading(_serviceKey);
} catch (e) {
print('웹용 파비콘 가져오기 오류: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
FaviconCache.cancelLoading(_serviceKey);
}
}
// 앱 환경용 파비콘 다운로드 및 로컬 저장
Future<void> _fetchLogoForApp() async {
try {
final domain = _extractDomain();
if (domain == null) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
FaviconCache.cancelLoading(_serviceKey);
return;
}
// 로딩 시작 시간 기록
final loadStartTime = DateTime.now();
// 1. Google API를 통해 파비콘 URL 생성
final faviconUrl =
GoogleFaviconService.getFaviconUrl(domain, widget.size.toInt() * 2);
// 2. http.get()으로 이미지 다운로드
final response = await http.get(Uri.parse(faviconUrl));
if (response.statusCode != 200) {
throw Exception('파비콘 다운로드 실패: ${response.statusCode}');
}
// 3. Uint8List로 변환
final Uint8List imageBytes = response.bodyBytes;
// 4. 고유한 파일명 생성 (서비스명 + URL의 해시)
final String hash =
md5.convert(utf8.encode(_serviceKey)).toString().substring(0, 8);
final String fileName =
'favicon_${widget.serviceName.replaceAll(' ', '_')}_$hash.png';
// 5. 임시 디렉토리 가져오기
final appDir = await getApplicationDocumentsDirectory();
final faviconDir = Directory('${appDir.path}/favicons');
if (!await faviconDir.exists()) {
await faviconDir.create(recursive: true);
}
// 6. PNG 파일로 저장
final File file = File('${faviconDir.path}/$fileName');
await file.writeAsBytes(imageBytes);
final String localFilePath = file.path;
// 최소 로딩 시간 보장 (깜박임 방지)
final processingTime =
DateTime.now().difference(loadStartTime).inMilliseconds;
if (processingTime < 300) {
await Future.delayed(Duration(milliseconds: 300 - processingTime));
}
if (mounted) {
setState(() {
_localLogoPath = localFilePath;
_isLoading = false;
});
// 로컬 파일 경로 캐시에 저장
FaviconCache.saveLocalFaviconPath(_serviceKey, localFilePath);
}
FaviconCache.cancelLoading(_serviceKey);
} catch (e) {
print('앱용 파비콘 다운로드 오류: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
FaviconCache.cancelLoading(_serviceKey);
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child,
);
},
child: AnimatedContainer(
key: ValueKey(
'icon_container_${widget.serviceName}_${widget.url ?? ''}_$_uniqueId'),
duration: const Duration(milliseconds: 200),
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.size * 0.2),
boxShadow: widget.isHovered
? [
BoxShadow(
color: _getColorFromName().withAlpha(76), // 약 0.3 알파값
blurRadius: 12,
spreadRadius: 0,
offset: const Offset(0, 4),
)
]
: null,
),
child: _buildIconContent(),
),
);
}
Widget _buildIconContent() {
// 로딩 중 표시
if (_isLoading) {
return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all(
color: AppColors.borderColor,
width: 0.5,
),
),
child: Center(
child: SizedBox(
width: widget.size * 0.4,
height: widget.size * 0.4,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withAlpha(179)),
),
),
),
);
}
if (kIsWeb) {
// 웹 환경: 파비콘 API에서 가져온 이미지 또는 Base64 인코딩된 이미지 사용
if (_logoUrl != null) {
if (_logoUrl!.startsWith('data:image/svg+xml;base64')) {
// Base64 인코딩된 SVG 이미지인 경우
return ClipRRect(
key: ValueKey('web_svg_logo_${_logoUrl!.hashCode}'),
borderRadius: BorderRadius.circular(widget.size * 0.2),
child: SvgPicture.string(
utf8.decode(base64.decode(_logoUrl!.split(',')[1])),
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
),
);
} else {
// DuckDuckGo나 다른 파비콘 서비스에서 가져온 URL인 경우
return ClipRRect(
key: ValueKey('web_url_logo_${_logoUrl!.hashCode}'),
borderRadius: BorderRadius.circular(widget.size * 0.2),
child: CachedNetworkImage(
imageUrl: _logoUrl!,
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: AppColors.surfaceColorAlt,
child: Center(
child: SizedBox(
width: widget.size * 0.4,
height: widget.size * 0.4,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withAlpha(179)),
),
),
),
),
errorWidget: (context, url, error) => _buildFallbackIcon(),
),
);
}
}
return _buildFallbackIcon();
} else {
// 앱 환경: 로컬 파일 표시
if (_localLogoPath == null) {
return _buildFallbackIcon();
}
return ClipRRect(
key: ValueKey('local_logo_${_localLogoPath}'),
borderRadius: BorderRadius.circular(widget.size * 0.2),
child: Image.file(
File(_localLogoPath!),
width: widget.size,
height: widget.size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildFallbackIcon();
},
),
);
}
}
Widget _buildFallbackIcon() {
final color = _getColorFromName();
return Container(
key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color,
color.withAlpha(204), // 약 0.8 알파값
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(widget.size * 0.2),
),
child: Center(
child: Text(
_getInitials(),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: widget.size * 0.4,
),
),
),
);
}
}