- _imageSize reset on image switch (wrong swap threshold on nav) - downsample caps both dims (tall images no longer full-res decode) - single _readImageSize instead of leaking loadingBuilder listeners - evict old downsample provider on full-res swap - remove imageCache.clear() that dropped live image (gallery flash)
559 lines
17 KiB
Dart
559 lines
17 KiB
Dart
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/gestures.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
import 'package:flutter/services.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
|
||
class ImageCanvas extends StatefulWidget {
|
||
final String filePath;
|
||
const ImageCanvas({super.key, required this.filePath});
|
||
@override
|
||
State<ImageCanvas> createState() => _ImageCanvasState();
|
||
}
|
||
|
||
class _ImageCanvasState extends State<ImageCanvas> {
|
||
bool _isFilled = true;
|
||
final GlobalKey _imageKey = GlobalKey();
|
||
double _tx = 0, _ty = 0, _sc = 1.0;
|
||
Size _vpSize = Size.zero;
|
||
|
||
// ── Görüntü ayarları ──
|
||
double _angle = 0;
|
||
double _contrast = 1.0;
|
||
double _saturation = 1.0;
|
||
double _brightness = 0;
|
||
|
||
// ── Gesture tracking ──
|
||
String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' | 'brightness'
|
||
Offset _pointerDownPos = Offset.zero;
|
||
double _startAngle = 0;
|
||
double _startContrast = 1.0;
|
||
double _startSaturation = 1.0;
|
||
double _startBrightness = 0;
|
||
DateTime? _lastRightTapTime;
|
||
Offset _lastRightTapPos = Offset.zero;
|
||
|
||
// ── Klavye modifier tracking ──
|
||
bool _ctrlPressed = false;
|
||
bool _shiftPressed = false;
|
||
|
||
// ── Downsampling + swap ──
|
||
bool _usingFullRes = false;
|
||
Size _imageSize = Size.zero;
|
||
static const int _downsampleTarget = 2048;
|
||
|
||
// ── Kare hızına eşitlenmiş render tick ──
|
||
final ValueNotifier<int> _renderTick = ValueNotifier(0);
|
||
bool _frameScheduled = false;
|
||
|
||
// ── Shader warmup (ilk kullanım derlemesini açılışa çek) ──
|
||
bool _warmupDone = false;
|
||
int _warmupStep = 0; // 1=filtre, 2=filtre+rotate, 3=bitti
|
||
|
||
void _markRenderDirty() {
|
||
if (_frameScheduled) return;
|
||
_frameScheduled = true;
|
||
SchedulerBinding.instance.scheduleFrameCallback((_) {
|
||
_frameScheduled = false;
|
||
_renderTick.value++;
|
||
});
|
||
}
|
||
|
||
void _clamp() {
|
||
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||
if (_angle != 0) return; // rotate varsa serbest pan
|
||
if (_isFilled) {
|
||
final double w = _vpSize.width * _sc;
|
||
final double h = _vpSize.height * _sc;
|
||
if (w >= _vpSize.width) {
|
||
if (_tx > 0) _tx = 0;
|
||
else if (_tx + w < _vpSize.width) _tx = _vpSize.width - w;
|
||
} else { if (_tx != 0) _tx = 0; }
|
||
if (h >= _vpSize.height) {
|
||
if (_ty > 0) _ty = 0;
|
||
else if (_ty + h < _vpSize.height) _ty = _vpSize.height - h;
|
||
} else { if (_ty != 0) _ty = 0; }
|
||
return;
|
||
}
|
||
double m = 0, n = 0;
|
||
double w = _vpSize.width * _sc;
|
||
double h = _vpSize.height * _sc;
|
||
final box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (box != null && box.hasSize && box.size.width > 0) {
|
||
final Size img = box.size;
|
||
m = (_vpSize.width - img.width) / 2;
|
||
n = (_vpSize.height - img.height) / 2;
|
||
w = img.width * _sc;
|
||
h = img.height * _sc;
|
||
}
|
||
if (w >= _vpSize.width) {
|
||
final double lo = -m * _sc;
|
||
final double hi = _vpSize.width - m * _sc - w;
|
||
if (lo <= hi) {
|
||
if (_tx < lo) _tx = lo;
|
||
if (_tx > hi) _tx = hi;
|
||
} else {
|
||
if (_tx < hi) _tx = hi;
|
||
if (_tx > lo) _tx = lo;
|
||
}
|
||
} else {
|
||
final double lo = -m * _sc;
|
||
final double hi = _vpSize.width - m * _sc - w;
|
||
if (_tx < lo) _tx = lo;
|
||
if (_tx > hi) _tx = hi;
|
||
}
|
||
if (h >= _vpSize.height) {
|
||
final double lo = -n * _sc;
|
||
final double hi = _vpSize.height - n * _sc - h;
|
||
if (lo <= hi) {
|
||
if (_ty < lo) _ty = lo;
|
||
if (_ty > hi) _ty = hi;
|
||
} else {
|
||
if (_ty < hi) _ty = hi;
|
||
if (_ty > lo) _ty = lo;
|
||
}
|
||
} else {
|
||
final double lo = -n * _sc;
|
||
final double hi = _vpSize.height - n * _sc - h;
|
||
if (_ty < lo) _ty = lo;
|
||
if (_ty > hi) _ty = hi;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
HardwareKeyboard.instance.addHandler(_onHardwareKey);
|
||
_startWarmup();
|
||
}
|
||
|
||
/// İlk kullanım shader derlemesini açılışa çek: gizli katmanda sırayla
|
||
/// filtre ve rotate render edilir, sonra değerler sıfırlanır.
|
||
void _startWarmup() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
_warmupStep = 1;
|
||
_markRenderDirty();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _warmupNext());
|
||
});
|
||
}
|
||
|
||
void _warmupNext() {
|
||
if (!mounted) return;
|
||
if (_warmupStep >= 2) {
|
||
_warmupDone = true;
|
||
_markRenderDirty();
|
||
return;
|
||
}
|
||
_warmupStep++;
|
||
_markRenderDirty();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _warmupNext());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
|
||
_renderTick.dispose();
|
||
_imageSizeStream?.removeListener(_imageSizeListener!);
|
||
super.dispose();
|
||
}
|
||
|
||
bool _onHardwareKey(KeyEvent event) {
|
||
final key = event.logicalKey;
|
||
if (event is KeyDownEvent) {
|
||
if (key == LogicalKeyboardKey.controlLeft ||
|
||
key == LogicalKeyboardKey.controlRight) {
|
||
_ctrlPressed = true;
|
||
} else if (key == LogicalKeyboardKey.shiftLeft ||
|
||
key == LogicalKeyboardKey.shiftRight) {
|
||
_shiftPressed = true;
|
||
}
|
||
} else if (event is KeyUpEvent) {
|
||
if (key == LogicalKeyboardKey.controlLeft ||
|
||
key == LogicalKeyboardKey.controlRight) {
|
||
_ctrlPressed = false;
|
||
} else if (key == LogicalKeyboardKey.shiftLeft ||
|
||
key == LogicalKeyboardKey.shiftRight) {
|
||
_shiftPressed = false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(ImageCanvas old) {
|
||
super.didUpdateWidget(old);
|
||
if (old.filePath != widget.filePath) {
|
||
_cachedFileImage?.evict();
|
||
_cachedProvider?.evict();
|
||
_cachedFileImage = null;
|
||
_cachedProvider = null;
|
||
_cachedPath = null;
|
||
_cachedCacheWidth = null;
|
||
PaintingBinding.instance.imageCache.evict(FileImage(File(old.filePath)));
|
||
_resetView();
|
||
}
|
||
}
|
||
|
||
void _resetView() {
|
||
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true;
|
||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
||
_usingFullRes = false;
|
||
_imageSize = Size.zero;
|
||
_markRenderDirty();
|
||
}
|
||
|
||
void _toggleFillFit() {
|
||
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0;
|
||
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
|
||
_markRenderDirty();
|
||
}
|
||
|
||
void _handleDoubleTap() => _toggleFillFit();
|
||
|
||
void _handleScroll(PointerScrollEvent e) {
|
||
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||
final double vx = _angle == 0 ? e.localPosition.dx : _vpSize.width / 2;
|
||
final double vy = _angle == 0 ? e.localPosition.dy : _vpSize.height / 2;
|
||
final double cx = (vx - _tx) / _sc;
|
||
final double cy = (vy - _ty) / _sc;
|
||
const double factor = 1.1;
|
||
final double rawSc = _sc * (e.scrollDelta.dy < 0 ? factor : 1.0 / factor);
|
||
final double newSc = rawSc.clamp(1.0, 20.0);
|
||
if (_angle == 0) {
|
||
_tx = _tx + cx * _sc - cx * newSc;
|
||
_ty = _ty + cy * _sc - cy * newSc;
|
||
} else {
|
||
// Rotate'li zoom: viewport merkezini koru
|
||
final double dw = _vpSize.width * (newSc - _sc);
|
||
final double dh = _vpSize.height * (newSc - _sc);
|
||
_tx -= dw / 2;
|
||
_ty -= dh / 2;
|
||
}
|
||
_sc = newSc;
|
||
_checkFullResSwap();
|
||
_clamp();
|
||
_markRenderDirty();
|
||
}
|
||
|
||
void _onPanUpdate(DragUpdateDetails d) {
|
||
_tx += d.delta.dx;
|
||
_ty += d.delta.dy;
|
||
_clamp();
|
||
_markRenderDirty();
|
||
}
|
||
|
||
void _onPointerDown(PointerDownEvent e) {
|
||
if (e.buttons != kSecondaryMouseButton) return;
|
||
|
||
_pointerDownPos = e.localPosition;
|
||
|
||
final now = DateTime.now();
|
||
if (_lastRightTapTime != null &&
|
||
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
|
||
(e.localPosition - _lastRightTapPos).distance < 20) {
|
||
_angle = 0;
|
||
_contrast = 1.0;
|
||
_saturation = 1.0;
|
||
_brightness = 0;
|
||
_interactionMode = 'none';
|
||
_markRenderDirty();
|
||
_lastRightTapTime = null;
|
||
return;
|
||
}
|
||
_lastRightTapTime = now;
|
||
_lastRightTapPos = e.localPosition;
|
||
|
||
if (_ctrlPressed && _shiftPressed) {
|
||
_interactionMode = 'brightness';
|
||
_startBrightness = _brightness;
|
||
} else if (_ctrlPressed) {
|
||
_interactionMode = 'saturation';
|
||
_startSaturation = _saturation;
|
||
} else if (_shiftPressed) {
|
||
_interactionMode = 'rotate';
|
||
_startAngle = _angle;
|
||
} else {
|
||
_interactionMode = 'contrast';
|
||
_startContrast = _contrast;
|
||
}
|
||
}
|
||
|
||
void _onPointerMove(PointerMoveEvent e) {
|
||
if (e.buttons != kSecondaryMouseButton) return;
|
||
|
||
final dy = e.localPosition.dy - _pointerDownPos.dy;
|
||
|
||
switch (_interactionMode) {
|
||
case 'contrast':
|
||
_contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0);
|
||
break;
|
||
case 'rotate':
|
||
_angle = _startAngle + dy * 0.9;
|
||
break;
|
||
case 'saturation':
|
||
_saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0);
|
||
break;
|
||
case 'brightness':
|
||
_brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0);
|
||
break;
|
||
}
|
||
_markRenderDirty();
|
||
}
|
||
|
||
void _onPointerUp(PointerUpEvent e) {
|
||
_interactionMode = 'none';
|
||
}
|
||
|
||
// ── Combined color filter (brightness × saturation × contrast) ──
|
||
|
||
static const double _rLum = 0.2126;
|
||
static const double _gLum = 0.7152;
|
||
static const double _bLum = 0.0722;
|
||
|
||
ColorFilter? _cachedFilter;
|
||
double _cachedSat = -1;
|
||
double _cachedCon = -1;
|
||
double _cachedBri = double.nan;
|
||
|
||
List<double> _multiply4x5(List<double> a, List<double> b) {
|
||
final result = List<double>.filled(20, 0.0);
|
||
for (int row = 0; row < 4; row++) {
|
||
for (int col = 0; col < 4; col++) {
|
||
double sum = 0.0;
|
||
for (int k = 0; k < 4; k++) {
|
||
sum += a[row * 5 + k] * b[k * 5 + col];
|
||
}
|
||
result[row * 5 + col] = sum;
|
||
}
|
||
double offsetSum = a[row * 5 + 4];
|
||
for (int k = 0; k < 4; k++) {
|
||
offsetSum += a[row * 5 + k] * b[k * 5 + 4];
|
||
}
|
||
result[row * 5 + 4] = offsetSum;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
ColorFilter _buildCombinedFilter() {
|
||
if (_cachedFilter != null &&
|
||
_cachedSat == _saturation &&
|
||
_cachedCon == _contrast &&
|
||
_cachedBri == _brightness) {
|
||
return _cachedFilter!;
|
||
}
|
||
_cachedSat = _saturation;
|
||
_cachedCon = _contrast;
|
||
_cachedBri = _brightness;
|
||
|
||
final s = _saturation;
|
||
final c = _contrast;
|
||
final b = _brightness;
|
||
final invS = 1.0 - s;
|
||
|
||
final sR_R = invS * _rLum + s, sR_G = invS * _gLum, sR_B = invS * _bLum;
|
||
final sG_R = invS * _rLum, sG_G = invS * _gLum + s, sG_B = invS * _bLum;
|
||
final sB_R = invS * _rLum, sB_G = invS * _gLum, sB_B = invS * _bLum + s;
|
||
|
||
final saturationMatrix = <double>[
|
||
sR_R, sR_G, sR_B, 0, 0,
|
||
sG_R, sG_G, sG_B, 0, 0,
|
||
sB_R, sB_G, sB_B, 0, 0,
|
||
0, 0, 0, 1, 0,
|
||
];
|
||
|
||
final brightnessMatrix = <double>[
|
||
1, 0, 0, 0, b,
|
||
0, 1, 0, 0, b,
|
||
0, 0, 1, 0, b,
|
||
0, 0, 0, 1, 0,
|
||
];
|
||
|
||
final offset = 128.0 * (1.0 - c);
|
||
final contrastMatrix = <double>[
|
||
c, 0, 0, 0, offset,
|
||
0, c, 0, 0, offset,
|
||
0, 0, c, 0, offset,
|
||
0, 0, 0, 1, 0,
|
||
];
|
||
|
||
// M_final = M_contrast * M_brightness * M_saturation
|
||
_cachedFilter = ColorFilter.matrix(
|
||
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix));
|
||
return _cachedFilter!;
|
||
}
|
||
|
||
FileImage? _cachedFileImage;
|
||
ImageProvider<Object>? _cachedProvider;
|
||
String? _cachedPath;
|
||
int? _cachedCacheWidth;
|
||
ImageStream? _imageSizeStream;
|
||
ImageStreamListener? _imageSizeListener;
|
||
|
||
/// Provider değiştiğinde boyutu tek sefer oku (listener sızıntısı olmadan).
|
||
void _readImageSize(ImageProvider<Object> provider) {
|
||
_imageSizeStream?.removeListener(_imageSizeListener!);
|
||
_imageSizeStream = provider.resolve(const ImageConfiguration());
|
||
_imageSizeListener = ImageStreamListener((info, _) {
|
||
if (mounted) {
|
||
_imageSize = Size(info.image.width.toDouble(), info.image.height.toDouble());
|
||
}
|
||
});
|
||
_imageSizeStream!.addListener(_imageSizeListener!);
|
||
}
|
||
|
||
void _checkFullResSwap() {
|
||
if (_usingFullRes || _imageSize.isEmpty) return;
|
||
final double stretchX = _vpSize.width * _sc / _imageSize.width;
|
||
final double stretchY = _vpSize.height * _sc / _imageSize.height;
|
||
if (math.max(stretchX, stretchY) >= 1.5) {
|
||
_cachedProvider?.evict(); // eski downsample provider'ı cache'ten at
|
||
setState(() {
|
||
_usingFullRes = true;
|
||
_cachedProvider = _cachedFileImage;
|
||
_cachedCacheWidth = null;
|
||
});
|
||
}
|
||
}
|
||
|
||
Widget _buildImage() {
|
||
final needFullRes = _usingFullRes;
|
||
final effectiveWidth = needFullRes ? null : _downsampleTarget;
|
||
if (_cachedPath != widget.filePath || _cachedCacheWidth != effectiveWidth) {
|
||
_cachedPath = widget.filePath;
|
||
_cachedCacheWidth = effectiveWidth;
|
||
_cachedFileImage = FileImage(File(widget.filePath));
|
||
_cachedProvider = effectiveWidth != null
|
||
? ResizeImage.resizeIfNeeded(effectiveWidth, effectiveWidth, _cachedFileImage!)
|
||
: _cachedFileImage;
|
||
_readImageSize(_cachedProvider!);
|
||
}
|
||
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
|
||
return Image(
|
||
key: _imageKey,
|
||
image: provider,
|
||
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
|
||
filterQuality: FilterQuality.high,
|
||
gaplessPlayback: true,
|
||
errorBuilder: (_, _, _) => _errorWidget(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
_vpSize = constraints.biggest;
|
||
_clamp();
|
||
return RepaintBoundary(
|
||
child: ValueListenableBuilder<int>(
|
||
valueListenable: _renderTick,
|
||
builder: (context, _, _) => _buildRenderLayer(),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildRenderLayer() {
|
||
final vpCenterX = _vpSize.width / 2;
|
||
final vpCenterY = _vpSize.height / 2;
|
||
final angleRad = _angle * math.pi / 180;
|
||
|
||
Widget imageContent = Builder(
|
||
builder: (context) {
|
||
final hasFilter = _saturation != 1.0 || _contrast != 1.0 || _brightness != 0.0;
|
||
final image = _isFilled
|
||
? SizedBox.expand(child: _buildImage())
|
||
: Center(child: _buildImage());
|
||
return hasFilter
|
||
? ColorFiltered(colorFilter: _buildCombinedFilter(), child: image)
|
||
: image;
|
||
},
|
||
);
|
||
|
||
if (_angle != 0) {
|
||
imageContent = Transform(
|
||
alignment: Alignment.topLeft,
|
||
transform: Matrix4.identity()
|
||
..translate(vpCenterX, vpCenterY)
|
||
..rotateZ(angleRad)
|
||
..translate(-vpCenterX, -vpCenterY),
|
||
child: imageContent,
|
||
);
|
||
}
|
||
|
||
return SizedBox.expand(
|
||
child: Stack(
|
||
children: [
|
||
ClipRect(
|
||
child: Listener(
|
||
onPointerDown: _onPointerDown,
|
||
onPointerMove: _onPointerMove,
|
||
onPointerUp: _onPointerUp,
|
||
onPointerSignal: (event) {
|
||
if (event is PointerScrollEvent) _handleScroll(event);
|
||
},
|
||
child: GestureDetector(
|
||
onDoubleTap: _handleDoubleTap,
|
||
onPanUpdate: _onPanUpdate,
|
||
child: Transform(
|
||
alignment: Alignment.topLeft,
|
||
transform: Matrix4.identity()
|
||
..translate(_tx, _ty)
|
||
..scale(_sc),
|
||
child: imageContent,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
_buildWarmupLayer(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Görünmez 1x1 katman — ColorFiltered ve Transform shader'larını açılışta
|
||
/// derletir; kullanıcı ilk sağ tıkta derleme beklemesin.
|
||
Widget _buildWarmupLayer() {
|
||
if (_warmupDone) return const SizedBox.shrink();
|
||
final filter = ColorFilter.matrix(const <double>[
|
||
1.5, 0, 0, 0, 0,
|
||
0, 1.5, 0, 0, 0,
|
||
0, 0, 1.5, 0, 0,
|
||
0, 0, 0, 1, 0,
|
||
]);
|
||
Widget child = const SizedBox.expand();
|
||
if (_warmupStep >= 2) {
|
||
child = Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()..rotateZ(0.3),
|
||
child: child,
|
||
);
|
||
}
|
||
child = ColorFiltered(colorFilter: filter, child: child);
|
||
return Opacity(
|
||
opacity: 0,
|
||
child: IgnorePointer(
|
||
child: SizedBox(width: 1, height: 1, child: child),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _errorWidget() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.broken_image, size: 48, color: Colors.white38),
|
||
const SizedBox(height: 8),
|
||
Text(AppLocalizations.of(context)!.imageLoadError,
|
||
style: const TextStyle(color: Colors.white38)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|