AppFlowy/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart
Lucas fc21d1d245
fix: retry count should be clear if the value exceeds max retries (#7195)
* fix: retry count should be clear if the value exceeds max retries

* feat: add retry button when loading image failed
2025-01-13 17:17:05 +08:00

277 lines
7.7 KiB
Dart

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<int> retryErrorCodes;
final void Function(bool isImageInCache)? onImageLoaded;
@override
FlowyNetworkImageState createState() => FlowyNetworkImageState();
}
class FlowyNetworkImageState extends State<FlowyNetworkImage> {
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<String, String> _buildRequestHeader() {
final header = <String, String>{};
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<String, int> _values = <String, int>{};
Map<String, int> 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());
}
}