import 'dart:convert'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/util/string_extension.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:string_validator/string_validator.dart'; /// This widget handles the downloading and caching of either internal or network images. /// It will append the access token to the URL if the URL is internal. class FlowyNetworkImage extends StatefulWidget { const FlowyNetworkImage({ super.key, this.userProfilePB, this.width, this.height, this.fit = BoxFit.cover, this.progressIndicatorBuilder, this.errorWidgetBuilder, required this.url, this.maxRetries = 5, this.retryDuration = const Duration(seconds: 6), this.retryErrorCodes = const {404}, this.onImageLoaded, }); /// The URL of the image. final String url; /// The width of the image. final double? width; /// The height of the image. final double? height; /// The fit of the image. final BoxFit fit; /// The user profile. /// /// If the userProfilePB is not null, the image will be downloaded with the access token. final UserProfilePB? userProfilePB; /// The progress indicator builder. final ProgressIndicatorBuilder? progressIndicatorBuilder; /// The error widget builder. final LoadingErrorWidgetBuilder? errorWidgetBuilder; /// Retry loading the image if it fails. final int maxRetries; /// Retry duration final Duration retryDuration; /// Retry error codes. final Set retryErrorCodes; final void Function(bool isImageInCache)? onImageLoaded; @override FlowyNetworkImageState createState() => FlowyNetworkImageState(); } class FlowyNetworkImageState extends State { final manager = CustomImageCacheManager(); final retryCounter = FlowyNetworkRetryCounter(); // This is used to clear the retry count when the widget is disposed in case of the url is the same. String? retryTag; @override void initState() { super.initState(); assert(isURL(widget.url)); if (widget.url.isAppFlowyCloudUrl) { assert( widget.userProfilePB != null && widget.userProfilePB!.token.isNotEmpty, ); } retryTag = retryCounter.add(widget.url); manager.getFileFromCache(widget.url).then((file) { widget.onImageLoaded?.call( file != null && file.file.path.isNotEmpty && file.originalUrl == widget.url, ); }); } @override void reassemble() { super.reassemble(); if (retryTag != null) { retryCounter.clear( tag: retryTag!, url: widget.url, maxRetries: widget.maxRetries, ); } } @override void dispose() { if (retryTag != null) { retryCounter.clear( tag: retryTag!, url: widget.url, maxRetries: widget.maxRetries, ); } super.dispose(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: retryCounter, builder: (context, child) { final retryCount = retryCounter.getRetryCount(widget.url); return CachedNetworkImage( key: ValueKey('${widget.url}_$retryCount'), cacheManager: manager, httpHeaders: _buildRequestHeader(), imageUrl: widget.url, fit: widget.fit, width: widget.width, height: widget.height, progressIndicatorBuilder: widget.progressIndicatorBuilder, errorWidget: _errorWidgetBuilder, errorListener: (value) async { Log.error( 'Unable to load image: ${value.toString()} - retryCount: $retryCount', ); // clear the cache and retry await manager.removeFile(widget.url); _retryLoadImage(); }, ); }, ); } /// if the error is 404 and the retry count is less than the max retries, it return a loading indicator. Widget _errorWidgetBuilder(BuildContext context, String url, Object error) { final retryCount = retryCounter.getRetryCount(url); if (error is HttpExceptionWithStatus) { if (widget.retryErrorCodes.contains(error.statusCode) && retryCount < widget.maxRetries) { final fakeDownloadProgress = DownloadProgress(url, null, 0); return widget.progressIndicatorBuilder?.call( context, url, fakeDownloadProgress, ) ?? const Center( child: _SensitiveContent(), ); } if (error.statusCode == 422) { // Unprocessable Entity: Used when the server understands the request but cannot process it due to //semantic issues (e.g., sensitive keywords). return const _SensitiveContent(); } } return widget.errorWidgetBuilder?.call(context, url, error) ?? const SizedBox.shrink(); } Map _buildRequestHeader() { final header = {}; final token = widget.userProfilePB?.token; if (token != null) { try { final decodedToken = jsonDecode(token); header['Authorization'] = 'Bearer ${decodedToken['access_token']}'; } catch (e) { Log.error('Unable to decode token: $e'); } } return header; } void _retryLoadImage() { final retryCount = retryCounter.getRetryCount(widget.url); if (retryCount < widget.maxRetries) { Future.delayed(widget.retryDuration, () { Log.debug( 'Retry load image: ${widget.url}, retry count: $retryCount', ); // Increment the retry count for the URL to trigger the image rebuild. retryCounter.increment(widget.url); }); } } } /// This class is used to count the number of retries for a given URL. @visibleForTesting class FlowyNetworkRetryCounter with ChangeNotifier { FlowyNetworkRetryCounter._(); factory FlowyNetworkRetryCounter() => _instance; static final _instance = FlowyNetworkRetryCounter._(); final Map _values = {}; Map get values => {..._values}; /// Get the retry count for a given URL. int getRetryCount(String url) => _values[url] ?? 0; /// Add a new URL to the retry counter. Don't call notifyListeners() here. /// /// This function will return a tag, use it to clear the retry count. /// Because the url may be the same, we need to add a unique tag to the url. String add(String url) { _values.putIfAbsent(url, () => 0); return url + uuid(); } /// Increment the retry count for a given URL. void increment(String url) { final count = _values[url]; if (count == null) { _values[url] = 1; } else { _values[url] = count + 1; } notifyListeners(); } /// Clear the retry count for a given tag. void clear({ required String tag, required String url, int? maxRetries, }) { _values.remove(tag); final retryCount = _values[url]; if (maxRetries == null || (retryCount != null && retryCount >= maxRetries)) { _values.remove(url); } } /// Reset the retry counter. void reset() { _values.clear(); } } class _SensitiveContent extends StatelessWidget { const _SensitiveContent(); @override Widget build(BuildContext context) { return FlowyText(LocaleKeys.ai_contentPolicyViolation.tr()); } }