diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 6522496dcaa..c205ab53292 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -332,14 +332,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_cache_manager: - dependency: transitive - description: - name: flutter_cache_manager - sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" - url: "https://pub.dev" - source: hosted - version: "3.3.1" flutter_driver: dependency: transitive description: flutter diff --git a/packages/neon/neon_dashboard/lib/src/pages/main.dart b/packages/neon/neon_dashboard/lib/src/pages/main.dart index 8a4ee02ce2c..f1aee42b960 100644 --- a/packages/neon/neon_dashboard/lib/src/pages/main.dart +++ b/packages/neon/neon_dashboard/lib/src/pages/main.dart @@ -342,7 +342,7 @@ class DashboardMainPage extends StatelessWidget { final colorFilter = ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn); if (widget.iconUrl.isNotEmpty) { - return NeonUrlImage( + return NeonUriImage( uri: Uri.parse(widget.iconUrl), svgColorFilter: colorFilter, size: const Size.square(largeIconSize), diff --git a/packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart b/packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart index cf8fd4b50f8..72ec6656e59 100644 --- a/packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart +++ b/packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart @@ -26,7 +26,7 @@ class DashboardWidgetItem extends StatelessWidget { child: NeonImageWrapper( borderRadius: roundIcon ? BorderRadius.circular(largeIconSize) : null, child: item.iconUrl.isNotEmpty - ? NeonUrlImage( + ? NeonUriImage( uri: Uri.parse(item.iconUrl), size: const Size.square(largeIconSize), ) @@ -48,7 +48,7 @@ class DashboardWidgetItem extends StatelessWidget { alignment: Alignment.bottomRight, child: SizedBox.square( dimension: smallIconSize, - child: NeonUrlImage( + child: NeonUriImage( uri: Uri.parse(overlayIconUrl), size: const Size.square(smallIconSize), ), diff --git a/packages/neon/neon_dashboard/test/widget_test.dart b/packages/neon/neon_dashboard/test/widget_test.dart index 7c694bd3486..dbb949c0301 100644 --- a/packages/neon/neon_dashboard/test/widget_test.dart +++ b/packages/neon/neon_dashboard/test/widget_test.dart @@ -30,8 +30,6 @@ Widget wrapWidget(AccountsBloc accountsBloc, Widget child) => MaterialApp( ); void main() { - NeonCachedImage.cacheManager = MockCacheManager(); - final accountsBloc = MockAccountsBloc(); when(() => accountsBloc.activeAccount).thenAnswer( (invocation) => BehaviorSubject.seeded( @@ -84,7 +82,7 @@ void main() { BorderRadius.circular(largeIconSize), ), ); - expect(find.byType(NeonCachedImage), findsNWidgets(2)); + expect(find.byType(NeonImage), findsNWidgets(2)); await expectLater(find.byType(DashboardWidgetItem), matchesGoldenFile('goldens/widget_item.png')); }); @@ -144,7 +142,7 @@ void main() { ), ); - expect(find.byType(NeonCachedImage), findsOneWidget); + expect(find.byType(NeonImage), findsOneWidget); }); }); @@ -293,7 +291,7 @@ void main() { BorderRadius.circular(largeIconSize), ), ); - expect(find.byType(NeonCachedImage), findsNWidgets(3)); + expect(find.byType(NeonImage), findsNWidgets(3)); expect(find.byType(DashboardWidgetItem), findsOneWidget); expect(find.bySubtype(), findsOneWidget); expect(find.byIcon(Icons.add), findsOneWidget); diff --git a/packages/neon/neon_files/lib/src/widgets/file_preview.dart b/packages/neon/neon_files/lib/src/widgets/file_preview.dart index 88abf74c736..0ca5679e600 100644 --- a/packages/neon/neon_files/lib/src/widgets/file_preview.dart +++ b/packages/neon/neon_files/lib/src/widgets/file_preview.dart @@ -101,19 +101,13 @@ class FilePreviewImage extends NeonApiImage { required int width, required int height, }) : super( - getImage: (client) async => client.core.preview.getPreview( + getImage: (client) => client.core.preview.getPreviewRaw( file: file.uri.path, x: width, y: height, ), - writeCache: (cacheManager, data) async { - await cacheManager.putFile( - cacheKey, - data, - maxAge: const Duration(days: 7), - eTag: file.etag, - ); - }, + etag: file.etag, + expires: null, isSvgHint: file.mimeType?.contains('svg') ?? false, ); } diff --git a/packages/neon/neon_news/lib/src/widgets/articles_view.dart b/packages/neon/neon_news/lib/src/widgets/articles_view.dart index fd8cf43025f..f6787391ba1 100644 --- a/packages/neon/neon_news/lib/src/widgets/articles_view.dart +++ b/packages/neon/neon_news/lib/src/widgets/articles_view.dart @@ -136,7 +136,7 @@ class _NewsArticlesViewState extends State { ), ), if (article.mediaThumbnail != null) ...[ - NeonUrlImage( + NeonUriImage( uri: Uri.parse(article.mediaThumbnail!), size: const Size(100, 50), fit: BoxFit.cover, diff --git a/packages/neon/neon_news/lib/src/widgets/feed_icon.dart b/packages/neon/neon_news/lib/src/widgets/feed_icon.dart index 1730ec0d231..f30c8ead670 100644 --- a/packages/neon/neon_news/lib/src/widgets/feed_icon.dart +++ b/packages/neon/neon_news/lib/src/widgets/feed_icon.dart @@ -23,7 +23,7 @@ class NewsFeedIcon extends StatelessWidget { size: Size.square(size), borderRadius: borderRadius, child: faviconLink != null && faviconLink.isNotEmpty - ? NeonUrlImage( + ? NeonUriImage( uri: Uri.parse(faviconLink), size: Size.square(size), ) diff --git a/packages/neon/neon_notifications/lib/src/pages/main.dart b/packages/neon/neon_notifications/lib/src/pages/main.dart index 75684f03a15..6c48ce4470b 100644 --- a/packages/neon/neon_notifications/lib/src/pages/main.dart +++ b/packages/neon/neon_notifications/lib/src/pages/main.dart @@ -92,7 +92,7 @@ class _NotificationsMainPageState extends State { ) : SizedBox.fromSize( size: const Size.square(largeIconSize), - child: NeonUrlImage( + child: NeonUriImage( uri: Uri.parse(notification.icon!), size: const Size.square(largeIconSize), svgColorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), diff --git a/packages/neon_framework/lib/src/testing/mocks.dart b/packages/neon_framework/lib/src/testing/mocks.dart index e57117bd73e..0801a3934d7 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -2,7 +2,6 @@ import 'dart:async'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; // ignore: depend_on_referenced_packages import 'package:mocktail/mocktail.dart'; import 'package:neon_framework/blocs.dart'; @@ -51,5 +50,3 @@ class MockSharedPreferences extends Mock implements SharedPreferences {} class MockCallbackFunction extends Mock { FutureOr call(); } - -class MockCacheManager extends Mock implements DefaultCacheManager {} diff --git a/packages/neon_framework/lib/src/utils/push_utils.dart b/packages/neon_framework/lib/src/utils/push_utils.dart index bdfee71c1e8..e07fb51d6c9 100644 --- a/packages/neon_framework/lib/src/utils/push_utils.dart +++ b/packages/neon_framework/lib/src/utils/push_utils.dart @@ -5,20 +5,22 @@ import 'dart:ui'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_svg/flutter_svg.dart' show vg; +import 'package:flutter_svg/flutter_svg.dart' show SvgBytesLoader, vg; import 'package:image/image.dart' as img; import 'package:meta/meta.dart'; +import 'package:neon_framework/src/bloc/result.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/push_notification.dart'; import 'package:neon_framework/src/settings/models/storage.dart'; import 'package:neon_framework/src/theme/colors.dart'; import 'package:neon_framework/src/utils/findable.dart'; +import 'package:neon_framework/src/utils/image_utils.dart'; import 'package:neon_framework/src/utils/localizations.dart'; -import 'package:neon_framework/src/utils/universal_svg_file_loader.dart'; +import 'package:neon_framework/src/utils/request_manager.dart'; import 'package:nextcloud/notifications.dart' as notifications; +import 'package:rxdart/rxdart.dart'; @internal @immutable @@ -118,27 +120,42 @@ class PushUtils { if (notification.icon?.endsWith('.svg') ?? false) { // Only SVG icons are supported right now (should be most of them) - final cacheManager = DefaultCacheManager(); - final file = await cacheManager.getSingleFile(notification.icon!); - - final pictureInfo = await vg.loadPicture(UniversalSvgFileLoader(file), null); - - const largeIconSize = 256; - final scale = largeIconSize / pictureInfo.size.longestSide; - final scaledSize = pictureInfo.size * scale; - - final recorder = PictureRecorder(); - Canvas(recorder) - ..scale(scale) - ..drawPicture(pictureInfo.picture) - ..drawColor(NcColors.primary, BlendMode.srcIn); - - pictureInfo.picture.dispose(); - - final image = recorder.endRecording().toImageSync(scaledSize.width.toInt(), scaledSize.height.toInt()); - final bytes = await image.toByteData(format: ImageByteFormat.png); - - largeIconBitmap = ByteArrayAndroidBitmap(img.encodeBmp(img.decodePng(bytes!.buffer.asUint8List())!)); + final uri = Uri.parse(notification.icon!); + final subject = BehaviorSubject>(); + await RequestManager.instance.wrapUri( + account: account, + uri: uri, + unwrap: (data) { + try { + return utf8.encode(ImageUtils.rewriteSvgDimensions(utf8.decode(data))); + } catch (_) {} + return data; + }, + subject: subject, + ); + final rawImage = subject.valueOrNull?.data; + unawaited(subject.close()); + + if (rawImage != null) { + final pictureInfo = await vg.loadPicture(SvgBytesLoader(rawImage), null); + + const largeIconSize = 256; + final scale = largeIconSize / pictureInfo.size.longestSide; + final scaledSize = pictureInfo.size * scale; + + final recorder = PictureRecorder(); + Canvas(recorder) + ..scale(scale) + ..drawPicture(pictureInfo.picture) + ..drawColor(NcColors.primary, BlendMode.srcIn); + + pictureInfo.picture.dispose(); + + final image = recorder.endRecording().toImageSync(scaledSize.width.toInt(), scaledSize.height.toInt()); + final bytes = await image.toByteData(format: ImageByteFormat.png); + + largeIconBitmap = ByteArrayAndroidBitmap(img.encodeBmp(img.decodePng(bytes!.buffer.asUint8List())!)); + } } } } catch (e, s) { diff --git a/packages/neon_framework/lib/src/utils/request_manager.dart b/packages/neon_framework/lib/src/utils/request_manager.dart index a81bbd5e46e..6b2419bc7a0 100644 --- a/packages/neon_framework/lib/src/utils/request_manager.dart +++ b/packages/neon_framework/lib/src/utils/request_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:built_value/serializer.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; @@ -131,6 +132,84 @@ class RequestManager { disableTimeout: disableTimeout, ); + /// Executes a HTTP request for binary content. + Future wrapBinary({ + required Account account, + required String cacheKey, + required AsyncValueGetter getCacheParameters, + required DynamiteRawResponse rawResponse, + required UnwrapCallback? unwrap, + required BehaviorSubject> subject, + bool disableTimeout = false, + }) async => + wrap>( + account: account, + cacheKey: cacheKey, + subject: subject, + request: () async { + await rawResponse.future; + return rawResponse; + }, + unwrap: (rawResponse) { + var data = rawResponse.response.body; + if (unwrap != null) { + data = unwrap(data); + } + + return data; + }, + serialize: (rawResponse) => base64.encode(rawResponse.response.body), + deserialize: (data) => DynamiteRawResponse>.fromJson( + { + 'statusCode': 200, + 'body': base64.decode(data), + 'headers': {}, + }, + bodyType: const FullType(Uint8List), + headersType: const FullType(Map, [FullType(String), FullType(String)]), + serializers: Serializers(), + ), + getCacheParameters: getCacheParameters, + disableTimeout: disableTimeout, + ); + + /// Executes a HTTP request for binary content using a simplified [uri] based approach. + Future wrapUri({ + required Account account, + required Uri uri, + required UnwrapCallback? unwrap, + required BehaviorSubject> subject, + }) { + final headers = account.getAuthorizationHeaders(uri); + + return wrapBinary( + account: account, + cacheKey: uri.toString(), + getCacheParameters: () async { + final response = await account.client.executeRawRequest( + 'HEAD', + uri, + headers: headers, + ); + + return CacheParameters.parseHeaders(response.headers); + }, + rawResponse: DynamiteRawResponse>( + response: account.client.executeRawRequest( + 'GET', + uri, + headers: headers, + validStatuses: const {200, 201}, + ), + bodyType: const FullType(Uint8List), + headersType: const FullType(Map, [FullType(String), FullType(String)]), + serializers: Serializers(), + ), + unwrap: unwrap, + subject: subject, + ); + } + /// Executes a generic request. /// /// This method is only meant to be used in testing. diff --git a/packages/neon_framework/lib/src/utils/universal_svg_file_loader.dart b/packages/neon_framework/lib/src/utils/universal_svg_file_loader.dart deleted file mode 100644 index b25c1e5aee2..00000000000 --- a/packages/neon_framework/lib/src/utils/universal_svg_file_loader.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:universal_io/io.dart'; - -/// A [BytesLoader] that decodes SVG data from a file in an isolate and creates -/// a vector_graphics binary representation. -/// -/// It has the same logic as [SvgFileLoader], but uses universal_io to also work on web. -class UniversalSvgFileLoader extends SvgLoader { - /// Creates a new universal SVG file loader. - const UniversalSvgFileLoader( - this.file, { - super.theme, - super.colorMapper, - }); - - /// The file containing the SVG data to decode and render. - final File file; - - @override - String provideSvg(void message) => utf8.decode(file.readAsBytesSync(), allowMalformed: true); - - @override - int get hashCode => Object.hash(file, theme, colorMapper); - - @override - bool operator ==(Object other) => - other is UniversalSvgFileLoader && other.file == file && other.theme == theme && other.colorMapper == colorMapper; -} diff --git a/packages/neon_framework/lib/src/widgets/custom_background.dart b/packages/neon_framework/lib/src/widgets/custom_background.dart index a695dd2abe2..e1f8c2a3d6f 100644 --- a/packages/neon_framework/lib/src/widgets/custom_background.dart +++ b/packages/neon_framework/lib/src/widgets/custom_background.dart @@ -51,7 +51,7 @@ class NeonCustomBackground extends StatelessWidget { return Stack( children: [ Positioned.fill( - child: NeonUrlImage( + child: NeonUriImage( uri: Uri.parse(theme.background), fit: BoxFit.cover, ), diff --git a/packages/neon_framework/lib/src/widgets/drawer.dart b/packages/neon_framework/lib/src/widgets/drawer.dart index ab78d7a353f..577c27d92ed 100644 --- a/packages/neon_framework/lib/src/widgets/drawer.dart +++ b/packages/neon_framework/lib/src/widgets/drawer.dart @@ -139,7 +139,7 @@ class NeonDrawerHeader extends StatelessWidget { ), ), Flexible( - child: NeonUrlImage( + child: NeonUriImage( uri: Uri.parse(theme.logo), ), ), diff --git a/packages/neon_framework/lib/src/widgets/image.dart b/packages/neon_framework/lib/src/widgets/image.dart index c0bbf23f650..c08ffbed2c0 100644 --- a/packages/neon_framework/lib/src/widgets/image.dart +++ b/packages/neon_framework/lib/src/widgets/image.dart @@ -3,213 +3,150 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:neon_framework/src/bloc/result.dart'; import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/utils/image_utils.dart'; import 'package:neon_framework/src/utils/provider.dart'; +import 'package:neon_framework/src/utils/request_manager.dart'; import 'package:neon_framework/src/widgets/error.dart'; import 'package:neon_framework/src/widgets/linear_progress_indicator.dart'; import 'package:nextcloud/nextcloud.dart'; - -/// The signature of a function reviving image data from the [cache]. -typedef CacheReviver = FutureOr Function(CacheManager cache); - -/// The signature of a function downloading image data. -typedef ImageDownloader = FutureOr Function(); - -/// The signature of a function writing [image] data to the [cache]. -typedef CacheWriter = Future Function(CacheManager cache, Uint8List image); +import 'package:rxdart/rxdart.dart'; /// The signature of a function building a widget displaying [error]. typedef ErrorWidgetBuilder = Widget? Function(BuildContext context, Object? error); /// The signature of a function downloading image data from a the nextcloud api through [client]. -typedef ApiImageDownloader = FutureOr> Function(NextcloudClient client); +typedef ApiImageDownloader = DynamiteRawResponse Function(NextcloudClient client); /// A widget painting an Image. /// -/// The image is cached in the [DefaultCacheManager] to avoid expensive -/// fetches. -/// /// See: /// * [NeonApiImage] for an image widget from an Nextcloud API endpoint. -/// * [NeonUrlImage] for an image widget from an arbitrary URL. +/// * [NeonUriImage] for an image widget from an arbitrary URL. /// * [NeonImageWrapper] for a wrapping widget for images -class NeonCachedImage extends StatefulWidget { +class NeonImage extends StatelessWidget { /// Custom image implementation. - /// - /// It is possible to provide custom [reviver] and [writeCache] functions to - /// adjust the caching. - NeonCachedImage({ - required ImageDownloader getImage, - required String cacheKey, - CacheReviver? reviver, - CacheWriter? writeCache, + const NeonImage({ + required this.image, + required this.onRetry, this.isSvgHint = false, this.size, this.fit, this.svgColorFilter, this.errorBuilder, - }) : image = _customImageGetter( - reviver, - getImage, - writeCache, - cacheKey, - ), - super(key: Key(cacheKey)); + super.key, + }); - /// The image content. - final Future image; + /// {@template NeonImage.image} + /// The subject containing the image data. + /// {@endtemplate} + final BehaviorSubject> image; + + /// {@template NeonImage.onRetry} + /// The function called to retry loading the image if it failed. + /// {@endtemplate} + final VoidCallback onRetry; - /// {@template NeonCachedImage.svgHint} + /// {@template NeonImage.svgHint} /// Hint whether the image is an SVG. /// {@endtemplate} final bool isSvgHint; - /// {@template NeonCachedImage.size} + /// {@template NeonImage.size} /// Dimensions for the painted image. /// {@endtemplate} final Size? size; - /// {@template NeonCachedImage.fit} + /// {@template NeonImage.fit} /// How to inscribe the image into the space allocated during layout. /// {@endtemplate} final BoxFit? fit; - /// {@template NeonCachedImage.svgColorFilter} + /// {@template NeonImage.svgColorFilter} /// The color filter to use when drawing SVGs. /// {@endtemplate} final ColorFilter? svgColorFilter; - /// {@template NeonCachedImage.errorBuilder} + /// {@template NeonImage.errorBuilder} /// Builder function building the error widget. /// - /// Defaults to a [NeonError] awaiting [image] again onRetry. + /// Defaults to a [NeonError]. /// {@endtemplate} final ErrorWidgetBuilder? errorBuilder; - static Future _customImageGetter( - CacheReviver? checkCache, - ImageDownloader getImage, - CacheWriter? writeCache, - String cacheKey, - ) async { - final cached = await checkCache?.call(cacheManager) ?? await _defaultCacheReviver(cacheKey); - if (cached != null) { - return cached; - } - - var data = await getImage(); - try { - data = utf8.encode(ImageUtils.rewriteSvgDimensions(utf8.decode(data))); - } catch (_) {} - - unawaited(writeCache?.call(cacheManager, data) ?? _defaultCacheWriter(data, cacheKey)); - - return data; - } - - static Future _defaultCacheReviver(String cacheKey) async { - final cacheFile = await cacheManager.getFileFromCache(cacheKey); - if (cacheFile != null && cacheFile.validTill.isAfter(DateTime.now())) { - return cacheFile.file.readAsBytes(); - } - - return null; - } - - static Future _defaultCacheWriter( - Uint8List data, - String cacheKey, - ) async { - await cacheManager.putFile( - cacheKey, - data, - maxAge: const Duration(days: 7), - ); - } - - /// The [CacheManager] instance. - @visibleForTesting - static DefaultCacheManager cacheManager = DefaultCacheManager(); - - @override - State createState() => _NeonCachedImageState(); -} - -class _NeonCachedImageState extends State { @override - Widget build(BuildContext context) => FutureBuilder( - future: widget.image, - builder: (context, fileSnapshot) { - if (fileSnapshot.hasError) { - return _buildError(fileSnapshot.error); - } + Widget build(BuildContext context) => ResultBuilder.behaviorSubject( + subject: image, + builder: (context, imageResult) { + final data = imageResult.data; + if (data != null) { + try { + // TODO: Is this safe enough? + if (isSvgHint || utf8.decode(data).contains(' _buildError(context, error), ); } - final content = fileSnapshot.requireData; - - try { - // TODO: Is this safe enough? - if (widget.isSvgHint || utf8.decode(content).contains(' _buildError(error), + return SizedBox( + width: size?.width, + child: const NeonLinearProgressIndicator(), ); }, ); - Widget _buildError(Object? error) => - widget.errorBuilder?.call(context, error) ?? + Widget _buildError(BuildContext context, Object? error) => + errorBuilder?.call(context, error) ?? NeonError( error, - onRetry: () { - setState(() {}); - }, + onRetry: onRetry, type: NeonErrorType.iconOnly, - iconSize: widget.size?.shortestSide, + iconSize: size?.shortestSide, ); } /// A widget painting an Image fetched from the Nextcloud API. /// +/// The image is cached in the [RequestManager] to avoid expensive +/// fetches. +/// /// See: -/// * [NeonCachedImage] for a customized image -/// * [NeonUrlImage] for an image widget from an arbitrary URL. +/// * [NeonImage] for a customized image +/// * [NeonUriImage] for an image widget from an arbitrary URL. /// * [NeonImageWrapper] for a wrapping widget for images -class NeonApiImage extends StatelessWidget { +class NeonApiImage extends StatefulWidget { /// Creates a new Neon API image fetching the image with the currently active account. /// /// See [NeonApiImage.withAccount] to fetch the image using a specific account. const NeonApiImage({ required this.getImage, required this.cacheKey, - this.reviver, - this.writeCache, + required this.etag, + required this.expires, this.isSvgHint = false, this.size, this.fit, @@ -224,9 +161,9 @@ class NeonApiImage extends StatelessWidget { const NeonApiImage.withAccount({ required this.getImage, required this.cacheKey, + required this.etag, + required this.expires, required Account this.account, - this.reviver, - this.writeCache, this.isSvgHint = false, this.size, this.fit, @@ -240,68 +177,103 @@ class NeonApiImage extends StatelessWidget { /// Defaults to the currently active account in [AccountsBloc.activeAccount]. final Account? account; - /// Image downloader. + /// {@macro NeonImage.getImage} final ApiImageDownloader getImage; - /// Cache key used for [NeonCachedImage.key]. + /// {@macro NeonImage.cacheKey} final String cacheKey; - /// Custom cache reviver function. - final CacheReviver? reviver; + /// The ETag used for invalidating the cache. + final String? etag; - /// Custom cache writer function. - final CacheWriter? writeCache; + /// The expiration date used for invalidating the cache. + final DateTime? expires; - /// {@macro NeonCachedImage.svgHint} + /// {@macro NeonImage.svgHint} final bool isSvgHint; - /// {@macro NeonCachedImage.size} + /// {@macro NeonImage.size} final Size? size; - /// {@macro NeonCachedImage.fit} + /// {@macro NeonImage.fit} final BoxFit? fit; - /// {@macro NeonCachedImage.svgColorFilter} + /// {@macro NeonImage.svgColorFilter} final ColorFilter? svgColorFilter; - /// {@macro NeonCachedImage.errorBuilder} + /// {@macro NeonImage.errorBuilder} final ErrorWidgetBuilder? errorBuilder; @override - Widget build(BuildContext context) { - final account = this.account ?? NeonProvider.of(context).activeAccount.value!; + State createState() => _NeonApiImageState(); +} + +class _NeonApiImageState extends State { + late Account account; + final image = BehaviorSubject>(); + + @override + void initState() { + super.initState(); + + account = widget.account ?? NeonProvider.of(context).activeAccount.value!; + + unawaited(load()); + } - return NeonCachedImage( - getImage: () async { - final response = await getImage(account.client); - return response.body; + @override + void dispose() { + unawaited(image.close()); + + super.dispose(); + } + + Future load() async { + await RequestManager.instance.wrapBinary( + account: account, + cacheKey: widget.cacheKey, + getCacheParameters: () async => CacheParameters( + etag: widget.etag, + expires: widget.expires, + ), + rawResponse: widget.getImage(account.client), + unwrap: (data) { + try { + return utf8.encode(ImageUtils.rewriteSvgDimensions(utf8.decode(data))); + } catch (_) {} + return data; }, - cacheKey: '${account.id}-$cacheKey', - reviver: reviver, - writeCache: writeCache, - isSvgHint: isSvgHint, - size: size, - fit: fit, - svgColorFilter: svgColorFilter, - errorBuilder: errorBuilder, + subject: image, ); } + + @override + Widget build(BuildContext context) => NeonImage( + image: image, + onRetry: load, + isSvgHint: widget.isSvgHint, + size: widget.size, + fit: widget.fit, + svgColorFilter: widget.svgColorFilter, + errorBuilder: widget.errorBuilder, + ); } -/// A widget painting an Image fetched from an arbitrary URL. +/// A widget painting an Image fetched from an arbitrary URI. +/// +/// The image is cached in the [RequestManager] to avoid expensive +/// fetches. /// /// See: -/// * [NeonCachedImage] for a customized image +/// * [NeonImage] for a customized image /// * [NeonApiImage] for an image widget from an Nextcloud API endpoint. /// * [NeonImageWrapper] for a wrapping widget for images -class NeonUrlImage extends StatelessWidget { +class NeonUriImage extends StatefulWidget { /// Creates a new Neon URL image with the active account. /// - /// See [NeonUrlImage.withAccount] for using a specific account. - const NeonUrlImage({ + /// See [NeonUriImage.withAccount] for using a specific account. + const NeonUriImage({ required this.uri, - this.reviver, - this.writeCache, this.isSvgHint = false, this.size, this.fit, @@ -312,12 +284,10 @@ class NeonUrlImage extends StatelessWidget { /// Creates a new Neon URL image with the given [account]. /// - /// See [NeonUrlImage] for using the active account. - const NeonUrlImage.withAccount({ + /// See [NeonUriImage] for using the active account. + const NeonUriImage.withAccount({ required this.uri, required Account this.account, - this.reviver, - this.writeCache, this.isSvgHint = false, this.size, this.fit, @@ -336,60 +306,80 @@ class NeonUrlImage extends StatelessWidget { /// This can also be a data URI. final Uri uri; - /// Custom cache reviver function. - final CacheReviver? reviver; - - /// Custom cache writer function. - final CacheWriter? writeCache; - - /// {@macro NeonCachedImage.svgHint} + /// {@macro NeonImage.svgHint} final bool isSvgHint; - /// {@macro NeonCachedImage.size} + /// {@macro NeonImage.size} final Size? size; - /// {@macro NeonCachedImage.fit} + /// {@macro NeonImage.fit} final BoxFit? fit; - /// {@macro NeonCachedImage.svgColorFilter} + /// {@macro NeonImage.svgColorFilter} final ColorFilter? svgColorFilter; - /// {@macro NeonCachedImage.errorBuilder} + /// {@macro NeonImage.errorBuilder} final ErrorWidgetBuilder? errorBuilder; @override - Widget build(BuildContext context) { - final account = this.account ?? NeonProvider.of(context).activeAccount.value!; + State createState() => _NeonUriImageState(); +} + +class _NeonUriImageState extends State { + late Account account; + final image = BehaviorSubject>(); + + @override + void initState() { + super.initState(); + + account = widget.account ?? NeonProvider.of(context).activeAccount.value!; - final dataUri = uri.data; + unawaited(load()); + } + + @override + void dispose() { + unawaited(image.close()); - return NeonCachedImage( - getImage: () async { - if (dataUri != null) { - return dataUri.contentAsBytes(); - } + super.dispose(); + } - final completedUri = account.completeUri(uri); + Future load() async { + if (widget.uri.data != null) { + var data = widget.uri.data!.contentAsBytes(); + try { + data = utf8.encode(ImageUtils.rewriteSvgDimensions(utf8.decode(data))); + } catch (_) {} + image.add(Result.success(data)); + return; + } - final response = await account.client.executeRawRequest( - 'GET', - completedUri, - headers: account.getAuthorizationHeaders(completedUri), - validStatuses: const {200, 201}, - ); + final completedUri = account.completeUri(widget.uri); - return response.stream.bytes; + await RequestManager.instance.wrapUri( + account: account, + uri: completedUri, + unwrap: (data) { + try { + return utf8.encode(ImageUtils.rewriteSvgDimensions(utf8.decode(data))); + } catch (_) {} + return data; }, - cacheKey: '${account.id}-$uri', - reviver: reviver, - writeCache: writeCache, - isSvgHint: isSvgHint || (dataUri?.mimeType.contains('svg') ?? false), - size: size, - fit: fit, - svgColorFilter: svgColorFilter, - errorBuilder: errorBuilder, + subject: image, ); } + + @override + Widget build(BuildContext context) => NeonImage( + image: image, + onRetry: load, + isSvgHint: widget.isSvgHint || (widget.uri.data?.mimeType.contains('svg') ?? false), + size: widget.size, + fit: widget.fit, + svgColorFilter: widget.svgColorFilter, + errorBuilder: widget.errorBuilder, + ); } /// Nextcloud image wrapper widget. @@ -397,9 +387,9 @@ class NeonUrlImage extends StatelessWidget { /// Wraps a child (most commonly an image) into a uniformly styled container. /// /// See: -/// * [NeonCachedImage] for a customized image +/// * [NeonImage] for a customized image /// * [NeonApiImage] for an image widget from an Nextcloud API endpoint. -/// * [NeonUrlImage] for an image widget from an arbitrary URL. +/// * [NeonUriImage] for an image widget from an arbitrary URL. class NeonImageWrapper extends StatelessWidget { /// Creates a new image wrapper. const NeonImageWrapper({ diff --git a/packages/neon_framework/lib/src/widgets/unified_search_results.dart b/packages/neon_framework/lib/src/widgets/unified_search_results.dart index be84f053c91..873b56d2b65 100644 --- a/packages/neon_framework/lib/src/widgets/unified_search_results.dart +++ b/packages/neon_framework/lib/src/widgets/unified_search_results.dart @@ -111,7 +111,7 @@ class NeonUnifiedSearchResults extends StatelessWidget { Widget _buildThumbnail(BuildContext context, Account account, core.UnifiedSearchResultEntry entry) { if (entry.thumbnailUrl.isNotEmpty) { - return NeonUrlImage.withAccount( + return NeonUriImage.withAccount( size: const Size.square(largeIconSize), uri: Uri.parse(entry.thumbnailUrl), account: account, @@ -129,7 +129,7 @@ class NeonUnifiedSearchResults extends StatelessWidget { core.UnifiedSearchResultEntry entry, ) { if (entry.icon.startsWith('/')) { - return NeonUrlImage.withAccount( + return NeonUriImage.withAccount( size: Size.square(IconTheme.of(context).size!), uri: Uri.parse(entry.icon), account: account, diff --git a/packages/neon_framework/lib/src/widgets/user_avatar.dart b/packages/neon_framework/lib/src/widgets/user_avatar.dart index 57d35db98f0..90beb246b7b 100644 --- a/packages/neon_framework/lib/src/widgets/user_avatar.dart +++ b/packages/neon_framework/lib/src/widgets/user_avatar.dart @@ -72,12 +72,14 @@ class _UserAvatarState extends State { child: NeonApiImage.withAccount( account: widget.account, cacheKey: 'avatar-${widget.username}-$brightness$pixelSize', - getImage: (client) async => switch (brightness) { - Brightness.dark => client.core.avatar.getAvatarDark( + etag: null, + expires: null, + getImage: (client) => switch (brightness) { + Brightness.dark => client.core.avatar.getAvatarDarkRaw( userId: widget.username, size: pixelSize, ), - Brightness.light => client.core.avatar.getAvatar( + Brightness.light => client.core.avatar.getAvatarRaw( userId: widget.username, size: pixelSize, ), diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml index c6b61769166..f247e060a0a 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -19,7 +19,6 @@ dependencies: filesize: ^2.0.0 flutter: sdk: flutter - flutter_cache_manager: ^3.0.0 flutter_file_dialog: ^3.0.0 flutter_local_notifications: ^16.0.0 flutter_localizations: @@ -49,6 +48,7 @@ dependencies: git: url: https://github.com/nextcloud/neon path: packages/sort_box + sqflite: ^2.3.0 sqflite_common_ffi: ^2.2.8-2 unifiedpush: ^5.0.0 unifiedpush_android: ^2.0.0 diff --git a/packages/neon_framework/test/image_test.dart b/packages/neon_framework/test/image_test.dart new file mode 100644 index 00000000000..2821cc0cdf0 --- /dev/null +++ b/packages/neon_framework/test/image_test.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/bloc/result.dart'; +import 'package:neon_framework/src/models/account.dart'; +import 'package:neon_framework/src/utils/request_manager.dart'; +import 'package:neon_framework/src/widgets/error.dart'; +import 'package:neon_framework/src/widgets/image.dart'; +import 'package:neon_framework/src/widgets/linear_progress_indicator.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rxdart/rxdart.dart'; + +class MockCallbackFunction extends Mock { + FutureOr call(); +} + +class MockRequestManager extends Mock implements RequestManager {} + +// ignore: avoid_implementing_value_types +class MockAccount extends Mock implements Account {} + +class MockNextcloudClient extends Mock implements NextcloudClient {} + +class MockDynamiteRawResponse extends Mock implements DynamiteRawResponse {} + +class MockGetImage extends Mock { + DynamiteRawResponse call(NextcloudClient client); +} + +void main() { + setUpAll(() { + registerFallbackValue(BehaviorSubject>()); + registerFallbackValue(MockAccount()); + registerFallbackValue(MockNextcloudClient()); + registerFallbackValue(MockDynamiteRawResponse()); + registerFallbackValue(Uri()); + }); + + testWidgets('NeonImage', (tester) async { + final image = BehaviorSubject>(); + final callback = MockCallbackFunction(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: NeonLocalizations.localizationsDelegates, + supportedLocales: NeonLocalizations.supportedLocales, + home: NeonImage( + image: image, + onRetry: callback.call, + ), + ), + ); + + expect(find.byType(NeonLinearProgressIndicator), findsOne); + + image.add(Result.error('')); + await tester.pumpAndSettle(); + + expect(find.byType(NeonError), findsOne); + await tester.tap(find.byType(NeonError)); + verify(callback.call).called(1); + + image.add( + Result.success( + utf8.encode( + '', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(SvgPicture), findsOne); + + image.add( + Result.success( + base64.decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAABhGlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9TpSIVByuIOmSoTnZREcdahSJUCLVCqw4mL/2DJg1Jiouj4Fpw8Gex6uDirKuDqyAI/oA4OzgpukiJ9yWFFjFeeLyP8+45vHcfIDQqTLO64oCm22Y6mRCzuVUx9IoARjCIIASZWcacJKXgW1/31E11F+NZ/n1/Vp+atxgQEInjzDBt4g3imU3b4LxPHGElWSU+J54w6YLEj1xXPH7jXHRZ4JkRM5OeJ44Qi8UOVjqYlUyNeJo4qmo65QtZj1XOW5y1So217slfGM7rK8tcpzWKJBaxBAkiFNRQRgU2YrTrpFhI03nCxz/s+iVyKeQqg5FjAVVokF0/+B/8nq1VmJr0ksIJoPvFcT7GgNAu0Kw7zvex4zRPgOAzcKW3/dUGMPtJer2tRY+A/m3g4rqtKXvA5Q4w9GTIpuxKQVpCoQC8n9E35YCBW6B3zZtb6xynD0CGZpW6AQ4OgfEiZa/7vLunc27/9rTm9wMlDXKHV2uA4wAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAd0SU1FB+gBGxUDIIYV2PEAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(Image), findsOne); + + await image.close(); + }); + + testWidgets('NeonApiImage', (tester) async { + final mockRequestManager = MockRequestManager(); + when( + () => mockRequestManager.wrapBinary( + account: any(named: 'account'), + cacheKey: any(named: 'cacheKey'), + getCacheParameters: any(named: 'getCacheParameters'), + rawResponse: any(named: 'rawResponse'), + unwrap: any(named: 'unwrap'), + subject: any(named: 'subject'), + ), + ).thenAnswer((_) => Future.value()); + RequestManager.instance = mockRequestManager; + + final mockNextcloudClient = MockNextcloudClient(); + + final mockAccount = MockAccount(); + when(() => mockAccount.client).thenReturn(mockNextcloudClient); + + final mockRawResponse = MockDynamiteRawResponse(); + + final getImage = MockGetImage(); + when(() => getImage(any())).thenAnswer((invocation) { + expect(invocation.positionalArguments.single, mockNextcloudClient); + return mockRawResponse; + }); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: NeonLocalizations.localizationsDelegates, + supportedLocales: NeonLocalizations.supportedLocales, + home: NeonApiImage.withAccount( + getImage: getImage.call, + cacheKey: 'key', + etag: null, + expires: null, + account: mockAccount, + ), + ), + ); + + verify(() => getImage(any())).called(1); + verify( + () => mockRequestManager.wrapBinary( + account: mockAccount, + cacheKey: 'key', + getCacheParameters: any(named: 'getCacheParameters'), + rawResponse: mockRawResponse, + unwrap: any(named: 'unwrap', that: isNotNull), + subject: any(named: 'subject'), + ), + ).called(1); + }); + + testWidgets('NeonUriImage', (tester) async { + final mockRequestManager = MockRequestManager(); + when( + () => mockRequestManager.wrapUri( + account: any(named: 'account'), + uri: any(named: 'uri'), + unwrap: any(named: 'unwrap'), + subject: any(named: 'subject'), + ), + ).thenAnswer((_) => Future.value()); + RequestManager.instance = mockRequestManager; + + final mockNextcloudClient = MockNextcloudClient(); + + final mockAccount = MockAccount(); + when(() => mockAccount.client).thenReturn(mockNextcloudClient); + when(() => mockAccount.completeUri(any())).thenAnswer((invocation) => invocation.positionalArguments.single as Uri); + + final uri = Uri.parse('https://example.com'); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: NeonLocalizations.localizationsDelegates, + supportedLocales: NeonLocalizations.supportedLocales, + home: NeonUriImage.withAccount( + uri: uri, + account: mockAccount, + ), + ), + ); + + verify( + () => mockRequestManager.wrapUri( + account: mockAccount, + uri: uri, + unwrap: any(named: 'unwrap', that: isNotNull), + subject: any(named: 'subject'), + ), + ).called(1); + }); +}