Browse Source

Merge pull request #880 from nextcloud/refactor/neon

refactor image handling in neon
pull/885/head
Nikolas Rimikis 1 year ago committed by GitHub
parent
commit
9cd8d6fb2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .cspell/dart_flutter.txt
  2. 2
      packages/neon/neon/lib/src/widgets/drawer.dart
  3. 177
      packages/neon/neon/lib/src/widgets/image.dart
  4. 33
      packages/neon/neon/lib/src/widgets/image_wrapper.dart
  5. 52
      packages/neon/neon/lib/src/widgets/server_icon.dart
  6. 8
      packages/neon/neon/lib/src/widgets/unified_search_results.dart
  7. 20
      packages/neon/neon/lib/src/widgets/user_avatar.dart
  8. 3
      packages/neon/neon/lib/widgets.dart
  9. 5
      packages/neon/neon_news/lib/widgets/feeds_view.dart
  10. 2
      packages/neon/neon_notifications/lib/pages/main.dart

1
.cspell/dart_flutter.txt

@ -1,4 +1,5 @@
autofocus autofocus
endtemplate
expando expando
gapless gapless
lerp lerp

2
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/models/app_implementation.dart';
import 'package:neon/src/router.dart'; import 'package:neon/src/router.dart';
import 'package:neon/src/utils/provider.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/drawer_destination.dart';
import 'package:neon/src/widgets/error.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:neon/src/widgets/linear_progress_indicator.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';

