mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-08-01 21:37:38 +00:00

* fix: retry count should be clear if the value exceeds max retries * feat: add retry button when loading image failed
277 lines
7.7 KiB
Dart
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());
|
|
}
|
|
}
|