diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart index f3f5000157..e6c4b33472 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart @@ -255,14 +255,17 @@ class CustomImageBlockComponentState extends State final url = node.attributes[CustomImageBlockKeys.url]; return Stack( children: [ - BlockSelectionContainer( - node: node, - delegate: this, - listenable: editorState.selectionNotifier, - cursorColor: editorState.editorStyle.cursorColor, - selectionColor: editorState.editorStyle.selectionColor, - child: child!, - ), + editorState.editable + ? BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: + editorState.editorStyle.selectionColor, + child: child!, + ) + : child!, if (value && url.isNotEmpty == true) widget.menuBuilder!(widget.node, this, imageStateNotifier), ], diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart index 7551947bce..021476aa4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart @@ -2,12 +2,14 @@ import 'dart:io'; import 'dart:math'; import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -125,6 +127,12 @@ class _ResizableImageState extends State { return _ImageLoadFailedWidget( width: imageWidth, error: error, + onRetry: () { + setState(() { + final retryCounter = FlowyNetworkRetryCounter(); + retryCounter.clear(tag: src, url: src); + }); + }, ); }, ); @@ -236,19 +244,24 @@ class _ResizableImageState extends State { } class _ImageLoadFailedWidget extends StatelessWidget { - const _ImageLoadFailedWidget({required this.width, required this.error}); + const _ImageLoadFailedWidget({ + required this.width, + required this.error, + required this.onRetry, + }); final double width; final Object error; + final VoidCallback onRetry; @override Widget build(BuildContext context) { final error = _getErrorMessage(); return Container( - height: 140, + height: 160, width: width, alignment: Alignment.center, - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + padding: const EdgeInsets.symmetric(vertical: 8.0), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4.0)), border: Border.all(color: Colors.grey.withOpacity(0.6)), @@ -258,10 +271,13 @@ class _ImageLoadFailedWidget extends StatelessWidget { children: [ const FlowySvg( FlowySvgs.broken_image_xl, - size: Size.square(48), + size: Size.square(36), ), - FlowyText(AppFlowyEditorL10n.current.imageLoadFailed), - const VSpace(6), + FlowyText( + AppFlowyEditorL10n.current.imageLoadFailed, + fontSize: 14, + ), + const VSpace(4), if (error != null) FlowyText( error, @@ -270,6 +286,11 @@ class _ImageLoadFailedWidget extends StatelessWidget { fontSize: 10, maxLines: 2, ), + const VSpace(12), + OutlinedRoundedButton( + text: LocaleKeys.chat_retry.tr(), + onTap: onRetry, + ), ], ), ); diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart index 8461380012..090db27ddc 100644 --- a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -71,7 +71,7 @@ class FlowyNetworkImage extends StatefulWidget { class FlowyNetworkImageState extends State { final manager = CustomImageCacheManager(); - final retryCounter = _FlowyNetworkRetryCounter(); + 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; @@ -104,14 +104,22 @@ class FlowyNetworkImageState extends State { super.reassemble(); if (retryTag != null) { - retryCounter.clear(retryTag!); + retryCounter.clear( + tag: retryTag!, + url: widget.url, + maxRetries: widget.maxRetries, + ); } } @override void dispose() { if (retryTag != null) { - retryCounter.clear(retryTag!); + retryCounter.clear( + tag: retryTag!, + url: widget.url, + maxRetries: widget.maxRetries, + ); } super.dispose(); @@ -204,11 +212,12 @@ class FlowyNetworkImageState extends State { } /// This class is used to count the number of retries for a given URL. -class _FlowyNetworkRetryCounter with ChangeNotifier { - _FlowyNetworkRetryCounter._(); +@visibleForTesting +class FlowyNetworkRetryCounter with ChangeNotifier { + FlowyNetworkRetryCounter._(); - factory _FlowyNetworkRetryCounter() => _instance; - static final _instance = _FlowyNetworkRetryCounter._(); + factory FlowyNetworkRetryCounter() => _instance; + static final _instance = FlowyNetworkRetryCounter._(); final Map _values = {}; Map get values => {..._values}; @@ -236,9 +245,19 @@ class _FlowyNetworkRetryCounter with ChangeNotifier { notifyListeners(); } - /// Clear the retry count for a given URL. - void clear(String tag) { + /// 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. diff --git a/frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart b/frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart new file mode 100644 index 0000000000..3c075126db --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/image/appflowy_network_image_test.dart @@ -0,0 +1,35 @@ +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AppFlowy Network Image:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test( + 'retry count should be clear if the value exceeds max retries', + () async { + const maxRetries = 5; + const fakeUrl = 'https://plus.unsplash.com/premium_photo-1731948132439'; + final retryCounter = FlowyNetworkRetryCounter(); + final tag = retryCounter.add(fakeUrl); + for (var i = 0; i < maxRetries; i++) { + retryCounter.increment(fakeUrl); + expect(retryCounter.getRetryCount(fakeUrl), i + 1); + } + retryCounter.clear( + tag: tag, + url: fakeUrl, + maxRetries: maxRetries, + ); + expect(retryCounter.getRetryCount(fakeUrl), 0); + }, + ); + }); +}