602 lines
20 KiB
Text
602 lines
20 KiB
Text
// lib/widgets/viewer/visual/raster.dart
|
||
import 'dart:math';
|
||
|
||
import 'package:aves/image_providers/region_provider.dart';
|
||
import 'package:aves/model/entry/entry.dart';
|
||
import 'package:aves/model/entry/extensions/images.dart';
|
||
import 'package:aves/model/entry/extensions/props.dart';
|
||
import 'package:aves/model/settings/enums/entry_background.dart';
|
||
import 'package:aves/model/settings/settings.dart';
|
||
import 'package:aves/model/viewer/view_state.dart';
|
||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||
import 'package:aves/widgets/viewer/controls/notifications.dart';
|
||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||
import 'package:aves_model/aves_model.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:latlong2/latlong.dart';
|
||
import 'package:aves/remote/remote_http.dart';
|
||
|
||
class RasterImageView extends StatefulWidget {
|
||
final AvesEntry entry;
|
||
final ValueNotifier<ViewState> viewStateNotifier;
|
||
final ImageErrorWidgetBuilder errorBuilder;
|
||
|
||
const RasterImageView({
|
||
super.key,
|
||
required this.entry,
|
||
required this.viewStateNotifier,
|
||
required this.errorBuilder,
|
||
});
|
||
|
||
@override
|
||
State<RasterImageView> createState() => _RasterImageViewState();
|
||
}
|
||
|
||
class _RasterImageViewState extends State<RasterImageView> {
|
||
late Size _displaySize;
|
||
late bool _useTiles;
|
||
bool _isTilingInitialized = false;
|
||
late int _maxSampleSize;
|
||
late double _tileSide;
|
||
Matrix4? _tileTransform;
|
||
ImageStream? _fullImageStream;
|
||
late ImageStreamListener _fullImageListener;
|
||
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
|
||
ImageInfo? _fullImageInfo;
|
||
|
||
static const int _pixelArtMaxSize = 256; // px
|
||
static const double _tilesByShortestSide = 2;
|
||
|
||
AvesEntry get entry => widget.entry;
|
||
|
||
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
||
|
||
ViewState get viewState => viewStateNotifier.value;
|
||
|
||
ImageProvider get thumbnailProvider => entry.bestCachedThumbnail;
|
||
|
||
bool get _isRemote => entry.origin == 1;
|
||
|
||
// === FULL IMAGE: provider sincrono (necessario per il listener)
|
||
ImageProvider get fullImageProvider {
|
||
if (_isRemote) {
|
||
final abs = RemoteHttp.absUrl(entry.remotePath ?? entry.path);
|
||
final hdrs = RemoteHttp.peekHeaders();
|
||
return NetworkImage(abs, headers: hdrs.isEmpty ? null : hdrs);
|
||
}
|
||
|
||
if (_useTiles) {
|
||
assert(_isTilingInitialized);
|
||
return entry.getRegion(
|
||
sampleSize: _maxSampleSize,
|
||
region: entry.fullImageRegion,
|
||
);
|
||
} else {
|
||
return entry.fullImage;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_displaySize = entry.displaySize;
|
||
// REMOTO: disabilita tiling per i remoti
|
||
_useTiles = _isRemote ? false : entry.useTiles;
|
||
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||
if (!_useTiles) _registerFullImage();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant RasterImageView oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
final oldViewState = oldWidget.viewStateNotifier.value;
|
||
final viewState = widget.viewStateNotifier.value;
|
||
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
|
||
_isTilingInitialized = false;
|
||
_fullImageLoaded.value = false;
|
||
_unregisterFullImage();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_fullImageLoaded.dispose();
|
||
_unregisterFullImage();
|
||
super.dispose();
|
||
}
|
||
|
||
void _registerFullImage() {
|
||
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
|
||
_fullImageStream!.addListener(_fullImageListener);
|
||
}
|
||
|
||
void _unregisterFullImage() {
|
||
_fullImageStream?.removeListener(_fullImageListener);
|
||
_fullImageStream = null;
|
||
_fullImageInfo?.dispose();
|
||
_fullImageInfo = null;
|
||
}
|
||
|
||
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
|
||
// implementer is responsible for disposing the provided `ImageInfo`
|
||
_unregisterFullImage();
|
||
_fullImageInfo = image;
|
||
_fullImageLoaded.value = true;
|
||
|
||
// ⚠️ REMOTO: NON aggiornare _displaySize quando arriva il full.
|
||
// Manteniamo quella di entry.displaySize (remoteWidth/remoteHeight),
|
||
// così il Magnifier non ricompone i limiti e non c'è "shrink".
|
||
FullImageLoadedNotification(entry, fullImageProvider).dispatch(context);
|
||
}
|
||
|
||
// REMOTO: non applicare mai scala > 1.0 (evita “partenza zoomata”)
|
||
double _effectiveScale(double raw) => _isRemote ? min(raw, 1.0) : raw;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ValueListenableBuilder<ViewState>(
|
||
valueListenable: viewStateNotifier,
|
||
builder: (context, viewState, child) {
|
||
final viewportSize = viewState.viewportSize;
|
||
final viewportSized = viewportSize?.isEmpty == false;
|
||
if (viewportSized && _useTiles && !_isTilingInitialized) _initTiling(viewportSize!);
|
||
|
||
final magnifierScale = _effectiveScale(viewState.scale!);
|
||
final sized = _displaySize * magnifierScale;
|
||
|
||
return SizedBox.fromSize(
|
||
// evita 0×0 che può portare a overflow numerici in fase di layout
|
||
size: Size(max(1, sized.width), max(1, sized.height)),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
if (entry.canHaveAlpha && viewportSized) _buildBackground(magnifierScale),
|
||
_buildLoading(), // placeholder (anche remoto)
|
||
if (_useTiles) ..._buildTiles() else _buildFullImage(magnifierScale),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// === FULL IMAGE (widget) ===
|
||
Widget _buildFullImage(double magnifierScale) {
|
||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||
final quality = _qualityForScaleAndSize(
|
||
magnifierScale: magnifierScale,
|
||
sampleSize: 1,
|
||
devicePixelRatio: devicePixelRatio,
|
||
);
|
||
|
||
Widget img = Image(
|
||
image: fullImageProvider,
|
||
gaplessPlayback: true,
|
||
errorBuilder: widget.errorBuilder,
|
||
width: (_displaySize * magnifierScale).width,
|
||
fit: BoxFit.contain, // naturale: niente crop
|
||
filterQuality: quality,
|
||
);
|
||
|
||
// EXIF solo per locali (per i remoti è neutralizzato in entry.dart)
|
||
if (!_isRemote) {
|
||
if (entry.isFlipped) {
|
||
final rotated = (entry.rotationDegrees ~/ 90) % 2 != 0;
|
||
final w = (rotated ? (_displaySize.height * magnifierScale) : (_displaySize.width * magnifierScale)) / 2.0;
|
||
final h = (rotated ? (_displaySize.width * magnifierScale) : (_displaySize.height * magnifierScale)) / 2.0;
|
||
final flipper = Matrix4.identity()
|
||
..translateByDouble(w, h, 0, 1)
|
||
..scaleByDouble(-1.0, 1.0, 1.0, 1.0)
|
||
..translateByDouble(-w, -h, 0, 1);
|
||
img = Transform(transform: flipper, child: img);
|
||
}
|
||
final quarterTurns = (entry.rotationDegrees ~/ 90) % 4;
|
||
if (quarterTurns != 0) {
|
||
img = RotatedBox(quarterTurns: quarterTurns, child: img);
|
||
}
|
||
}
|
||
|
||
return img;
|
||
}
|
||
|
||
// === LOADING / PLACEHOLDER ===
|
||
Widget _buildLoading() {
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: _fullImageLoaded,
|
||
builder: (context, fullImageLoaded, child) {
|
||
if (fullImageLoaded) return const SizedBox();
|
||
|
||
// Per i remoti, mostra thumb remoto (o path) via HTTP + header async
|
||
if (_isRemote) {
|
||
final rel = entry.remoteThumb2 ?? entry.remoteThumb1 ?? entry.remotePath ?? entry.path;
|
||
if (rel != null && rel.isNotEmpty) {
|
||
final url = RemoteHttp.absUrl(rel);
|
||
return AspectRatio(
|
||
aspectRatio: entry.displayAspectRatio,
|
||
child: FutureBuilder<Map<String, String>>(
|
||
future: RemoteHttp.headers(),
|
||
builder: (context, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return const ColoredBox(color: Colors.black12);
|
||
}
|
||
final hdrs = snap.data ?? const {};
|
||
return Image.network(
|
||
url,
|
||
fit: BoxFit.contain, // evita “fill” (effetto zoomato) in loading
|
||
headers: hdrs.isEmpty ? null : hdrs,
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Default: usa il thumbnail “migliore” in cache locale
|
||
return Center(
|
||
child: AspectRatio(
|
||
// mantieni l'aspect ratio originale
|
||
aspectRatio: entry.displayAspectRatio,
|
||
child: Image(
|
||
image: thumbnailProvider,
|
||
fit: BoxFit.contain,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _initTiling(Size viewportSize) {
|
||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||
_tileSide = viewportSize.shortestSide * devicePixelRatio / _tilesByShortestSide;
|
||
// scale for initial state `contained`
|
||
final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height);
|
||
_maxSampleSize = ExtraAvesEntryImages.sampleSizeForScale(
|
||
magnifierScale: containedScale,
|
||
devicePixelRatio: devicePixelRatio,
|
||
);
|
||
|
||
final rotationDegrees = entry.rotationDegrees;
|
||
final isFlipped = entry.isFlipped;
|
||
_tileTransform = null;
|
||
if (rotationDegrees != 0 || isFlipped) {
|
||
_tileTransform = Matrix4.identity()
|
||
..translateByDouble(entry.width / 2.0, entry.height / 2.0, 0, 1)
|
||
..scaleByDouble(isFlipped ? -1.0 : 1.0, 1.0, 1.0, 1.0)
|
||
..rotateZ(-degToRadian(rotationDegrees.toDouble()))
|
||
..translateByDouble(-_displaySize.width / 2.0, -_displaySize.height / 2.0, 0, 1);
|
||
}
|
||
_isTilingInitialized = true;
|
||
_registerFullImage();
|
||
}
|
||
|
||
Widget _buildBackground(double magnifierScale) {
|
||
final viewportSize = viewState.viewportSize!;
|
||
final viewSize = _displaySize * magnifierScale;
|
||
final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
|
||
// deflate come “quick way” per evitare bleed
|
||
final decorationSize = (applyBoxFit(BoxFit.none, viewSize, viewportSize).source - const Offset(.5, .5)) as Size;
|
||
|
||
Widget child;
|
||
final background = settings.imageBackground;
|
||
if (background == EntryBackground.checkered) {
|
||
final side = viewportSize.shortestSide;
|
||
final checkSize = side / ((side / EntryPageView.decorationCheckSize).round());
|
||
final offset = ((decorationSize - viewportSize) as Offset) / 2;
|
||
child = ValueListenableBuilder<bool>(
|
||
valueListenable: _fullImageLoaded,
|
||
builder: (context, fullImageLoaded, child) {
|
||
if (!fullImageLoaded) return const SizedBox();
|
||
|
||
return CustomPaint(
|
||
painter: CheckeredPainter(
|
||
checkSize: checkSize,
|
||
offset: offset,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
} else {
|
||
child = DecoratedBox(
|
||
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: child,
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildTiles() {
|
||
if (!_isTilingInitialized) return [];
|
||
|
||
final displayWidth = _displaySize.width.round();
|
||
final displayHeight = _displaySize.height.round();
|
||
final viewRect = _getViewRect(displayWidth, displayHeight);
|
||
final magnifierScale = viewState.scale!;
|
||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||
|
||
// per la sample size massima (scala iniziale), l’intera immagine è in view
|
||
final fullImageRegionTile = _RegionTile(
|
||
entry: entry,
|
||
tileRect: Rect.fromLTWH(0, 0, displayWidth * magnifierScale, displayHeight * magnifierScale),
|
||
regionRect: entry.fullImageRegion,
|
||
sampleSize: _maxSampleSize,
|
||
quality: _qualityForScaleAndSize(
|
||
magnifierScale: magnifierScale,
|
||
sampleSize: _maxSampleSize,
|
||
devicePixelRatio: devicePixelRatio,
|
||
),
|
||
);
|
||
final tiles = [fullImageRegionTile];
|
||
|
||
final minSampleSize = min(
|
||
ExtraAvesEntryImages.sampleSizeForScale(
|
||
magnifierScale: magnifierScale,
|
||
devicePixelRatio: devicePixelRatio,
|
||
),
|
||
_maxSampleSize,
|
||
);
|
||
|
||
int nextSampleSize(int sampleSize) => (sampleSize / 2).floor();
|
||
for (var sampleSize = nextSampleSize(_maxSampleSize); sampleSize >= minSampleSize; sampleSize = nextSampleSize(sampleSize)) {
|
||
final regionSide = (_tileSide * sampleSize).round();
|
||
for (var x = 0; x < displayWidth; x += regionSide) {
|
||
for (var y = 0; y < displayHeight; y += regionSide) {
|
||
final rects = _getTileRects(
|
||
x: x,
|
||
y: y,
|
||
regionSide: regionSide,
|
||
displayWidth: displayWidth,
|
||
displayHeight: displayHeight,
|
||
scale: magnifierScale,
|
||
viewRect: viewRect,
|
||
);
|
||
if (rects != null) {
|
||
final (tileRect, regionRect) = rects;
|
||
tiles.add(
|
||
_RegionTile(
|
||
entry: entry,
|
||
tileRect: tileRect,
|
||
regionRect: regionRect,
|
||
sampleSize: sampleSize,
|
||
quality: _qualityForScaleAndSize(
|
||
magnifierScale: magnifierScale,
|
||
sampleSize: sampleSize,
|
||
devicePixelRatio: devicePixelRatio,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return tiles;
|
||
}
|
||
|
||
Rect _getViewRect(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;
|
||
}
|
||
|
||
(Rect tileRect, Rectangle<num> regionRect)? _getTileRects({
|
||
required int x,
|
||
required int y,
|
||
required int regionSide,
|
||
required int displayWidth,
|
||
required int displayHeight,
|
||
required double scale,
|
||
required Rect viewRect,
|
||
}) {
|
||
final nextX = x + regionSide;
|
||
final nextY = y + regionSide;
|
||
final thisRegionWidth = regionSide - (nextX >= displayWidth ? nextX - displayWidth : 0);
|
||
final thisRegionHeight = regionSide - (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<num> 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<double>.fromPoints(
|
||
Point<double>(tl.dx, tl.dy),
|
||
Point<double>(br.dx, br.dy),
|
||
);
|
||
} else {
|
||
regionRect = Rectangle<num>(x, y, thisRegionWidth, thisRegionHeight);
|
||
}
|
||
return (tileRect, regionRect);
|
||
}
|
||
|
||
// follow recommended thresholds from `FilterQuality` documentation
|
||
static FilterQuality _qualityForScale({
|
||
required double magnifierScale,
|
||
required int sampleSize,
|
||
required double devicePixelRatio,
|
||
}) {
|
||
final entryScale = magnifierScale * devicePixelRatio;
|
||
final renderingScale = entryScale * sampleSize;
|
||
if (renderingScale > 1) {
|
||
return renderingScale > 10 ? FilterQuality.high : FilterQuality.medium;
|
||
} else {
|
||
return renderingScale < .5 ? FilterQuality.medium : FilterQuality.high;
|
||
}
|
||
}
|
||
|
||
// usually follow recommendations, except for small images
|
||
// (like icons, pixel art, etc.) for which the "nearest neighbor" algorithm is used
|
||
FilterQuality _qualityForScaleAndSize({
|
||
required double magnifierScale,
|
||
required int sampleSize,
|
||
required double devicePixelRatio,
|
||
}) {
|
||
if (_displaySize.longestSide < _pixelArtMaxSize) {
|
||
return FilterQuality.none;
|
||
}
|
||
|
||
return _qualityForScale(
|
||
magnifierScale: magnifierScale,
|
||
sampleSize: sampleSize,
|
||
devicePixelRatio: devicePixelRatio,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RegionTile extends StatefulWidget {
|
||
final AvesEntry entry;
|
||
|
||
// `tileRect` uses Flutter view coordinates
|
||
// `regionRect` uses the raw image pixel coordinates
|
||
final Rect tileRect;
|
||
final Rectangle<num> regionRect;
|
||
final int sampleSize;
|
||
final FilterQuality quality;
|
||
|
||
const _RegionTile({
|
||
required this.entry,
|
||
required this.tileRect,
|
||
required this.regionRect,
|
||
required this.sampleSize,
|
||
required this.quality,
|
||
});
|
||
|
||
@override
|
||
State<_RegionTile> createState() => _RegionTileState();
|
||
|
||
@override
|
||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||
super.debugFillProperties(properties);
|
||
properties.add(IntProperty('id', entry.id));
|
||
properties.add(IntProperty('contentId', entry.contentId));
|
||
properties.add(DiagnosticsProperty<Rect>('tileRect', tileRect));
|
||
properties.add(DiagnosticsProperty<Rectangle<num>>('regionRect', regionRect));
|
||
properties.add(IntProperty('sampleSize', sampleSize));
|
||
}
|
||
}
|
||
|
||
class _RegionTileState extends State<_RegionTile> {
|
||
late RegionProvider _provider;
|
||
|
||
AvesEntry get entry => widget.entry;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_registerWidget(widget);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _RegionTile oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.entry != widget.entry ||
|
||
oldWidget.tileRect != widget.tileRect ||
|
||
oldWidget.sampleSize != widget.sampleSize ||
|
||
oldWidget.sampleSize != widget.sampleSize) {
|
||
_unregisterWidget(oldWidget);
|
||
_registerWidget(widget);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unregisterWidget(widget);
|
||
super.dispose();
|
||
}
|
||
|
||
void _registerWidget(_RegionTile widget) {
|
||
_initProvider();
|
||
}
|
||
|
||
void _unregisterWidget(_RegionTile widget) {
|
||
_pauseProvider();
|
||
}
|
||
|
||
void _initProvider() {
|
||
_provider = entry.getRegion(
|
||
sampleSize: widget.sampleSize,
|
||
region: widget.regionRect,
|
||
);
|
||
}
|
||
|
||
void _pauseProvider() => _provider.pause();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tileRect = widget.tileRect;
|
||
|
||
Widget child = Image(
|
||
image: _provider,
|
||
width: tileRect.width,
|
||
height: tileRect.height,
|
||
fit: BoxFit.fill,
|
||
filterQuality: widget.quality,
|
||
);
|
||
|
||
// apply EXIF orientation (tile path locale)
|
||
final quarterTurns = entry.rotationDegrees ~/ 90;
|
||
if (entry.isFlipped) {
|
||
final rotated = quarterTurns % 2 != 0;
|
||
final w = (rotated ? tileRect.height : tileRect.width) / 2.0;
|
||
final h = (rotated ? tileRect.width : tileRect.height) / 2.0;
|
||
final flipper = Matrix4.identity()
|
||
..translateByDouble(w, h, 0, 1)
|
||
..scaleByDouble(-1.0, 1.0, 1.0, 1.0)
|
||
..translateByDouble(-w, -h, 0, 1);
|
||
child = Transform(
|
||
transform: flipper,
|
||
child: child,
|
||
);
|
||
}
|
||
if (quarterTurns != 0) {
|
||
child = RotatedBox(
|
||
quarterTurns: quarterTurns,
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
if (settings.debugShowViewerTiles) {
|
||
final regionRect = widget.regionRect;
|
||
child = Stack(
|
||
children: [
|
||
Positioned.fill(child: child),
|
||
Text(
|
||
'\ntile=(${tileRect.left.round()}, ${tileRect.top.round()}) ${tileRect.width.round()} x ${tileRect.height.round()}'
|
||
'\nregion=(${regionRect.left.round()},{${regionRect.top.round()}) ${regionRect.width.round()} x ${regionRect.height.round()}'
|
||
'\nsampling=${widget.sampleSize} quality=${widget.quality.name}',
|
||
style: const TextStyle(backgroundColor: Colors.black87),
|
||
),
|
||
Positioned.fill(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: Colors.red, width: 1),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
return Positioned.fromRect(
|
||
rect: tileRect,
|
||
child: child,
|
||
);
|
||
}
|
||
}
|