// Author: fruit.com.cn // 职责:统一网络图片入口,补服务地址,并在加载中/失败时显示纯色占位块。 import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.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.fallbackAspectRatio, this.fallbackColor = AppColors.raised, this.fallbackWidget, this.decodeWidth, this.decodeHeight, }); final String path; final double? width; final double? height; final BoxFit fit; 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(); 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(), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) return child; return _fallback(); }, ); }, ); } Widget _fallback() { final custom = fallbackWidget; final content = custom != null ? SizedBox(width: width, height: height, child: custom) : Container(width: width, height: height, color: fallbackColor); final ratio = fallbackAspectRatio; if (ratio == null) return content; return AspectRatio(aspectRatio: ratio, child: content); } }