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'; import '../services/crop_save_service.dart'; import '../services/window_persistence.dart'; class ImageCanvas extends StatefulWidget { final String filePath; const ImageCanvas({super.key, required this.filePath}); @override State createState() => ImageCanvasState(); } class ImageCanvasState extends State { bool _isFilled = WindowPersistence.filled; 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; bool _flipH = false; // ── 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 _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 = WindowPersistence.filled; _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; _flipH = false; _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; _flipH = false; WindowPersistence.saveFilled(_isFilled); _markRenderDirty(); } /// Ctrl+R: sağ çift tık gibi efektleri sıfırlar ve yatay aynalar; tekrar basınca geri alır. void toggleFlipH() { _flipH = !_flipH; _angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0; _interactionMode = 'none'; _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; final double factor = _ctrlPressed ? 1.01 : 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; _flipH = false; _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; List? _cachedMatrix; double _cachedSat = -1; double _cachedCon = -1; double _cachedBri = double.nan; List _multiply4x5(List a, List b) { final result = List.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 = [ 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 = [ 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 = [ 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 final matrix = _multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix); _cachedMatrix = matrix; _cachedFilter = ColorFilter.matrix(matrix); return _cachedFilter!; } /// Aktif renk matrisi (4x5 row-major); identity ise null. /// CropSaveService'e aynen taşınır (aynı alan/semantik: 0-255). List? get activeColorMatrix { if (_saturation == 1.0 && _contrast == 1.0 && _brightness == 0.0) return null; _buildCombinedFilter(); return _cachedMatrix; } /// Viewport'un gorunen bolgesinin tam cozunurluktaki eslemesi (crop_save_service'e). /// Degrade durumda null. CropViewParams? cropViewParams(int w, int h) { if (_vpSize.width <= 0 || _vpSize.height <= 0 || w <= 0 || h <= 0) return null; final k = _isFilled ? math.max(_vpSize.width / w, _vpSize.height / h) : math.min(_vpSize.width / w, _vpSize.height / h); final ox = (_vpSize.width - w * k) / 2, oy = (_vpSize.height - h * k) / 2; return CropViewParams( w: w, h: h, vw: _vpSize.width, vh: _vpSize.height, tx: _tx, ty: _ty, sc: _sc, angleDeg: _angle, flipH: _flipH, cx: _vpSize.width / 2, cy: _vpSize.height / 2, k: k, ox: ox, oy: oy, ); } FileImage? _cachedFileImage; ImageProvider? _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 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 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( 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 || _flipH) { imageContent = Transform( alignment: Alignment.topLeft, transform: Matrix4.identity() ..translate(vpCenterX, vpCenterY) ..rotateZ(angleRad) ..scale(_flipH ? -1.0 : 1.0, 1.0) ..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 [ 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)), ], ), ); } }