Files
submanager/lib/widgets/cached_network_image_widget.dart
JiWoong Sul 4731288622 Major UI/UX and architecture improvements
- Implemented new navigation system with NavigationProvider and route management
- Added adaptive theme system with ThemeProvider for better theme handling
- Introduced glassmorphism design elements (app bars, scaffolds, cards)
- Added advanced animations (spring animations, page transitions, staggered lists)
- Implemented performance optimizations (memory manager, lazy loading)
- Refactored Analysis screen into modular components
- Added floating navigation bar with haptic feedback
- Improved subscription cards with swipe actions
- Enhanced skeleton loading with better animations
- Added cached network image support
- Improved overall app architecture and code organization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-10 18:36:57 +09:00

315 lines
8.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../theme/app_colors.dart';
import 'skeleton_loading.dart';
/// 최적화된 캐시 네트워크 이미지 위젯
class OptimizedCachedNetworkImage extends StatelessWidget {
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
final Duration fadeInDuration;
final Duration fadeOutDuration;
final Widget? placeholder;
final Widget? errorWidget;
final Map<String, String>? httpHeaders;
final bool enableMemoryCache;
final bool enableDiskCache;
final int? maxWidth;
final int? maxHeight;
const OptimizedCachedNetworkImage({
super.key,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
this.fadeInDuration = const Duration(milliseconds: 300),
this.fadeOutDuration = const Duration(milliseconds: 300),
this.placeholder,
this.errorWidget,
this.httpHeaders,
this.enableMemoryCache = true,
this.enableDiskCache = true,
this.maxWidth,
this.maxHeight,
});
@override
Widget build(BuildContext context) {
// 성능 최적화를 위한 이미지 크기 계산
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final optimalWidth = maxWidth ??
(width != null ? (width! * devicePixelRatio).round() : null);
final optimalHeight = maxHeight ??
(height != null ? (height! * devicePixelRatio).round() : null);
Widget image = CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
fadeInDuration: fadeInDuration,
fadeOutDuration: fadeOutDuration,
httpHeaders: httpHeaders,
memCacheWidth: optimalWidth,
memCacheHeight: optimalHeight,
maxWidthDiskCache: optimalWidth,
maxHeightDiskCache: optimalHeight,
placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(),
errorWidget: (context, url, error) =>
errorWidget ?? _buildDefaultErrorWidget(),
imageBuilder: (context, imageProvider) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: borderRadius,
image: DecorationImage(
image: imageProvider,
fit: fit,
),
),
);
},
);
if (borderRadius != null) {
return ClipRRect(
borderRadius: borderRadius!,
child: image,
);
}
return image;
}
Widget _buildDefaultPlaceholder() {
return SkeletonLoading(
width: width,
height: height,
borderRadius: borderRadius?.topLeft.x ?? 0,
);
}
Widget _buildDefaultErrorWidget() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: borderRadius,
),
child: const Icon(
Icons.broken_image_outlined,
color: AppColors.textMuted,
size: 24,
),
);
}
}
/// 프로그레시브 이미지 로더 (저화질 → 고화질)
class ProgressiveNetworkImage extends StatelessWidget {
final String thumbnailUrl;
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
const ProgressiveNetworkImage({
super.key,
required this.thumbnailUrl,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.passthrough,
children: [
// 썸네일 (저화질)
OptimizedCachedNetworkImage(
imageUrl: thumbnailUrl,
width: width,
height: height,
fit: fit,
borderRadius: borderRadius,
fadeInDuration: Duration.zero,
),
// 원본 이미지 (고화질)
OptimizedCachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
borderRadius: borderRadius,
),
],
);
}
}
/// 이미지 갤러리 위젯 (메모리 효율적)
class OptimizedImageGallery extends StatefulWidget {
final List<String> imageUrls;
final double itemHeight;
final double spacing;
final int crossAxisCount;
final void Function(int)? onImageTap;
const OptimizedImageGallery({
super.key,
required this.imageUrls,
this.itemHeight = 120,
this.spacing = 8,
this.crossAxisCount = 3,
this.onImageTap,
});
@override
State<OptimizedImageGallery> createState() => _OptimizedImageGalleryState();
}
class _OptimizedImageGalleryState extends State<OptimizedImageGallery> {
final ScrollController _scrollController = ScrollController();
final Set<int> _visibleIndices = {};
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// 초기 보이는 아이템 계산
WidgetsBinding.instance.addPostFrameCallback((_) {
_calculateVisibleIndices();
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
_calculateVisibleIndices();
}
void _calculateVisibleIndices() {
if (!mounted) return;
final viewportHeight = context.size?.height ?? 0;
final scrollOffset = _scrollController.offset;
final itemHeight = widget.itemHeight + widget.spacing;
final itemsPerRow = widget.crossAxisCount;
final firstVisibleRow = (scrollOffset / itemHeight).floor();
final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil();
final newVisibleIndices = <int>{};
for (int row = firstVisibleRow; row <= lastVisibleRow; row++) {
for (int col = 0; col < itemsPerRow; col++) {
final index = row * itemsPerRow + col;
if (index < widget.imageUrls.length) {
newVisibleIndices.add(index);
}
}
}
if (!setEquals(_visibleIndices, newVisibleIndices)) {
setState(() {
_visibleIndices.clear();
_visibleIndices.addAll(newVisibleIndices);
});
}
}
@override
Widget build(BuildContext context) {
return GridView.builder(
controller: _scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
childAspectRatio: 1.0,
crossAxisSpacing: widget.spacing,
mainAxisSpacing: widget.spacing,
),
itemCount: widget.imageUrls.length,
itemBuilder: (context, index) {
// 보이는 영역의 이미지만 로드
if (_visibleIndices.contains(index) ||
(index >= _visibleIndices.first - widget.crossAxisCount &&
index <= _visibleIndices.last + widget.crossAxisCount)) {
return GestureDetector(
onTap: () => widget.onImageTap?.call(index),
child: OptimizedCachedNetworkImage(
imageUrl: widget.imageUrls[index],
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(8),
),
);
}
// 보이지 않는 영역은 플레이스홀더
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(8),
),
);
},
);
}
bool setEquals(Set<int> a, Set<int> b) {
if (a.length != b.length) return false;
for (final item in a) {
if (!b.contains(item)) return false;
}
return true;
}
}
/// 히어로 애니메이션이 적용된 이미지
class HeroNetworkImage extends StatelessWidget {
final String imageUrl;
final String heroTag;
final double? width;
final double? height;
final BoxFit fit;
final VoidCallback? onTap;
const HeroNetworkImage({
super.key,
required this.imageUrl,
required this.heroTag,
this.width,
this.height,
this.fit = BoxFit.cover,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Hero(
tag: heroTag,
child: OptimizedCachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
),
),
);
}
}