Files
submanager/lib/widgets/website_icon.dart
2025-09-16 14:30:14 +09:00

737 lines
23 KiB
Dart

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;
import '../utils/reduce_motion.dart';
// 파비콘 캐시 관리 클래스
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) {
// 파비콘 캐시 로드 오류
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) {
// 파비콘 캐시 저장 오류
}
}
// 파비콘 캐시 삭제
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) {
// 파비콘 캐시 삭제 오류
}
}
// 앱에서 로컬 파비콘 파일 경로 가져오기
static Future<String?> getLocalFaviconPath(String serviceKey) async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('local_favicon_$serviceKey');
} catch (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) {
// 로컬 파비콘 경로 저장 오류
}
}
}
// 구글 파비콘 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<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.toARGB32().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;
// 각 인스턴스에 대한 고유 식별자 추가
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: ReduceMotion.platform()
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
);
_scaleAnimation =
Tween<double>(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08)
.animate(CurvedAnimation(
parent: _animationController, curve: Curves.easeOutCubic));
// 초기 _previousServiceKey 설정
_previousServiceKey = _serviceKey;
// 최초 로딩
_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(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<Color> colors = [
const Color(0xFF2563EB), // primary
const Color(0xFF22C55E), // success
const Color(0xFF6366F1), // info
const Color(0xFFF59E0B), // warning
const Color(0xFFF472B6), // accent/danger
];
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) {
// 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<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) {
// 앱용 파비콘 다운로드 오류
if (mounted) {
setState(() {
_isLoading = false;
});
}
FaviconCache.cancelLoading(_serviceKey);
}
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
final scale =
ReduceMotion.isEnabled(context) ? 1.0 : _scaleAnimation.value;
return Transform.scale(
scale: scale,
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) {
if (ReduceMotion.isEnabled(context)) {
return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.5,
),
),
);
}
return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 0.5,
),
),
child: Center(
child: SizedBox(
width: widget.size * 0.4,
height: widget.size * 0.4,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary.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,
fadeInDuration: ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
fadeOutDuration: ReduceMotion.isEnabled(context)
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
placeholder: (context, url) {
if (ReduceMotion.isEnabled(context)) {
return Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest);
}
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: SizedBox(
width: widget.size * 0.4,
height: widget.size * 0.4,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context)
.colorScheme
.primary
.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(
color: color,
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,
),
),
),
);
}
}