Some checks failed
Build Windows / build-windows (push) Has been cancelled
- Add ColorFilter.matrix (row-major) for saturation and contrast - Saturation: 0=grayscale, 1=normal, 2=oversaturated (luminance-weighted) - Contrast: standard contrast matrix (pivot at 0.5) - Keyboard modifier tracking via HardwareKeyboard.addHandler - Right-click + vertical drag: contrast (0-2) - Ctrl + right-click + vertical drag: saturation (0-2) - Shift + right-click + vertical drag: rotate - Right double-click: reset all values - Update install.sh to check debug/ bundle before release/ - Update .deb package with new release build
342 lines
9.8 KiB
Dart
342 lines
9.8 KiB
Dart
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;
|
||
|
||
// ── Gesture tracking ──
|
||
String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation'
|
||
Offset _pointerDownPos = Offset.zero;
|
||
double _startAngle = 0;
|
||
double _startContrast = 1.0;
|
||
double _startSaturation = 1.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 (_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;
|
||
});
|
||
}
|
||
|
||
void _toggleFillFit() {
|
||
setState(() {
|
||
_isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero;
|
||
_angle = 0; _contrast = 1.0; _saturation = 1.0;
|
||
});
|
||
}
|
||
|
||
void _handleDoubleTap() => _toggleFillFit();
|
||
|
||
void _handleScroll(PointerScrollEvent e) {
|
||
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
|
||
final double vx = e.localPosition.dx;
|
||
final double vy = e.localPosition.dy;
|
||
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);
|
||
_tx = _tx + cx * _sc - cx * newSc;
|
||
_ty = _ty + cy * _sc - cy * newSc;
|
||
_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;
|
||
_interactionMode = 'none';
|
||
});
|
||
_lastRightTapTime = null;
|
||
return;
|
||
}
|
||
_lastRightTapTime = now;
|
||
_lastRightTapPos = e.localPosition;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
void _onPointerUp(PointerUpEvent e) {
|
||
_interactionMode = 'none';
|
||
}
|
||
|
||
// ── Color filters ──
|
||
|
||
static const double _rLum = 0.2126;
|
||
static const double _gLum = 0.7152;
|
||
static const double _bLum = 0.0722;
|
||
|
||
ColorFilter _buildSaturationFilter() {
|
||
final s = _saturation;
|
||
final invS = 1.0 - s;
|
||
return ColorFilter.matrix(<double>[
|
||
invS * _rLum + s, invS * _gLum, invS * _bLum, 0, 0,
|
||
invS * _rLum, invS * _gLum + s, invS * _bLum, 0, 0,
|
||
invS * _rLum, invS * _gLum, invS * _bLum + s, 0, 0,
|
||
0, 0, 0, 1, 0,
|
||
]);
|
||
}
|
||
|
||
ColorFilter _buildContrastFilter() {
|
||
final c = _contrast;
|
||
final t = (1.0 - c) / 2.0;
|
||
return ColorFilter.matrix(<double>[
|
||
c, 0, 0, 0, t,
|
||
0, c, 0, 0, t,
|
||
0, 0, c, 0, t,
|
||
0, 0, 0, 1, 0,
|
||
]);
|
||
}
|
||
|
||
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;
|
||
|
||
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(vpCenterX, vpCenterY)
|
||
..rotateZ(angleRad)
|
||
..translate(-vpCenterX, -vpCenterY)
|
||
..translate(_tx, _ty)
|
||
..scale(_sc),
|
||
child: ColorFiltered(
|
||
colorFilter: _buildSaturationFilter(),
|
||
child: ColorFiltered(
|
||
colorFilter: _buildContrastFilter(),
|
||
child: _isFilled
|
||
? SizedBox.expand(child: _buildImage())
|
||
: Center(child: _buildImage()),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|