- 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>
315 lines
8.3 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |