feat: letterbox-aware clamp, full-frame image, fit/fill zoom fix
Some checks failed
Build Windows / build-windows (push) Has been cancelled

- image_canvas.dart:
  - Fill mod: viewport tabanli clamp (_tx=0'da kilit)
  - Fit mod: RenderBox ile resmin gercek render boyutuna gore clamp
  - Letterbox offset (offX/offY) hesaba katilarak resmin kenarlarina kadar pan
  - Zoom-to-cursor dogru calisiyor (alignment: topLeft ile)
  - Min scale 1.0 (her iki modda da)
  - Overflow durumunda lo/hi reversed clamp (goruntu disari kacmaz)
- viewer_screen.dart: image alani top:24 -> top:0 (tam cerceve)
- install.sh: release -> debug bundle path (gelistirme)
This commit is contained in:
Alhan
2026-07-24 06:22:59 +03:00
parent 9a0e89bcea
commit 4dae4234ba
3 changed files with 171 additions and 152 deletions

View File

@@ -119,28 +119,36 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
child: Stack(
children: [
Positioned(
top: 24,
top: 0,
left: 0,
right: 0,
bottom: 0,
child: Container(color: const Color(0xFF1a1a1a)),
),
Container(
color: Colors.transparent,
child: _hasImages
? ImageCanvas(filePath: ImageManager.instance.currentImagePath)
: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text('Resimleri buraya sürükleyin\nveya Ctrl+O ile açın',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 16)),
],
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Colors.transparent,
child: _hasImages
? ImageCanvas(
filePath: ImageManager.instance.currentImagePath,
)
: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text('Resimleri buraya sürükleyin\nveya Ctrl+O ile açın',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 16)),
],
),
),
),
),
),
const CustomTitleBar(),
if (_isDragging)

View File

@@ -4,160 +4,171 @@ import 'package:flutter/gestures.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> {
late TransformationController _controller;
double _currentScale = 1.0;
bool _isFilled = true; // varsayılan: ekranı doldur
bool _isFilled = true;
final GlobalKey _imageKey = GlobalKey();
double _tx = 0, _ty = 0, _sc = 1.0;
Size _vpSize = Size.zero;
@override
void initState() {
super.initState();
_controller = TransformationController();
_controller.addListener(_onTransformChanged);
}
@override
void didUpdateWidget(ImageCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filePath != widget.filePath) {
_resetView();
void _clamp() {
if (_vpSize.width <= 0 || _vpSize.height <= 0) return;
if (_isFilled) {
// Fill mode: viewport tabanli clamp
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;
}
// Fit mode: resmin gercek boyutuna gore clamp (letterbox dahil)
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;
}
}
void _resetView() {
_controller.value = Matrix4.identity();
_currentScale = 1.0;
_isFilled = true;
}
void _onTransformChanged() {
_currentScale = _controller.value.getMaxScaleOnAxis();
}
void _toggleFillFit() {
setState(() {
_isFilled = !_isFilled;
_controller.value = Matrix4.identity();
_currentScale = 1.0;
});
}
void _handleDoubleTap() {
_toggleFillFit();
}
void _handleScroll(PointerScrollEvent event) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final localPosition = renderBox.globalToLocal(event.position);
final delta = event.scrollDelta.dy;
final scaleFactor = delta < 0 ? 1.1 : 1 / 1.1;
final newScale = (_currentScale * scaleFactor).clamp(0.1, 10.0);
if (newScale <= 0.1 || newScale >= 10.0) return;
final actualScale = newScale / _currentScale;
// Fare altındaki child-space noktayı hesapla
final matrix = _controller.value;
final tx = matrix.getTranslation().x;
final ty = matrix.getTranslation().y;
final childX = (localPosition.dx - tx) / _currentScale;
final childY = (localPosition.dy - ty) / _currentScale;
// controller * zoomAroundPoint sırasıyla uygula
final newMatrix = Matrix4.copy(_controller.value)
// ignore: deprecated_member_use
..translate(childX, childY)
// ignore: deprecated_member_use
..scale(actualScale)
// ignore: deprecated_member_use
..translate(-childX, -childY);
_controller.value = newMatrix;
}
@override
void dispose() {
_controller.removeListener(_onTransformChanged);
_controller.dispose();
super.dispose();
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; });
}
void _toggleFillFit() {
setState(() { _isFilled = !_isFilled; _tx = 0; _ty = 0; _sc = 1.0; _vpSize = Size.zero; });
}
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(() {});
}
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) {
return Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
_handleScroll(event);
}
},
child: GestureDetector(
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _controller,
boundaryMargin: const EdgeInsets.all(double.infinity),
minScale: 0.1,
maxScale: 10.0,
panEnabled: true,
scaleEnabled: false,
child: _isFilled
? SizedBox.expand(
child: Image.file(
File(widget.filePath),
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
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)),
],
),
);
},
),
)
: Center(
child: Image.file(
File(widget.filePath),
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
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)),
],
),
);
},
),
_vpSize = constraints.biggest;
_clamp();
return SizedBox.expand(
child: Stack(
children: [
ClipRect(
child: GestureDetector(
onDoubleTap: _handleDoubleTap,
onPanUpdate: _onPanUpdate,
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) _handleScroll(event);
},
child: Transform(
alignment: Alignment.topLeft,
transform: Matrix4.identity()
..translate(_tx, _ty)
..scale(_sc),
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)),
],
),
);
}
}