raster image background

This commit is contained in:
Thibault Deckers 2020-12-23 16:00:46 +09:00
parent 5e7c85597a
commit 07b9db6750
9 changed files with 345 additions and 198 deletions

View file

@ -63,6 +63,8 @@ class ImageEntry {
bool get canDecode => !undecodable.contains(mimeType);
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
ImageEntry copyWith({
@required String uri,
@required String path,

View file

@ -55,6 +55,7 @@ class Settings extends ChangeNotifier {
static const coordinateFormatKey = 'coordinates_format';
// rendering
static const rasterBackgroundKey = 'raster_background';
static const vectorBackgroundKey = 'vector_background';
// search
@ -185,6 +186,10 @@ class Settings extends ChangeNotifier {
// rendering
EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values);
set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());
EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values);
set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString());

View file

@ -1,15 +1,17 @@
class MimeTypes {
static const anyImage = 'image/*';
static const bmp = 'image/bmp';
static const gif = 'image/gif';
static const heic = 'image/heic';
static const heif = 'image/heif';
static const ico = 'image/x-icon';
static const jpeg = 'image/jpeg';
static const png = 'image/png';
static const svg = 'image/svg+xml';
static const tiff = 'image/tiff';
static const webp = 'image/webp';
static const tiff = 'image/tiff';
static const psd = 'image/vnd.adobe.photoshop';
static const arw = 'image/x-sony-arw';
@ -40,6 +42,10 @@ class MimeTypes {
static const mp4 = 'video/mp4';
// groups
// formats that support transparency
static const List<String> alphaImages = [bmp, gif, ico, png, svg, tiff, webp];
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
static bool isImage(String mimeType) => mimeType.startsWith('image');

View file

@ -15,6 +15,7 @@ class XMP {
'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP',
'GCamera': 'Google Camera',
'GFocus': 'Google Focus',
'GPano': 'Google Panorama',
'illustrator': 'Illustrator',

View file

@ -1,7 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.dart';
@ -30,6 +28,8 @@ class ImageView extends StatefulWidget {
final List<Tuple2<String, IjkMediaController>> videoControllers;
final VoidCallback onDisposed;
static const decorationCheckSize = 20.0;
const ImageView({
Key key,
@required this.entry,
@ -56,8 +56,6 @@ class _ImageViewState extends State<ImageView> {
MagnifierTapCallback get onTap => widget.onTap;
static const decorationCheckSize = 20.0;
@override
void initState() {
super.initState();
@ -98,25 +96,6 @@ class _ImageViewState extends State<ImageView> {
: child;
}
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
// is laid the same way as the final image when `contained`
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: imageProvider,
fit: BoxFit.fill,
),
),
);
}
Widget _buildRasterView() {
return Magnifier(
// key includes size and orientation to refresh when the image is rotated
@ -124,7 +103,6 @@ class _ImageViewState extends State<ImageView> {
child: TiledImageView(
entry: entry,
viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider),
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
),
childSize: entry.displaySize,
@ -165,11 +143,11 @@ class _ImageViewState extends State<ImageView> {
if (viewportSize == null) return child;
final side = viewportSize.shortestSide;
final checkSize = side / ((side / decorationCheckSize).round());
final checkSize = side / ((side / ImageView.decorationCheckSize).round());
final viewSize = entry.displaySize * viewState.scale;
final decorationSize = Size(min(viewSize.width, viewportSize.width), min(viewSize.height, viewportSize.height));
final offset = Offset(decorationSize.width - viewportSize.width, decorationSize.height - viewportSize.height) / 2;
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
final offset = ((decorationSize - viewportSize) as Offset) / 2;
return Stack(
alignment: Alignment.center,

View file

@ -1,23 +1,26 @@
import 'dart:math';
import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
class TiledImageView extends StatefulWidget {
final ImageEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final Widget baseChild;
final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({
@required this.entry,
@required this.viewStateNotifier,
@required this.baseChild,
@required this.errorBuilder,
});
@ -26,159 +29,244 @@ class TiledImageView extends StatefulWidget {
}
class _TiledImageViewState extends State<TiledImageView> {
bool _initialized = false;
double _tileSide, _initialScale;
bool _isTilingInitialized = false;
int _maxSampleSize;
Matrix4 _transform;
double _tileSide;
Matrix4 _tileTransform;
ImageStream _fullImageStream;
ImageStreamListener _fullImageListener;
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
ImageEntry get entry => widget.entry;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get fullImage => UriImage(
ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
ImageProvider get fullImageProvider {
if (useTiles) {
assert(_isTilingInitialized);
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
final viewState = viewStateNotifier.value;
final regionRect = _getTileRects(
x: 0,
y: 0,
layerRegionWidth: displayWidth,
layerRegionHeight: displayHeight,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: viewState.scale,
viewRect: _getViewRect(viewState, displayWidth, displayHeight),
).item2;
return RegionProvider(RegionProviderKey.fromEntry(
entry,
sampleSize: _maxSampleSize,
rect: regionRect,
));
} else {
return UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
}
}
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
@override
void initState() {
super.initState();
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!useTiles) _registerFullImage();
}
@override
void didUpdateWidget(TiledImageView oldWidget) {
super.didUpdateWidget(oldWidget);
final oldViewState = oldWidget.viewStateNotifier.value;
final viewState = widget.viewStateNotifier.value;
if (oldViewState.viewportSize != viewState.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
_initialized = false;
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
_isTilingInitialized = false;
_fullImageLoaded.value = false;
_unregisterFullImage();
}
}
void _initFromViewport(Size viewportSize) {
final displaySize = entry.displaySize;
_tileSide = viewportSize.shortestSide * scaleFactor;
_initialScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height);
_maxSampleSize = _sampleSizeForScale(_initialScale);
@override
void dispose() {
_unregisterFullImage();
super.dispose();
}
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_transform = null;
if (rotationDegrees != 0 || isFlipped) {
_transform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
}
_initialized = true;
void _registerFullImage() {
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
_fullImageStream.addListener(_fullImageListener);
}
void _unregisterFullImage() {
_fullImageStream?.removeListener(_fullImageListener);
_fullImageStream = null;
}
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
_unregisterFullImage();
_fullImageLoaded.value = true;
}
@override
Widget build(BuildContext context) {
if (viewStateNotifier == null) return SizedBox.shrink();
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
return ValueListenableBuilder<ViewState>(
valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return SizedBox.shrink();
if (!_initialized) _initFromViewport(viewportSize);
final viewportSized = viewportSize != null;
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize);
var scale = viewState.scale;
if (scale == 0.0) {
// for initial scale as `contained`
scale = _initialScale;
}
final scaledSize = entry.displaySize * scale;
final loading = SizedBox(
width: scaledSize.width,
height: scaledSize.height,
child: widget.baseChild,
);
List<Widget> children;
if (useTiles) {
children = [
loading,
..._getTiles(viewState, displayWidth, displayHeight, scale),
];
} else {
children = [
if (!imageCache.statusForKey(fullImage).keepAlive) loading,
Image(
image: fullImage,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: scaledSize.width,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
)
];
}
return Stack(
alignment: Alignment.center,
children: children,
return SizedBox.fromSize(
size: entry.displaySize * viewState.scale,
child: Stack(
alignment: Alignment.center,
children: [
if (useBackground && viewportSized) _buildBackground(viewState),
_buildLoading(viewState),
if (useTiles) ..._getTiles(viewState),
if (!useTiles)
Image(
image: fullImageProvider,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: (entry.displaySize * viewState.scale).width,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
],
),
);
},
);
}
List<RegionTile> _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) {
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
void _initTiling(Size viewportSize) {
final displaySize = entry.displaySize;
_tileSide = viewportSize.shortestSide * scaleFactor;
// scale for initial state `contained`
final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height);
_maxSampleSize = _sampleSizeForScale(containedScale);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_tileTransform = null;
if (rotationDegrees != 0 || isFlipped) {
_tileTransform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
}
_isTilingInitialized = true;
_registerFullImage();
}
Widget _buildLoading(ViewState viewState) {
return ValueListenableBuilder(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (fullImageLoaded) return SizedBox.shrink();
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: thumbnailProvider,
fit: BoxFit.fill,
),
),
);
},
);
final viewRect = viewOrigin & viewportSize;
}
Widget _buildBackground(ViewState viewState) {
final viewportSize = viewState.viewportSize;
assert(viewportSize != null);
final viewSize = entry.displaySize * viewState.scale;
final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
Decoration decoration;
final background = settings.rasterBackground;
if (background == EntryBackground.checkered) {
final side = viewportSize.shortestSide;
final checkSize = side / ((side / ImageView.decorationCheckSize).round());
final offset = ((decorationSize - viewportSize) as Offset) / 2;
decoration = CheckeredDecoration(
checkSize: checkSize,
offset: offset,
);
} else {
decoration = BoxDecoration(
color: background.color,
);
}
return Positioned(
left: decorationOffset.dx >= 0 ? decorationOffset.dx : null,
top: decorationOffset.dy >= 0 ? decorationOffset.dy : null,
width: decorationSize.width,
height: decorationSize.height,
child: DecoratedBox(
decoration: decoration,
),
);
}
List<Widget> _getTiles(ViewState viewState) {
if (!_isTilingInitialized) return [];
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
final viewRect = _getViewRect(viewState, displayWidth, displayHeight);
final scale = viewState.scale;
final tiles = <RegionTile>[];
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) {
// for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image instead of splitting it in tiles
final useTiles = sampleSize != _maxSampleSize;
// so we subsample the whole image without tiling
final fullImageRegion = sampleSize == _maxSampleSize;
final regionSide = (_tileSide * sampleSize).round();
final layerRegionWidth = useTiles ? regionSide : displayWidth;
final layerRegionHeight = useTiles ? regionSide : displayHeight;
final layerRegionWidth = fullImageRegion ? displayWidth : regionSide;
final layerRegionHeight = fullImageRegion ? displayHeight : regionSide;
for (var x = 0; x < displayWidth; x += layerRegionWidth) {
for (var y = 0; y < displayHeight; y += layerRegionHeight) {
final nextX = x + layerRegionWidth;
final nextY = y + layerRegionHeight;
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (viewRect.overlaps(tileRect)) {
Rectangle<int> regionRect;
if (_transform != null) {
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight);
regionRect = Rectangle<int>.fromPoints(
Point<int>(tl.dx.round(), tl.dy.round()),
Point<int>(br.dx.round(), br.dy.round()),
);
} else {
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
}
final rects = _getTileRects(
x: x,
y: y,
layerRegionWidth: layerRegionWidth,
layerRegionHeight: layerRegionHeight,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: scale,
viewRect: viewRect,
);
if (rects != null) {
tiles.add(RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
tileRect: rects.item1,
regionRect: rects.item2,
sampleSize: sampleSize,
));
}
@ -188,6 +276,52 @@ class _TiledImageViewState extends State<TiledImageView> {
return tiles;
}
Rect _getViewRect(ViewState viewState, int displayWidth, int displayHeight) {
final scale = viewState.scale;
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
return viewOrigin & viewportSize;
}
Tuple2<Rect, Rectangle<int>> _getTileRects({
@required int x,
@required int y,
@required int layerRegionWidth,
@required int layerRegionHeight,
@required int displayWidth,
@required int displayHeight,
@required double scale,
@required Rect viewRect,
}) {
final nextX = x + layerRegionWidth;
final nextY = y + layerRegionHeight;
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (!viewRect.overlaps(tileRect)) return null;
Rectangle<int> regionRect;
if (_tileTransform != null) {
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.bottomRight);
regionRect = Rectangle<int>.fromPoints(
Point<int>(tl.dx.round(), tl.dy.round()),
Point<int>(br.dx.round(), br.dy.round()),
);
} else {
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
}
return Tuple2<Rect, Rectangle<int>>(tileRect, regionRect);
}
int _sampleSizeForScale(double scale) {
var sample = 0;
if (0 < scale && scale < 1) {

View file

@ -0,0 +1,78 @@
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart';
class EntryBackgroundSelector extends StatefulWidget {
final ValueGetter<EntryBackground> getter;
final ValueSetter<EntryBackground> setter;
const EntryBackgroundSelector({
@required this.getter,
@required this.setter,
});
@override
_EntryBackgroundSelectorState createState() => _EntryBackgroundSelectorState();
}
class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: DropdownButton<EntryBackground>(
items: _buildItems(context),
value: widget.getter(),
onChanged: (selected) {
widget.setter(selected);
setState(() {});
},
),
);
}
List<DropdownMenuItem<EntryBackground>> _buildItems(BuildContext context) {
const radius = 12.0;
return [
EntryBackground.white,
EntryBackground.black,
EntryBackground.checkered,
EntryBackground.transparent,
].map((selected) {
Widget child;
switch (selected) {
case EntryBackground.transparent:
child = Icon(
Icons.clear,
size: 20,
color: Colors.white30,
);
break;
case EntryBackground.checkered:
child = ClipOval(
child: DecoratedBox(
decoration: CheckeredDecoration(
checkSize: radius,
),
),
);
break;
default:
break;
}
return DropdownMenuItem<EntryBackground>(
value: selected,
child: Container(
height: radius * 2,
width: radius * 2,
decoration: BoxDecoration(
color: selected.isColor ? selected.color : null,
border: AvesCircleBorder.build(context),
shape: BoxShape.circle,
),
child: child,
),
);
}).toList();
}
}

View file

@ -8,7 +8,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/svg_background.dart';
import 'package:aves/widgets/settings/entry_background.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
@ -115,8 +115,18 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
ListTile(
title: Text('SVG background'),
trailing: SvgBackgroundSelector(),
title: Text('Raster image background'),
trailing: EntryBackgroundSelector(
getter: () => settings.rasterBackground,
setter: (value) => settings.rasterBackground = value,
),
),
ListTile(
title: Text('Vector image background'),
trailing: EntryBackgroundSelector(
getter: () => settings.vectorBackground,
setter: (value) => settings.vectorBackground = value,
),
),
ListTile(
title: Text('Coordinate format'),

View file

@ -1,67 +0,0 @@
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart';
class SvgBackgroundSelector extends StatefulWidget {
@override
_SvgBackgroundSelectorState createState() => _SvgBackgroundSelectorState();
}
class _SvgBackgroundSelectorState extends State<SvgBackgroundSelector> {
@override
Widget build(BuildContext context) {
const radius = 12.0;
return DropdownButtonHideUnderline(
child: DropdownButton<EntryBackground>(
items: [
EntryBackground.white,
EntryBackground.black,
EntryBackground.checkered,
EntryBackground.transparent,
].map((selected) {
Widget child;
switch (selected) {
case EntryBackground.transparent:
child = Icon(
Icons.clear,
size: 20,
color: Colors.white30,
);
break;
case EntryBackground.checkered:
child = ClipOval(
child: DecoratedBox(
decoration: CheckeredDecoration(
checkSize: radius,
),
),
);
break;
default:
break;
}
return DropdownMenuItem<EntryBackground>(
value: selected,
child: Container(
height: radius * 2,
width: radius * 2,
decoration: BoxDecoration(
color: selected.isColor ? selected.color : null,
border: AvesCircleBorder.build(context),
shape: BoxShape.circle,
),
child: child,
),
);
}).toList(),
value: settings.vectorBackground,
onChanged: (selected) {
settings.vectorBackground = selected;
setState(() {});
},
),
);
}
}