Files
imajviewer/lib/widgets/image_canvas.dart
Alhan 1002156cd4
Some checks failed
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
fix: aspect ratio distortion on image switch (v1.3.1)
Downsample used ResizeImagePolicy.exact which forced a 2048×2048
square and stretched non-square images. Switch to ResizeImagePolicy.fit
to preserve aspect ratio while still capping both dimensions.
2026-08-14 22:17:48 +03:00

564 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
_cachedFileImage!,
width: effectiveWidth,
height: effectiveWidth,
policy: ResizeImagePolicy.fit,
)
: _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ıı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)),
],
),
);
}
}