Files
imajviewer/lib/widgets/image_canvas.dart

424 lines
12 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/services.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;
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);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
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) _resetView();
}
void _resetView() {
setState(() {
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = true; _vpSize = Size.zero;
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
});
}
void _toggleFillFit() {
setState(() {
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero;
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
});
}
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, 10.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;
_clamp();
setState(() {});
}
void _onPanUpdate(DragUpdateDetails d) {
_tx += d.delta.dx;
_ty += d.delta.dy;
_clamp();
setState(() {});
}
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) {
setState(() {
_angle = 0;
_contrast = 1.0;
_saturation = 1.0;
_brightness = 0;
_interactionMode = 'none';
});
_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':
setState(() {
_contrast = (_startContrast + dy * 0.005).clamp(0.0, 2.0);
});
break;
case 'rotate':
setState(() {
_angle = _startAngle + dy * 0.9;
});
break;
case 'saturation':
setState(() {
_saturation = (_startSaturation + dy * 0.005).clamp(0.0, 2.0);
});
break;
case 'brightness':
setState(() {
_brightness = (_startBrightness + dy * 0.5).clamp(-100.0, 100.0);
});
break;
}
}
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!;
}
Widget _buildImage() {
return Image.file(
key: _imageKey,
File(widget.filePath),
fit: _isFilled ? BoxFit.cover : BoxFit.contain,
filterQuality: FilterQuality.high,
errorBuilder: (_, _, _) => _errorWidget(),
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_vpSize = constraints.biggest;
_clamp();
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,
),
),
),
),
],
),
);
},
);
}
Widget _errorWidget() {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image, size: 48, color: Colors.white38),
SizedBox(height: 8),
Text('Resim yüklenemedi', style: TextStyle(color: Colors.white38)),
],
),
);
}
}