import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import '../theme/app_colors.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 _memoryCache = {}; // 로딩 상태 추적 (동시에 동일한 URL에 대한 중복 요청 방지) static final Set _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 getFromPrefs(String serviceKey) async { try { final prefs = await SharedPreferences.getInstance(); return prefs.getString('favicon_$serviceKey'); } catch (e) { // 파비콘 캐시 로드 오류 return null; } } // SharedPreferences에 파비콘 URL 저장 static Future saveToPrefs(String serviceKey, String logoUrl) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('favicon_$serviceKey', logoUrl); } catch (e) { // 파비콘 캐시 저장 오류 } } // 파비콘 캐시 삭제 static Future remove(String serviceKey) async { _memoryCache.remove(serviceKey); _loadingKeys.remove(serviceKey); try { final prefs = await SharedPreferences.getInstance(); await prefs.remove('favicon_$serviceKey'); } catch (e) { // 파비콘 캐시 삭제 오류 } } // 앱에서 로컬 파비콘 파일 경로 가져오기 static Future getLocalFaviconPath(String serviceKey) async { try { final prefs = await SharedPreferences.getInstance(); return prefs.getString('local_favicon_$serviceKey'); } catch (e) { // 로컬 파비콘 경로 로드 오류 return null; } } // 앱에서 로컬 파비콘 파일 경로 저장 static Future saveLocalFaviconPath( String serviceKey, String filePath) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('local_favicon_$serviceKey', filePath); } catch (e) { // 로컬 파비콘 경로 저장 오류 } } } // 구글 파비콘 API 서비스 class GoogleFaviconService { // 구글 파비콘 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 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.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); // 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생) final svgContent = '' '' '$initial' ''; // 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 createState() => _WebsiteIconState(); } class _WebsiteIconState extends State with SingleTickerProviderStateMixin { String? _logoUrl; // 웹에서 사용할 로고 URL String? _localLogoPath; // 앱에서 사용할 로컬 파일 경로 bool _isLoading = true; late AnimationController _animationController; late Animation _scaleAnimation; // 각 인스턴스에 대한 고유 식별자 추가 final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString(); // 서비스와 URL 조합으로 캐시 키 생성 String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}'; // 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용) String? _previousServiceKey; @override void initState() { super.initState(); // 애니메이션 컨트롤러 초기화 _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _scaleAnimation = Tween(begin: 1.0, end: 1.08).animate( CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic)); // 초기 _previousServiceKey 설정 _previousServiceKey = _serviceKey; // 최초 로딩 _loadFaviconWithCache(); } // 캐시를 활용해 파비콘 로드 Future _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(const 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(const 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) { // 서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey _previousServiceKey = currentServiceKey; // 변경된 서비스 정보로 파비콘 로드 _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 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 _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) { // DuckDuckGo 파비콘 API 요청 실패 // 실패 시 백업 방법으로 진행 } // 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) { // 웹용 파비콘 가져오기 오류 if (mounted) { setState(() { _isLoading = false; }); } FaviconCache.cancelLoading(_serviceKey); } } // 앱 환경용 파비콘 다운로드 및 로컬 저장 Future _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) { // 앱용 파비콘 다운로드 오류 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().withValues(alpha: 0.3), // 약 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( AppColors.primaryColor.withValues(alpha: 0.7)), ), ), ), ); } 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( AppColors.primaryColor.withValues(alpha: 0.7)), ), ), ), ), 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.withValues(alpha: 0.8), // 약 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, ), ), ), ); } }