707 lines
22 KiB
Dart
707 lines
22 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;
|
|
|
|
// 파비콘 캐시 관리 클래스
|
|
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: const Duration(milliseconds: 300),
|
|
);
|
|
|
|
_scaleAnimation = Tween<double>(begin: 1.0, end: 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 = [
|
|
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) {
|
|
// 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 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<Color>(
|
|
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<Color>(
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|