From fbb93b89f3689a0f9e2cd8a99331138f56802615 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Fri, 29 Sep 2023 20:35:36 +0200 Subject: [PATCH] refactor(neon,neon_news,neon_notifications): refactor image handling in neon Signed-off-by: Nikolas Rimikis --- .cspell/dart_flutter.txt | 1 + .../neon/neon/lib/src/widgets/drawer.dart | 2 +- .../widgets/{cached_image.dart => image.dart} | 177 ++++++++++++++++-- .../neon/lib/src/widgets/image_wrapper.dart | 33 ---- .../neon/lib/src/widgets/server_icon.dart | 62 +++++- .../src/widgets/unified_search_results.dart | 8 +- .../neon/lib/src/widgets/user_avatar.dart | 32 ++-- packages/neon/neon/lib/widgets.dart | 3 +- .../neon_news/lib/widgets/feeds_view.dart | 5 +- .../neon_notifications/lib/pages/main.dart | 2 +- 10 files changed, 235 insertions(+), 90 deletions(-) rename packages/neon/neon/lib/src/widgets/{cached_image.dart => image.dart} (54%) delete mode 100644 packages/neon/neon/lib/src/widgets/image_wrapper.dart diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index e0dd5859..82f3cf3e 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -1,4 +1,5 @@ autofocus +endtemplate expando gapless lerp diff --git a/packages/neon/neon/lib/src/widgets/drawer.dart b/packages/neon/neon/lib/src/widgets/drawer.dart index 8eea8854..827990ce 100644 --- a/packages/neon/neon/lib/src/widgets/drawer.dart +++ b/packages/neon/neon/lib/src/widgets/drawer.dart @@ -9,9 +9,9 @@ import 'package:neon/src/blocs/apps.dart'; import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/provider.dart'; -import 'package:neon/src/widgets/cached_image.dart'; import 'package:neon/src/widgets/drawer_destination.dart'; import 'package:neon/src/widgets/error.dart'; +import 'package:neon/src/widgets/image.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:nextcloud/nextcloud.dart'; diff --git a/packages/neon/neon/lib/src/widgets/cached_image.dart b/packages/neon/neon/lib/src/widgets/image.dart similarity index 54% rename from packages/neon/neon/lib/src/widgets/cached_image.dart rename to packages/neon/neon/lib/src/widgets/image.dart index 077ec11c..911ba453 100644 --- a/packages/neon/neon/lib/src/widgets/cached_image.dart +++ b/packages/neon/neon/lib/src/widgets/image.dart @@ -12,25 +12,46 @@ import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:nextcloud/nextcloud.dart'; -typedef CacheReviver = FutureOr Function(CacheManager cacheManager); +/// 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(); -typedef CacheWriter = Future Function(CacheManager cacheManager, Uint8List image); -typedef ErrorWidgetBuilder = Widget? Function(BuildContext, dynamic); +/// The signature of a function writing [image] data to the [cache]. +typedef CacheWriter = Future Function(CacheManager cache, Uint8List image); + +/// 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); +/// A widget painting an Image. +/// +/// The image is cached in the [DefaultCacheManager] to avoid expensive +/// fetches. +/// +/// See: +/// * [NeonApiImage] for an image from the [NextcloudClient] +/// * [NeonImageWrapper] for a wrapping widget for images class NeonCachedImage extends StatefulWidget { - const NeonCachedImage({ - required this.image, + /// Prints the data from [image] to the canvas. + /// + /// The data is not persisted in the cache. + NeonCachedImage({ + required final Uint8List image, required Key super.key, this.isSvgHint = false, this.size, this.fit, - this.svgColor, this.iconColor, this.errorBuilder, - }); + }) : image = Future.value(image); + /// Fetches the image from [url]. + /// + /// The image is automatically cached. NeonCachedImage.url({ required final String url, final Account? account, @@ -38,12 +59,15 @@ class NeonCachedImage extends StatefulWidget { this.isSvgHint = false, this.size, this.fit, - this.svgColor, this.iconColor, this.errorBuilder, }) : image = _getImageFromUrl(url, account), super(key: key ?? Key(url)); + /// Custom image implementation. + /// + /// It is possible to provide custom [reviver] and [writeCache] functions to + /// adjust the caching. NeonCachedImage.custom({ required final ImageDownloader getImage, required final String cacheKey, @@ -52,7 +76,6 @@ class NeonCachedImage extends StatefulWidget { this.isSvgHint = false, this.size, this.fit, - this.svgColor, this.iconColor, this.errorBuilder, }) : image = _customImageGetter( @@ -63,15 +86,36 @@ class NeonCachedImage extends StatefulWidget { ), super(key: Key(cacheKey)); + /// The image content. final Future image; + + /// {@template NeonCachedImage.svgHint} + /// Hint whether the image is an SVG. + /// {@endtemplate} final bool isSvgHint; + /// {@template NeonCachedImage.size} + /// Dimensions for the painted image. + /// {@endtemplate} final Size? size; + + /// {@template NeonCachedImage.fit} + /// How to inscribe the image into the space allocated during layout. + /// {@endtemplate} final BoxFit? fit; - final Color? svgColor; + /// {@template NeonCachedImage.iconColor} + /// The color to use when drawing the image. + /// + /// Applies to SVG images only and the loading animation. + /// {@endtemplate} final Color? iconColor; + /// {@template NeonCachedImage.errorBuilder} + /// Builder function building the error widget. + /// + /// Defaults to a [NeonError] awaiting [image] again onRetry. + /// {@endtemplate} final ErrorWidgetBuilder? errorBuilder; static Future _getImageFromUrl(final String url, final Account? account) async { @@ -161,7 +205,7 @@ class _NeonCachedImageState extends State { height: widget.size?.height, width: widget.size?.width, fit: widget.fit ?? BoxFit.contain, - colorFilter: widget.svgColor != null ? ColorFilter.mode(widget.svgColor!, BlendMode.srcIn) : null, + colorFilter: widget.iconColor != null ? ColorFilter.mode(widget.iconColor!, BlendMode.srcIn) : null, ); } } catch (_) { @@ -192,7 +236,18 @@ class _NeonCachedImageState extends State { ); } +/// Nextcloud API image. +/// +/// Extension for [NeonCachedImage] providing a [NextcloudClient] to the caller +/// to retrieve the image. +/// +/// See: +/// * [NeonCachedImage] for a customized image +/// * [NeonImageWrapper] for a wrapping widget for images class NeonApiImage extends StatelessWidget { + /// Creates a new Neon API image with the active account. + /// + /// Use [NeonApiImage.custom] to specify fetching the image with a different account. const NeonApiImage({ required this.getImage, required this.cacheKey, @@ -201,26 +256,64 @@ class NeonApiImage extends StatelessWidget { this.isSvgHint = false, this.size, this.fit, - this.svgColor, + this.iconColor, + this.errorBuilder, + super.key, + }) : account = null; + + /// Creates a new Neon API image for a given account. + /// + /// Use [NeonApiImage] to fetch the image with the currently active account. + const NeonApiImage.custom({ + required this.getImage, + required this.cacheKey, + required Account this.account, + this.reviver, + this.writeCache, + this.isSvgHint = false, + this.size, + this.fit, this.iconColor, this.errorBuilder, super.key, }); + /// Optional account to use for the request. + /// + /// Defaults to the currently active account in [AccountsBloc.activeAccount]. + /// Use the [NeonApiImage.custom] constructor to specify a different account. + final Account? account; + + /// Image downloader. final ApiImageDownloader getImage; + + /// Cache key used for [NeonCachedImage.key]. final String cacheKey; + + /// Custom cache reviver function. final CacheReviver? reviver; + + /// Custom cache writer function. final CacheWriter? writeCache; + + /// {@macro NeonCachedImage.svgHint} final bool isSvgHint; + + /// {@macro NeonCachedImage.size} final Size? size; + + /// {@macro NeonCachedImage.fit} final BoxFit? fit; - final Color? svgColor; + + /// {@macro NeonCachedImage.iconColor} final Color? iconColor; + + /// {@macro NeonCachedImage.errorBuilder} final ErrorWidgetBuilder? errorBuilder; @override Widget build(final BuildContext context) { - final account = NeonProvider.of(context).activeAccount.value!; + final account = this.account ?? NeonProvider.of(context).activeAccount.value!; return NeonCachedImage.custom( getImage: () async { @@ -228,6 +321,62 @@ class NeonApiImage extends StatelessWidget { return response.body; }, cacheKey: '${account.id}-$cacheKey', + reviver: reviver, + writeCache: writeCache, + isSvgHint: isSvgHint, + size: size, + fit: fit, + iconColor: iconColor, + errorBuilder: errorBuilder, ); } } + +/// Nextcloud image wrapper widget. +/// +/// Wraps a child (most commonly an image) into a uniformly styled container. +/// +/// See: +/// * [NeonCachedImage] for a customized image +/// * [NeonApiImage] for an image widget from a [NextcloudClient]. +class NeonImageWrapper extends StatelessWidget { + /// Creates a new image wrapper. + const NeonImageWrapper({ + required this.child, + this.color = Colors.white, + this.size, + this.borderRadius = const BorderRadius.all(Radius.circular(8)), + super.key, + }); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The color to paint the background area with. + final Color color; + + /// The size of the widget. + final Size? size; + + /// The corners of this box are rounded by this [BorderRadius]. + /// + /// If null defaults to `const BorderRadius.all(Radius.circular(8))`. + /// + /// {@macro flutter.painting.BoxDecoration.clip} + final BorderRadius? borderRadius; + + @override + Widget build(final BuildContext context) => Container( + height: size?.height, + width: size?.width, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: borderRadius, + color: color, + ), + clipBehavior: Clip.antiAlias, + child: child, + ); +} diff --git a/packages/neon/neon/lib/src/widgets/image_wrapper.dart b/packages/neon/neon/lib/src/widgets/image_wrapper.dart deleted file mode 100644 index e30cfee3..00000000 --- a/packages/neon/neon/lib/src/widgets/image_wrapper.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class NeonImageWrapper extends StatelessWidget { - const NeonImageWrapper({ - required this.child, - this.color = Colors.white, - this.size, - this.borderRadius, - super.key, - }); - - final Widget child; - final Color color; - final Size? size; - final BorderRadius? borderRadius; - - @override - Widget build(final BuildContext context) => ClipRRect( - borderRadius: borderRadius ?? BorderRadius.zero, - child: ColorFiltered( - colorFilter: ColorFilter.mode(color, BlendMode.dstATop), - child: SizedBox.fromSize( - size: size, - child: ColoredBox( - color: Colors.transparent, - child: Center( - child: child, - ), - ), - ), - ), - ); -} diff --git a/packages/neon/neon/lib/src/widgets/server_icon.dart b/packages/neon/neon/lib/src/widgets/server_icon.dart index 2bc31528..b1b039f0 100644 --- a/packages/neon/neon/lib/src/widgets/server_icon.dart +++ b/packages/neon/neon/lib/src/widgets/server_icon.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'package:vector_graphics/vector_graphics.dart'; +/// Nextcloud server icon. +/// +/// Draws an icon from the Nextcloud server icon set. class NeonServerIcon extends StatelessWidget { + /// Creates a new server icon const NeonServerIcon({ required this.icon, this.color, @@ -9,18 +13,56 @@ class NeonServerIcon extends StatelessWidget { super.key, }); + /// Name of the server icon to draw. final String icon; + + /// The color to use when drawing the icon. + /// + /// Defaults to the nearest [IconTheme]'s [IconThemeData.color]. + /// + /// The color (whether specified explicitly here or obtained from the + /// [IconTheme]) will be further adjusted by the nearest [IconTheme]'s + /// [IconThemeData.opacity]. + /// + /// {@tool snippet} + /// Typically, a Material Design color will be used, as follows: + /// + /// ```dart + /// NeonServerIcon( + /// icon: 'icon-add', + /// color: Colors.blue.shade400, + /// ) + /// ``` + /// {@end-tool} final Color? color; - final Size? size; + + /// The size of the icon in logical pixels. + /// + /// Icons occupy a square with width and height equal to size. + /// + /// Defaults to the nearest [IconTheme]'s [IconThemeData.size]. + /// + /// If this [NeonServerIcon] is being placed inside an [IconButton], then use + /// [IconButton.iconSize] instead, so that the [IconButton] can make the splash + /// area the appropriate size as well. The [IconButton] uses an [IconTheme] to + /// pass down the size to the [NeonServerIcon]. + final double? size; @override - Widget build(final BuildContext context) => VectorGraphic( - width: size?.width, - height: size?.height, - colorFilter: color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null, - loader: AssetBytesLoader( - 'assets/icons/server/${icon.replaceFirst(RegExp('^icon-'), '').replaceFirst(RegExp(r'-(dark|white)$'), '')}.svg.vec', - packageName: 'neon', - ), - ); + Widget build(final BuildContext context) { + final iconTheme = Theme.of(context).iconTheme; + + final size = this.size ?? iconTheme.size; + final color = this.color ?? iconTheme.color; + + return VectorGraphic( + width: size, + height: size, + colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, + loader: AssetBytesLoader( + 'assets/icons/server/${icon.replaceFirst(RegExp('^icon-'), '').replaceFirst(RegExp(r'-(dark|white)$'), '')}.svg.vec', + packageName: 'neon', + ), + ); + } } diff --git a/packages/neon/neon/lib/src/widgets/unified_search_results.dart b/packages/neon/neon/lib/src/widgets/unified_search_results.dart index 25ef8d4c..0a0b70a3 100644 --- a/packages/neon/neon/lib/src/widgets/unified_search_results.dart +++ b/packages/neon/neon/lib/src/widgets/unified_search_results.dart @@ -9,9 +9,8 @@ import 'package:neon/src/blocs/unified_search.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/theme/sizes.dart'; import 'package:neon/src/utils/provider.dart'; -import 'package:neon/src/widgets/cached_image.dart'; import 'package:neon/src/widgets/error.dart'; -import 'package:neon/src/widgets/image_wrapper.dart'; +import 'package:neon/src/widgets/image.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/list_view.dart'; import 'package:neon/src/widgets/server_icon.dart'; @@ -94,7 +93,6 @@ class NeonUnifiedSearchResults extends StatelessWidget { ListTile( leading: NeonImageWrapper( size: const Size.square(largeIconSize), - borderRadius: const BorderRadius.all(Radius.circular(8)), child: _buildThumbnail(context, accountsBloc.activeAccount.value!, entry), ), title: Text(entry.title), @@ -129,10 +127,9 @@ class NeonUnifiedSearchResults extends StatelessWidget { final Account account, final CoreUnifiedSearchResultEntry entry, ) { - final size = Size.square(IconTheme.of(context).size!); if (entry.icon.startsWith('/')) { return NeonCachedImage.url( - size: size, + size: Size.square(IconTheme.of(context).size!), url: entry.icon, account: account, ); @@ -140,7 +137,6 @@ class NeonUnifiedSearchResults extends StatelessWidget { if (entry.icon.startsWith('icon-')) { return NeonServerIcon( - size: size, color: Theme.of(context).colorScheme.primary, icon: entry.icon, ); diff --git a/packages/neon/neon/lib/src/widgets/user_avatar.dart b/packages/neon/neon/lib/src/widgets/user_avatar.dart index 76c7c391..92ec11ee 100644 --- a/packages/neon/neon/lib/src/widgets/user_avatar.dart +++ b/packages/neon/neon/lib/src/widgets/user_avatar.dart @@ -1,6 +1,3 @@ -// ignore_for_file: use_late_for_private_fields_and_variables -// ^ This is a really strange false positive, it goes of at a very random place without any meaning. Hopefully fixed soon? - import 'dart:async'; import 'package:flutter/material.dart'; @@ -10,7 +7,7 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/theme/sizes.dart'; import 'package:neon/src/utils/provider.dart'; -import 'package:neon/src/widgets/cached_image.dart'; +import 'package:neon/src/widgets/image.dart'; import 'package:neon/src/widgets/server_icon.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:rxdart/rxdart.dart'; @@ -61,20 +58,18 @@ class _UserAvatarState extends State { radius: size / 2, backgroundColor: widget.backgroundColor, child: ClipOval( - child: NeonCachedImage.custom( - cacheKey: '${widget.account.id}-avatar-${widget.username}-$brightness$pixelSize', - getImage: () async { - final response = switch (brightness) { - Brightness.dark => await widget.account.client.core.avatar.getAvatarDark( - userId: widget.username, - size: pixelSize, - ), - Brightness.light => await widget.account.client.core.avatar.getAvatar( - userId: widget.username, - size: pixelSize, - ), - }; - return response.body; + child: NeonApiImage.custom( + account: widget.account, + cacheKey: 'avatar-${widget.username}-$brightness$pixelSize', + getImage: (final client) async => switch (brightness) { + Brightness.dark => client.core.avatar.getAvatarDark( + userId: widget.username, + size: pixelSize, + ), + Brightness.light => client.core.avatar.getAvatar( + userId: widget.username, + size: pixelSize, + ), }, ), ), @@ -83,7 +78,6 @@ class _UserAvatarState extends State { if (!widget.showStatus) { return avatar; } - return Stack( alignment: Alignment.center, children: [ diff --git a/packages/neon/neon/lib/widgets.dart b/packages/neon/neon/lib/widgets.dart index 34bee9ed..a1b6d690 100644 --- a/packages/neon/neon/lib/widgets.dart +++ b/packages/neon/neon/lib/widgets.dart @@ -1,7 +1,6 @@ -export 'package:neon/src/widgets/cached_image.dart'; export 'package:neon/src/widgets/dialog.dart'; export 'package:neon/src/widgets/error.dart'; -export 'package:neon/src/widgets/image_wrapper.dart'; +export 'package:neon/src/widgets/image.dart'; export 'package:neon/src/widgets/linear_progress_indicator.dart'; export 'package:neon/src/widgets/list_view.dart'; export 'package:neon/src/widgets/relative_time.dart'; diff --git a/packages/neon/neon_news/lib/widgets/feeds_view.dart b/packages/neon/neon_news/lib/widgets/feeds_view.dart index 10002f8f..14b88b93 100644 --- a/packages/neon/neon_news/lib/widgets/feeds_view.dart +++ b/packages/neon/neon_news/lib/widgets/feeds_view.dart @@ -53,10 +53,7 @@ class NewsFeedsView extends StatelessWidget { subtitle: feed.unreadCount! > 0 ? Text(AppLocalizations.of(context).articlesUnread(feed.unreadCount!)) : const SizedBox(), - leading: NewsFeedIcon( - feed: feed, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), + leading: NewsFeedIcon(feed: feed), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/packages/neon/neon_notifications/lib/pages/main.dart b/packages/neon/neon_notifications/lib/pages/main.dart index 9fdc10bf..f0d55403 100644 --- a/packages/neon/neon_notifications/lib/pages/main.dart +++ b/packages/neon/neon_notifications/lib/pages/main.dart @@ -80,7 +80,7 @@ class _NotificationsMainPageState extends State { child: NeonCachedImage.url( url: notification.icon!, size: const Size.square(largeIconSize), - svgColor: Theme.of(context).colorScheme.primary, + iconColor: Theme.of(context).colorScheme.primary, ), ), onTap: () async {