// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.dart'; import 'image_placeholder_shimmer.dart'; const int _minImageDecodePixels = 96; const int _maxImageDecodePixels = 2048; /// 将布局逻辑尺寸换算成有限像素尺寸,兼顾小头像和全屏首帧。 int resolveImageDecodePixels(double? logicalExtent, double devicePixelRatio) { final ratio = devicePixelRatio.isFinite && devicePixelRatio > 0 ? devicePixelRatio : 1.0; final extent = logicalExtent != null && logicalExtent.isFinite && logicalExtent > 0 ? logicalExtent : 120.0; return (extent * ratio) .ceil() .clamp(_minImageDecodePixels, _maxImageDecodePixels) .toInt(); } /// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。 class RemoteImage extends StatelessWidget { const RemoteImage( this.path, { super.key, this.width, this.height, this.fit = BoxFit.cover, this.showFallbackIcon = false, this.fallbackIconKey, this.fallbackAspectRatio, this.fallbackColor = AppColors.raised, this.fallbackWidget, this.decodeWidth, this.decodeHeight, }); final String path; final double? width; final double? height; final BoxFit fit; final bool showFallbackIcon; final Key? fallbackIconKey; final double? fallbackAspectRatio; final Color fallbackColor; /// 自定义兜底:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。 final Widget? fallbackWidget; final int? decodeWidth; final int? decodeHeight; @override Widget build(BuildContext context) { final url = AppConfig.mediaUrl(path); if (url.isEmpty) return _fallback(brand: showFallbackIcon); return LayoutBuilder( builder: (context, constraints) { final pixelRatio = MediaQuery.devicePixelRatioOf(context); final layoutWidth = constraints.hasBoundedWidth ? constraints.maxWidth : width; final aspectRatio = fallbackAspectRatio != null && fallbackAspectRatio!.isFinite && fallbackAspectRatio! > 0 ? fallbackAspectRatio : null; final inferredHeight = constraints.hasBoundedHeight ? constraints.maxHeight : height ?? (layoutWidth != null && aspectRatio != null ? layoutWidth / aspectRatio : null); final cacheWidth = decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio); // cover/contain 本身会保持宽高比,此时若同时强制 cacheWidth 和 // cacheHeight,解码会把竖版图强行拉成横版像素。只在显式指定了 // decodeHeight、或 fit 不保持宽高比时才限定解码高度。 final keepsAspectRatio = fit == BoxFit.cover || fit == BoxFit.contain; final cacheHeight = decodeHeight ?? (keepsAspectRatio || inferredHeight == null ? null : resolveImageDecodePixels(inferredHeight, pixelRatio)); return Image.network( url, width: width, height: height, fit: fit, cacheWidth: cacheWidth .clamp(_minImageDecodePixels, _maxImageDecodePixels) .toInt(), cacheHeight: cacheHeight ?.clamp(_minImageDecodePixels, _maxImageDecodePixels) .toInt(), gaplessPlayback: true, errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) return child; return _loadingFallback(); }, ); }, ); } Widget _loadingFallback() { final custom = fallbackWidget; if (custom != null) return custom; return ImagePlaceholderShimmer(child: _fallback(brand: showFallbackIcon)); } Widget _fallback({required bool brand}) { final custom = fallbackWidget; if (custom != null) return custom; final mark = brand ? LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth.isFinite ? constraints.maxWidth : 120.0; return Center( child: Image.asset( 'assets/icon/splash_logo.png', key: fallbackIconKey, width: width * 0.72, fit: BoxFit.contain, ), ); }, ) : null; final fallback = Container( width: width, height: height, color: brand ? AppColors.mediaBlack : fallbackColor, alignment: Alignment.center, child: mark, ); final ratio = fallbackAspectRatio; if (ratio == null) return fallback; return AspectRatio(aspectRatio: ratio, child: fallback); } }