177
packages/neon/neon/lib/src/widgets/cached_image.dart → 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:neon/src/widgets/linear_progress_indicator.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
typedef CacheReviver = FutureOr<Uint8List?> Function(CacheManager cacheManager); /// The signature of a function reviving image data from the [cache].
typedef CacheReviver = FutureOr<Uint8List?> Function(CacheManager cache);
/// The signature of a function downloading image data.
typedef ImageDownloader = FutureOr<Uint8List> Function(); typedef ImageDownloader = FutureOr<Uint8List> Function();
typedef CacheWriter = Future<void> 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<void> 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<DynamiteResponse<Uint8List, dynamic>> Function(NextcloudClient client); typedef ApiImageDownloader = FutureOr<DynamiteResponse<Uint8List, dynamic>> 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 { class NeonCachedImage extends StatefulWidget {
const NeonCachedImage({ /// Prints the data from [image] to the canvas.
required this.image, ///
/// The data is not persisted in the cache.
NeonCachedImage({
required final Uint8List image,
required Key super.key, required Key super.key,
this.isSvgHint = false, this.isSvgHint = false,
this.size, this.size,
this.fit, this.fit,
this.svgColor,
this.iconColor, this.iconColor,
this.errorBuilder, this.errorBuilder,
}); }) : image = Future.value(image);
/// Fetches the image from [url].
///
/// The image is automatically cached.
NeonCachedImage.url({ NeonCachedImage.url({
required final String url, required final String url,
final Account? account, final Account? account,
@ -38,12 +59,15 @@ class NeonCachedImage extends StatefulWidget {
this.isSvgHint = false, this.isSvgHint = false,
this.size, this.size,
this.fit, this.fit,
this.svgColor,
this.iconColor, this.iconColor,
this.errorBuilder, this.errorBuilder,
}) : image = _getImageFromUrl(url, account), }) : image = _getImageFromUrl(url, account),
super(key: key ?? Key(url)); super(key: key ?? Key(url));
/// Custom image implementation.
///
/// It is possible to provide custom [reviver] and [writeCache] functions to
/// adjust the caching.
NeonCachedImage.custom({ NeonCachedImage.custom({
required final ImageDownloader getImage, required final ImageDownloader getImage,
required final String cacheKey, required final String cacheKey,
@ -52,7 +76,6 @@ class NeonCachedImage extends StatefulWidget {
this.isSvgHint = false, this.isSvgHint = false,
this.size, this.size,
this.fit, this.fit,
this.svgColor,
this.iconColor, this.iconColor,
this.errorBuilder, this.errorBuilder,
}) : image = _customImageGetter( }) : image = _customImageGetter(
@ -63,15 +86,36 @@ class NeonCachedImage extends StatefulWidget {
), ),
super(key: Key(cacheKey)); super(key: Key(cacheKey));
/// The image content.
final Future<Uint8List> image; final Future<Uint8List> image;
/// {@template NeonCachedImage.svgHint}
/// Hint whether the image is an SVG.
/// {@endtemplate}
final bool isSvgHint; final bool isSvgHint;
/// {@template NeonCachedImage.size}
/// Dimensions for the painted image.
/// {@endtemplate}
final Size? size; final Size? size;
/// {@template NeonCachedImage.fit}
/// How to inscribe the image into the space allocated during layout.
/// {@endtemplate}
final BoxFit? fit; 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; final Color? iconColor;
/// {@template NeonCachedImage.errorBuilder}
/// Builder function building the error widget.
///
/// Defaults to a [NeonError] awaiting [image] again onRetry.
/// {@endtemplate}
final ErrorWidgetBuilder? errorBuilder; final ErrorWidgetBuilder? errorBuilder;
static Future<Uint8List> _getImageFromUrl(final String url, final Account? account) async { static Future<Uint8List> _getImageFromUrl(final String url, final Account? account) async {
@ -161,7 +205,7 @@ class _NeonCachedImageState extends State<NeonCachedImage> {
height: widget.size?.height, height: widget.size?.height,
width: widget.size?.width, width: widget.size?.width,
fit: widget.fit ?? BoxFit.contain, 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 (_) { } catch (_) {
@ -192,7 +236,18 @@ class _NeonCachedImageState extends State<NeonCachedImage> {
); );
} }
/// 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 { 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({ const NeonApiImage({
required this.getImage, required this.getImage,
required this.cacheKey, required this.cacheKey,
@ -201,26 +256,64 @@ class NeonApiImage extends StatelessWidget {
this.isSvgHint = false, this.isSvgHint = false,
this.size, this.size,
this.fit, 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.iconColor,
this.errorBuilder, this.errorBuilder,
super.key, 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; final ApiImageDownloader getImage;
/// Cache key used for [NeonCachedImage.key].
final String cacheKey; final String cacheKey;
/// Custom cache reviver function.
final CacheReviver? reviver; final CacheReviver? reviver;
/// Custom cache writer function.
final CacheWriter? writeCache; final CacheWriter? writeCache;
/// {@macro NeonCachedImage.svgHint}
final bool isSvgHint; final bool isSvgHint;
/// {@macro NeonCachedImage.size}
final Size? size; final Size? size;
/// {@macro NeonCachedImage.fit}
final BoxFit? fit; final BoxFit? fit;
final Color? svgColor;
/// {@macro NeonCachedImage.iconColor}
final Color? iconColor; final Color? iconColor;
/// {@macro NeonCachedImage.errorBuilder}
final ErrorWidgetBuilder? errorBuilder; final ErrorWidgetBuilder? errorBuilder;
@override @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
final account = NeonProvider.of<AccountsBloc>(context).activeAccount.value!; final account = this.account ?? NeonProvider.of<AccountsBloc>(context).activeAccount.value!;
return NeonCachedImage.custom( return NeonCachedImage.custom(
getImage: () async { getImage: () async {
@ -228,6 +321,62 @@ class NeonApiImage extends StatelessWidget {
return response.body; return response.body;
}, },
cacheKey: '${account.id}-$cacheKey', 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,
);
}

33
packages/neon/neon/lib/src/widgets/image_wrapper.dart

@ -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,
),
),
),
),
);
}

52
packages/neon/neon/lib/src/widgets/server_icon.dart

@ -1,7 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:vector_graphics/vector_graphics.dart'; import 'package:vector_graphics/vector_graphics.dart';
/// Nextcloud server icon.
///
/// Draws an icon from the Nextcloud server icon set.
class NeonServerIcon extends StatelessWidget { class NeonServerIcon extends StatelessWidget {
/// Creates a new server icon
const NeonServerIcon({ const NeonServerIcon({
required this.icon, required this.icon,
this.color, this.color,
@ -9,18 +13,56 @@ class NeonServerIcon extends StatelessWidget {
super.key, super.key,
}); });
/// Name of the server icon to draw.
final String icon; 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 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 @override
Widget build(final BuildContext context) => VectorGraphic( Widget build(final BuildContext context) {
width: size?.width, final iconTheme = Theme.of(context).iconTheme;
height: size?.height,
colorFilter: color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null, 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( loader: AssetBytesLoader(
'assets/icons/server/${icon.replaceFirst(RegExp('^icon-'), '').replaceFirst(RegExp(r'-(dark|white)$'), '')}.svg.vec', 'assets/icons/server/${icon.replaceFirst(RegExp('^icon-'), '').replaceFirst(RegExp(r'-(dark|white)$'), '')}.svg.vec',
packageName: 'neon', packageName: 'neon',
), ),
); );
}
} }

8
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/models/account.dart';
import 'package:neon/src/theme/sizes.dart'; import 'package:neon/src/theme/sizes.dart';
import 'package:neon/src/utils/provider.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/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/linear_progress_indicator.dart';
import 'package:neon/src/widgets/list_view.dart'; import 'package:neon/src/widgets/list_view.dart';
import 'package:neon/src/widgets/server_icon.dart'; import 'package:neon/src/widgets/server_icon.dart';
@ -94,7 +93,6 @@ class NeonUnifiedSearchResults extends StatelessWidget {
ListTile( ListTile(
leading: NeonImageWrapper( leading: NeonImageWrapper(
size: const Size.square(largeIconSize), size: const Size.square(largeIconSize),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _buildThumbnail(context, accountsBloc.activeAccount.value!, entry), child: _buildThumbnail(context, accountsBloc.activeAccount.value!, entry),
), ),
title: Text(entry.title), title: Text(entry.title),
@ -129,10 +127,9 @@ class NeonUnifiedSearchResults extends StatelessWidget {
final Account account, final Account account,
final CoreUnifiedSearchResultEntry entry, final CoreUnifiedSearchResultEntry entry,
) { ) {
final size = Size.square(IconTheme.of(context).size!);
if (entry.icon.startsWith('/')) { if (entry.icon.startsWith('/')) {
return NeonCachedImage.url( return NeonCachedImage.url(
size: size, size: Size.square(IconTheme.of(context).size!),
url: entry.icon, url: entry.icon,
account: account, account: account,
); );
@ -140,7 +137,6 @@ class NeonUnifiedSearchResults extends StatelessWidget {
if (entry.icon.startsWith('icon-')) { if (entry.icon.startsWith('icon-')) {
return NeonServerIcon( return NeonServerIcon(
size: size,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
icon: entry.icon, icon: entry.icon,
); );

20
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 'dart:async';
import 'package:flutter/material.dart'; 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/models/account.dart';
import 'package:neon/src/theme/sizes.dart'; import 'package:neon/src/theme/sizes.dart';
import 'package:neon/src/utils/provider.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:neon/src/widgets/server_icon.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@ -61,20 +58,18 @@ class _UserAvatarState extends State<NeonUserAvatar> {
radius: size / 2, radius: size / 2,
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
child: ClipOval( child: ClipOval(
child: NeonCachedImage.custom( child: NeonApiImage.custom(
cacheKey: '${widget.account.id}-avatar-${widget.username}-$brightness$pixelSize', account: widget.account,
getImage: () async { cacheKey: 'avatar-${widget.username}-$brightness$pixelSize',
final response = switch (brightness) { getImage: (final client) async => switch (brightness) {
Brightness.dark => await widget.account.client.core.avatar.getAvatarDark( Brightness.dark => client.core.avatar.getAvatarDark(
userId: widget.username, userId: widget.username,
size: pixelSize, size: pixelSize,
), ),
Brightness.light => await widget.account.client.core.avatar.getAvatar( Brightness.light => client.core.avatar.getAvatar(
userId: widget.username, userId: widget.username,
size: pixelSize, size: pixelSize,
), ),
};
return response.body;
}, },
), ),
), ),
@ -83,7 +78,6 @@ class _UserAvatarState extends State<NeonUserAvatar> {
if (!widget.showStatus) { if (!widget.showStatus) {
return avatar; return avatar;
} }
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [

3
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/dialog.dart';
export 'package:neon/src/widgets/error.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/linear_progress_indicator.dart';
export 'package:neon/src/widgets/list_view.dart'; export 'package:neon/src/widgets/list_view.dart';
export 'package:neon/src/widgets/relative_time.dart'; export 'package:neon/src/widgets/relative_time.dart';

5
packages/neon/neon_news/lib/widgets/feeds_view.dart

@ -53,10 +53,7 @@ class NewsFeedsView extends StatelessWidget {
subtitle: feed.unreadCount! > 0 subtitle: feed.unreadCount! > 0
? Text(AppLocalizations.of(context).articlesUnread(feed.unreadCount!)) ? Text(AppLocalizations.of(context).articlesUnread(feed.unreadCount!))
: const SizedBox(), : const SizedBox(),
leading: NewsFeedIcon( leading: NewsFeedIcon(feed: feed),
feed: feed,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

2
packages/neon/neon_notifications/lib/pages/main.dart

@ -80,7 +80,7 @@ class _NotificationsMainPageState extends State<NotificationsMainPage> {
child: NeonCachedImage.url( child: NeonCachedImage.url(
url: notification.icon!, url: notification.icon!,
size: const Size.square(largeIconSize), size: const Size.square(largeIconSize),
svgColor: Theme.of(context).colorScheme.primary, iconColor: Theme.of(context).colorScheme.primary,
), ),
), ),
onTap: () async { onTap: () async {

Loading…
Cancel
Save