Author SHA1 Message Date
Alhan eed0646237 chore: dist swap — track 1.8.0 .deb, untrack 1.7.2 (still on Gitea v1.7.2 release)
Build & Release / build-linux (push) Canceled after 0s
Build & Release / build-windows (push) Canceled after 0s
Build & Release / create-release (push) Canceled after 0s
2026-10-31 02:11:25 +03:00
Alhan 9a7480cd68 feat: window size presets — 4x8 screen-ratio grid in settings (v1.8.0)
- Settings 'Size' section: 8 col x 4 row grid, 31 buttons labeled a:b
  (a splits H, b splits W); top-left cell empty placeholder (no 1:1 —
  title bar double click already maximizes)
- Click resizes the window to work.width/b x work.height/a of the
  display's work area (screen_retriever visibleSize, taskbar excluded);
  position unchanged, no selection state tracking
- minimumSize 400x300 -> 160x100 (4:8 cell needs it)
- Settings file-properties rows left-aligned (were centered under path)
2026-10-31 02:10:39 +03:00
Alhan 8a80cba116 chore: point README at Gitea releases; build-windows.ps1 mirrors pubspec version
Build & Release / build-linux (push) Canceled after 0s
Build & Release / build-windows (push) Canceled after 0s
Build & Release / create-release (push) Canceled after 0s
2026-10-31 00:14:47 +03:00
Alhan 3b1fad18fa fix: right-button double click no longer wipes rotation mid-drag (v1.7.2)
Build & Release / build-linux (push) Canceled after 0s
Build & Release / build-windows (push) Canceled after 0s
Build & Release / create-release (push) Canceled after 0s
Two rotate drags started quickly at nearly the same spot were treated as a
double click: the reset was detected on button down against the previous down
time/position, so it wiped all effects and ignored the second drag, leaving
Ctrl+S to save an unrotated crop.

- Double click is now only counted for actual clicks (movement <= 4px) and is
  decided on button up; a drag breaks the chain.
- Pointer up is matched by pointer id, so a left-drag pan never counts as a click.
- dist: 1.7.2 .deb replaces the tracked 1.7.1 package.
2026-10-25 20:48:59 +03:00
Alhan 32fb12c210 feat: zoom percentage badge at bottom center (v1.7.1)
Build & Release / build-linux (push) Canceled after 0s
Build & Release / build-windows (push) Canceled after 0s
Build & Release / create-release (push) Canceled after 0s
- Percentage shown between the prev/next buttons, together with the hover
  controls, on a translucent background.
- Value is the on-screen scale relative to the image's real pixel size
  (100% = 1:1); updated on wheel zoom, fill/fit toggle, image change and
  window resize.
- Pixel-size header parsing moved to lib/services/image_header.dart, shared
  with the settings file-properties panel.
- dist: 1.7.1 .deb replaces the tracked 1.7.0 package.
2026-10-25 18:53:59 +03:00
Alhan abbde04e50 chore: rebuild 1.7.0 .deb with F11 + file properties
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-21 02:22:24 +03:00
Alhan e6808aaf8e feat: file properties in settings (resolution, size, format, modified, path)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-21 02:10:08 +03:00
Alhan 805c7eedf2 chore: regenerate l10n base class (F11 doc line)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-21 01:45:36 +03:00
Alhan 2ae32e351d feat: F11 fullscreen toggle (Esc still exits)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-21 01:45:21 +03:00
Alhan ee456773d4 chore: ship 1.7.0 .deb as latest in dist/ + changelog (v1.7.0)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-20 23:14:49 +03:00
Alhan 57fa3d92dd feat: settings version display, complete shortcut list, Ctrl fine zoom (v1.7.0)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-20 23:09:13 +03:00
Alhan 5f4d4ffe1a fix: window resize crash by building with Flutter 3.47.7 (v1.6.3)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
The GTK embedder's OpenGL compositor uploaded the frame with the window's new
dimensions while its pixel buffer still held the previous size, so memcpy read
past the end of the buffer and the process died with SIGSEGV
(fl_compositor_opengl.cc). Upstream fix: flutter#187626, present in Flutter
3.47.7 but not in 3.44.7.

Linux builds now use /home/alhan/development/flutter-3.47.7; pubspec.lock was
re-resolved for Dart 3.13.5 and the new tool added its build/platform excludes
to analysis_options.yaml. dist/ carries the 1.6.3 package instead of 1.6.2.
2026-10-20 22:22:17 +03:00
Alhan a2d2c0d151 chore: crop save JPEG quality 93 -> 95 (v1.6.2)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-14 04:04:54 +03:00
Alhan 6e13892ed1 fix: Ctrl+R flip also matches the physical R key (v1.6.1)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
On some keymaps the R key resolves to a different logical key (observed on a
Turkish remote-desktop setup: the R key arrives as logical T), so the Ctrl+R
shortcut never fired even though the key events reached the app. The shortcut
now matches PhysicalKeyboardKey.keyR as well; a regression test covers the
wrong-layout case.
2026-10-14 03:33:16 +03:00
Alhan bad48167c8 feat: horizontal flip with R key, baked into crop save (v1.6.0)
- R mirrors the view about the viewport centre and clears the active effects
  (same reset as double right click); pressing R again restores the original
  orientation. Pan/zoom are kept.
- Image switch, fill/fit toggle and right double click also clear the flip.
- Crop save (Ctrl+S) mirrors the output too, so the saved JPEG matches the
  on-screen view (verified in combination with rotation).
- Shortcut lists (tr/en), README and CHANGELOG updated; deb rebuilt as 1.6.0.
2026-10-14 02:28:24 +03:00
Alhan 24cab6f908 docs: document crop save feature in README (save button / Ctrl+S)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-12 23:45:45 +03:00
Alhan 2cd9e6c357 feat: persist fill/fit view mode in window.json (v1.5.0)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
The double-click fill/fit mode was hardcoded (image_canvas: _isFilled = true) and
reset on every image change, so it never survived a restart. It is now stored as
"filled" in window.json and restored on launch and on image switch.

WindowPersistence.saveSync() now merges with the existing file, otherwise the
startup/move/resize geometry writes would drop the mode key.

Also: version 1.5.0, CHANGELOG entry, dist/ ships imajviewer_1.5.0_amd64.deb
(replaces 1.4.2).
2026-10-12 22:33:43 +03:00
Alhan b3bd540549 fix: crop save dropped blue channel brightness/contrast offset (v1.4.2)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
m[13] (blue alpha coefficient, always 0) was read as the blue offset instead of
m[14], so saved JPEGs lost the blue brightness/contrast offset and came out with a
color cast. Saturation/rotate were unaffected (no offsets).

Also: version 1.4.2, CHANGELOG entry, dist/ ships imajviewer_1.4.2_amd64.deb
(replaces 1.4.1).
2026-10-12 22:22:04 +03:00
Alhan afe99198ff build: ship 1.4.1 .deb (replace 1.3.1 as latest in dist/)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-11 22:23:55 +03:00
Alhan c34a3bfd95 fix: crop save always JPEG q93 + save rotated view 1:1 (v1.4.1)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
- Output extension is always .jpg regardless of source format (was: PNG in -> PNG out)
- Rotation now saved correctly: viewport grid resampled bilinearly in source
  space (was: unrotated bounding box copy)
- Quality 90 -> 93; l10n strings updated (JPEG 93)
- New: tool/crop_test.dart — pure-Dart end-to-end test (dart run tool/crop_test.dart)
2026-10-11 22:16:25 +03:00
Alhan 268dc879c8 chore: remove internal docs and old release binaries from tracking
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
- docs/ untracked: internal dev docs (reports, sessions, plans) stay local
- dist/: keep only latest .deb (1.3.1) and .exe (1.1.0); remove 4 old builds
- .gitignore: ignore /docs/ and all dist binaries except the two latest
2026-10-11 21:47:33 +03:00
Alhan 168e087c8b feat: crop save (save visible area at original resolution) v1.4.0
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
- Save visible area at original resolution as JPEG q90 (title bar button or Ctrl+S)
- Output name: original name + _HHmmss suffix; no overwrite, warning shown
- Active color effects baked in; rotation saves bounding box
- New: crop_save_service, l10n strings TR/EN, docs/plan-crop-save.md
2026-10-11 21:36:20 +03:00
Alhan 73c1ff51d6 fix: window position/size persistence under Wayland (v1.3.2)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-10-11 17:51:47 +03:00
Alhan 1002156cd4 fix: aspect ratio distortion on image switch (v1.3.1)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
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
Alhan 72c35a8a26 fix: image_canvas correctness + RAM fixes (v1.3.0)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
- _imageSize reset on image switch (wrong swap threshold on nav)
- downsample caps both dims (tall images no longer full-res decode)
- single _readImageSize instead of leaking loadingBuilder listeners
- evict old downsample provider on full-res swap
- remove imageCache.clear() that dropped live image (gallery flash)
2026-08-14 21:54:34 +03:00
Alhan addece7ade build: Linux .deb installer v1.2.0 (imajviewer_1.2.0_amd64.deb)
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
2026-08-14 21:26:55 +03:00
Alhan 45cf56a1e6 feat: downsampling + full-res swap + RAM leak fix
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
- Büyük resimler ilk yüklemede max 2048px'e downsampling (RAM ~1.6MB)
- Zoom 1.5x stretch'i geçince full-res'e swap (orijinal çözünürlük)
- Resim geçişinde eski provider evict + ImageCache.evict() (RAM leak)
- Tiled render kodu tamamen kaldırıldı
2026-08-14 21:22:26 +03:00
46 changed files with 1382 additions and 2109 deletions

No files matched your search

+9
View File
@@ -45,8 +45,17 @@ app.*.map.json
/android/app/release
# Distribution binaries — release assets, not tracked in git
# (only the latest build of each type stays tracked for direct download)
*.deb
*.tar.gz
*.exe
!dist/imajviewer_1.8.0_amd64.deb
!dist/imajviewer_setup_1.1.0.exe
deb/usr/lib/imajviewer/
release/
# Local backup snapshots (see soul.md §5)
docs/.bak/
# Internal dev docs stay local, not in the public repo
/docs/
+128
View File
@@ -4,12 +4,140 @@ All notable changes to imajViewer are documented in this file.
## [Unreleased]
## [1.8.0] - 2026-10-31
### Added
- Window size presets: 4×8 screen-ratio grid in settings; clicking a preset
resizes the window to that fraction of the screen work area (taskbar
excluded). Minimum window size lowered to 160×100.
### Fixed
- Settings file-properties rows were centered under the longest row (path);
now left-aligned.
## [1.7.2] - 2026-10-25
### Fixed
- Rotating sometimes vanished before saving: the right-button double-click reset was
detected on button *down* against the previous down time and position, so two rotate
drags started quickly at nearly the same spot were treated as a double click — all
effects (rotation included) were wiped and the second drag was ignored, so `Ctrl+S`
saved an unrotated crop. A double click is now only counted for actual clicks
(movement ≤ 4 px, decided on button up), a drag breaks the chain, and
`pointer up` is matched by pointer id so a left-drag pan never counts as a click.
## [1.7.1] - 2026-10-25
### Added
- Zoom percentage badge at the bottom center, between the previous/next buttons: shown
together with the hover controls, translucent background, live while wheeling
(100% = one image pixel per screen pixel), recomputed on fill/fit toggle, image
change and window resize.
### Changed
- Image header parsing (pixel size without decoding) moved from the viewer screen into
`lib/services/image_header.dart`; the settings file-properties panel and the zoom badge
use the same code.
## [1.7.0] - 2026-10-20
### Added
- Settings dialog shows the app version next to the name.
- Fine zoom: `Ctrl`+wheel zooms 1% per step (plain wheel stays 10% per step).
- Settings shortcut list completed: `Ctrl+O` (open), `Esc` (exit fullscreen), scroll/zoom,
`Ctrl`+scroll fine zoom, left-drag pan, double right click (reset effects).
### Changed
- README: zoom feature line and shortcut table updated to match.
## [1.6.3] - 2026-10-20
### Fixed
- `SIGSEGV` while resizing the window. The GTK embedder's OpenGL compositor uploaded the frame using
the window's new dimensions while its pixel buffer still held the previous size, so `memcpy` read
past the end of the buffer (`engine/.../linux/fl_compositor_opengl.cc`). Fixed upstream in
flutter#187626, which is not part of Flutter 3.44.7 — the Linux build now uses Flutter 3.47.7,
where the compositor derives the texture size from the framebuffer itself.
## [1.6.2] - 2026-10-14
### Changed
- Crop save JPEG quality raised from 93 to 95.
## [1.6.1] - 2026-10-14
### Fixed
- Horizontal flip shortcut (`Ctrl+R`) never fired when the keyboard layout resolves the R key to a
different logical key (reported with a Turkish/remote-desktop setup where the R key arrives as
logical `T`). The shortcut now matches the physical R key position as well.
## [1.6.0] - 2026-10-14
### Added
- Horizontal flip (`Ctrl+R`): mirrors the image left-right about the viewport centre and clears
the active effects (same reset as double right click); pressing `Ctrl+R` again restores the
original orientation. Zoom/pan are kept.
- Crop save (`Ctrl+S`) now bakes the flip into the output as well, so the saved JPEG matches
the mirrored view (also in combination with rotation).
## [1.5.0] - 2026-10-12
### Added
- Fill/fit mode (double-click) is now persisted in `window.json` and restored on the next launch
and when switching images, instead of always resetting to fill.
## [1.4.2] - 2026-10-12
### Fixed
- Crop save dropped the blue channel's brightness/contrast offset (matrix index `m[13]`, the
alpha coefficient, instead of `m[14]`), so saved JPEGs came out with a color cast whenever
brightness or contrast was applied. Saturation/rotate were unaffected.
## [1.4.1] - 2026-10-11
### Fixed
- Crop save always writes JPEG (previously kept the source extension, e.g. PNG) — quality 93
- Crop save with rotation now saves the rotated view 1:1 instead of an unrotated bounding box
## [1.4.0] - 2026-10-11
### Added
- Crop save: saves the visible area at original resolution as JPEG quality 90 — title bar save button or Ctrl+S
- Output name: original name + `_HHmmss` time suffix (e.g. `photo.jpg` → `photo_143025.jpg`)
- If a file with the same name exists, nothing is written and a warning is shown
- Active color effects (contrast/saturation/brightness) are baked into the output
- With rotation active, the bounding box around the visible area is saved
## [1.3.2] - 2026-08-28
### Fixed
- Window position/size persistence under Wayland — forces X11 backend (XWayland) when an X server is available
## [1.3.1] - 2026-08-14
### Fixed
- Aspect ratio distortion on image switch — downsample now uses `ResizeImagePolicy.fit` (was `exact`, which forced 2048×2048 square and stretched non-square images)
## [1.3.0] - 2026-08-14
### Fixed
- `_imageSize` now resets on image switch (wrong swap threshold on navigation)
- Downsample now caps both dimensions, not just width (tall images no longer full-res decode)
- Image size listener leak in `loadingBuilder` — replaced with single `_readImageSize`
- Old downsample provider now evicted on full-res swap
- Removed `imageCache.clear()` that also dropped the live image (gallery flash)
## [1.2.0] - 2026-08-14
### Added
- Downsampling: large images decode at max 2048px on first load (RAM ~1.6MB)
- Full-res swap: zooming in past 1.5x stretch swaps to original resolution
- Internationalization: Turkish + English, auto-detected from system locale
- Zoom decodes at original resolution while zooming in (crisp details)
- Max zoom increased to 20x
### Fixed
- RAM leak on image switch — old providers now evicted + `ImageCache.evict()`
- Black screen flash during zoom (gapless playback)
### Changed
+26 -10
View File
@@ -1,8 +1,8 @@
# imajViewer
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Build](https://github.com/alhan/imajviewer/actions/workflows/build.yml/badge.svg)](https://github.com/alhan/imajviewer/actions/workflows/build.yml)
[![Release](https://img.shields.io/github/v/release/alhan/imajviewer)](https://github.com/alhan/imajviewer/releases/latest)
[Releases](https://git.softmediadesign.com/git_alhan/imajviewer/releases)
Ultra-lightweight image viewer written in Flutter — Linux & Windows.
@@ -12,16 +12,24 @@ Ultra-lightweight image viewer written in Flutter — Linux & Windows.
- **Formats:** PNG, JPEG, JFIF, WebP, BMP, GIF, WBMP
- **Pan:** Left drag
- **Zoom:** Mouse wheel (cursor-centered; viewport-centered in rotate mode)
- **Zoom:** Mouse wheel — 10% per step, cursor-centered (viewport-centered in rotate mode);
hold `Ctrl` for fine zoom (1% per step)
- **Fill/Fit toggle:** Double left click
- **Contrast:** Right drag + vertical move
- **Saturation:** Ctrl + Right drag + vertical move
- **Brightness:** Ctrl+Shift + Right drag + vertical move
- **Rotate:** Shift + Right drag + vertical move (~100px = 90°)
- **Flip horizontally:** `Ctrl+R` — mirrors the view and clears the active effects (same reset as
double right click); press `Ctrl+R` again to undo. Also matches the physical R key position, so
keymaps that report the R key as a different character still work
- **Reset (all effects):** Double right click
- **Navigation arrows:** bottom corners, visible on hover
- **Settings (gear):** sort by time/name, print, shortcut info
- **Print:** send the image to a printer (A4, fit)
- **Save visible area:** title bar save icon or `Ctrl+S` — the on-screen region is written at
original resolution as JPEG (quality 95) next to the source file (`photo.jpg` →
`photo_143025.jpg`); active effects (contrast/saturation/brightness), rotation and horizontal
flip are baked into the output; if that name already exists, nothing is written and a warning is shown
- Drag & drop to open images
- Window size/position memory (JSON persistence)
- Set as default image viewer
@@ -33,7 +41,7 @@ Ultra-lightweight image viewer written in Flutter — Linux & Windows.
### Linux (.deb, recommended)
No Flutter SDK needed — grab the latest `.deb` from the
[Releases page](https://github.com/alhan/imajviewer/releases/latest):
[Releases page](https://git.softmediadesign.com/git_alhan/imajviewer/releases):
```bash
sudo dpkg -i imajviewer_*_amd64.deb
@@ -45,7 +53,7 @@ After install, open an image with `xdg-open <file>` or double-click it.
### Windows (installer)
Download `imajviewer_setup_*.exe` from the
[Releases page](https://github.com/alhan/imajviewer/releases/latest) and run it.
[Releases page](https://git.softmediadesign.com/git_alhan/imajviewer/releases) and run it.
The installer:
- Installs to Program Files
@@ -76,22 +84,30 @@ folder including `imajviewer.exe` and all DLLs).
| Interaction | Action |
|---|---|
| Left drag | Pan |
| Mouse wheel | Zoom (cursor-centered) |
| Mouse wheel | Zoom (cursor-centered, 10% per step) |
| Ctrl + Mouse wheel | Fine zoom (1% per step) |
| Double left click | Fill / Fit toggle |
| Right drag + vertical | Contrast (0.0 – 2.0) |
| Ctrl + Right drag + vertical | Saturation (0.0 – 2.0) |
| Ctrl+Shift + Right drag + vertical | Brightness (-100 – 100) |
| Shift + Right drag + vertical | Rotate (free) |
| Ctrl + R | Flip horizontally (clears effects; press again to undo) |
| Double right click | Reset (all effects) |
| Left / Right arrow | Previous / next image |
| Ctrl + O | Open file |
| Ctrl + S | Save visible area (JPEG 95, original resolution) |
| Esc | Exit fullscreen |
| Ctrl + Q | Close window |
| Ctrl + Shift + Q | Close all windows |
## CI / Releases
## Releases
GitHub Actions builds Linux `.deb` and Windows installer on every push to
`main`. Pushing a `v*` tag creates a GitHub Release with both installers as
assets.
Installers are published on the
[Gitea Releases page](https://git.softmediadesign.com/git_alhan/imajviewer/releases):
- Linux `.deb` — `flutter build linux --release` + `bash dist/rebuild-deb.sh`
- Windows installer — on a Windows machine: `.\build-windows.ps1`
(Flutter + VS 2022 Build Tools + Inno Setup 6)
## Development
+5
View File
@@ -7,6 +7,11 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- windows/**
- linux/**
include: package:flutter_lints/flutter.yaml
linter:
+5
View File
@@ -136,6 +136,11 @@ if (-not $SkipInno) {
Write-Err "Installer scripti bulunamadi: $installerScript"
}
# Installer surumunu pubspec.yaml'dan aynala (CI'daki adimla ayni)
$pubVersion = (Select-String -Path (Join-Path $ProjectRoot 'pubspec.yaml') -Pattern '^version:\s*([0-9]+\.[0-9]+\.[0-9]+)').Matches[0].Groups[1].Value
(Get-Content $installerScript -Raw) -replace '#define MyAppVersion "[^"]+"', "#define MyAppVersion `"$pubVersion`"" | Set-Content $installerScript
Write-Ok "Inno Setup surumu pubspec'ten: $pubVersion"
$distDir = Join-Path $ProjectRoot "dist"
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
BIN
View File
Binary file not shown.
-209
View File
@@ -1,209 +0,0 @@
# Implementation — imajViewer
> Teknik uygulama dokümanı
> Versiyon: 1.0
---
## 1. Mimari Genel Bakış
```
┌──────────────────────────────────────────────────┐
│ Flutter App │
│ ┌────────────┐ ┌────────────────────────────┐ │
│ │ window_mgr │ │ ViewerScreen │ │
│ │ (frameless)│ │ ┌──────────────────────┐ │ │
│ └────────────┘ │ │ CustomTitleBar │ │ │
│ │ │ (minimize, close) │ │ │
│ │ ├──────────────────────┤ │ │
│ │ │ ImageCanvas │ │ │
│ │ │ (InteractiveViewer) │ │ │
│ │ │ - Scroll → Zoom │ │ │
│ │ │ - Drag → Pan │ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ ImageCacheManager │ │
│ │ - PrecacheImage │ │
│ │ - LRU eviction (> N images) │ │
│ │ - File → Memory → GPU Texture │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
## 2. Bileşen Ağacı (Widget Tree)
```
MaterialApp (dark theme)
└── ViewerScreen (StatefulWidget)
├── Stack
│ ├── InteractiveViewer (zoom + pan)
│ │ └── RawImage (resim)
│ ├── CustomTitleBar (Positioned: top-right)
│ │ ├── IconButton (minimize)
│ │ └── IconButton (close)
│ └── (koyu arka plan)
└── DragTarget (sürükle-bırak dosya alanı)
```
## 3. Data Flow
```
Kullanıcı dosya bırakır / açar
│
▼
ViewerScreen.dragResult / openFileDialog
│
▼
ImageList (List<String> filePaths)
│
▼
preloadImages() → ImageCache'e ekle
│
▼
Indexed, currentIndex değişince
│
▼
InteractiveViewer.builder → Image.file(path)
│
▼
Flutter ImageCache → GPU Texture → Render
```
## 4. Adım Adım Uygulama
### Adım 1: Proje İskeleti
- `flutter create imajViewer`
- `--platforms=linux` ile oluştur
- Temele dark tema, frameless window ekle
**Dosyalar:** `main.dart`, `app.dart`
### Adım 2: window_manager Kurulumu
- pubspec.yaml: `window_manager: ^0.4.3`
- `main()` içinde `windowManager.ensureInitialized()`
- `waitUntilReadyToShow` → frameless ayarları:
- `setTitleBarStyle(TitleBarStyle.hidden)`
- `setSize(Size(1280, 800))`
- `setMinimumSize(Size(800, 600))`
- `setBackgroundColor(Colors.transparent)`
- Linux'ta `linux/my_app.cc`'de window_manager için ek ayar gerekebilir
**Dosyalar:** `lib/main.dart`, `pubspec.yaml`
### Adım 3: CustomTitleBar Widget
- Sağ üst köşede, sabit konumda
- Siyah yarı saydam arka plan
- minimize (—) ve kapat (X) butonları
- **Püf nokta:** Butonların üzerine gelince InteractiveViewer zoom yapmamalı
- `AbsorbPointer` veya `IgnorePointer` ile InteractiveViewer etkileşimini engelle
- Pencere boyutlandırma ve sürükleme kenarlıkları (window_manager'in `setResizable` özelliği)
**Dosya:** `lib/widgets/custom_title_bar.dart`
### Adım 4: ImageCanvas (Ana Görüntüleme)
- `InteractiveViewer` widget'ı
- `boundaryMargin: EdgeInsets.all(double.infinity)` → sınırsız kaydırma
- `minScale: 0.1`, `maxScale: 10.0`
- `onInteractionUpdate` → zoom oranını göstermek için (ops.)
- `transformationController` ile programatik zoom kontrolü
- Mouse wheel event: `Listener` ile `onPointerSignal` yakala
- `PointerScrollEvent` → transformationController ile zoom
- Fare imlecinin resim üzerindeki konumuna göre zoom odağı
**Püf nokta:**
`InteractiveViewer`'ın default scroll-to-zoom desteği vardır ama imleç odağında zoom
için `TransformationController`'a manuel matrix hesaplaması yapmak gerekir.
**Dosya:** `lib/widgets/image_canvas.dart`
### Adım 5: Resim Yönetimi (ImageManager)
- `ChangeNotifier` ile state yönetimi
- Dışarıdan dosya eklendiğinde `notifyListeners()` + `precacheImage()`
- `ImageCache.maximumSize` ve `maximumSizeBytes` ayarı
- **Performans stratejisi:**
- Sadece görünen resim bellekte tutulur
- 12+ resim için `ImageCache.maximumSizeBytes` = 512MB
- Resim değiştirince önceki resmin cache'ini düşürmeye gerek yok (Flutter LRU)
- Sürükle-bırak: `DragTarget<List<File>>` widget'ı
**Dosya:** `lib/services/image_manager.dart`
### Adım 6: Dosya Açma (Dialog + DragDrop)
- `file_picker` paketi ile `Ctrl+O` kısayolu
- `DragTarget` ile sürükle-bırak
- Desteklenen format filtreleri: PNG, JPEG, WebP, BMP, GIF
- **Dikkat:** Flutter Linux'ta DragTarget çalışıyor — ayrıca `window_manager`'dan `onDragFile` event'i de var
**Dosya:** `lib/services/file_handler.dart`
### Adım 7: Klavye Kısayolları
- Flutter'daki `Shortcuts` + `Actions` sistemi
- `← →` → resim değiştir
- `Ctrl+O` → dosya aç
- `F11` → tam ekran (fullscreen toggle)
- `Esc` → tam ekrandan çık
- `Ctrl+Q` → çıkış
**Dosya:** `lib/shortcuts/app_shortcuts.dart`
### Adım 8: Birleştirme ve Test
- Ana ekranı `ViewerScreen`'de topla
- `Listener(onPointerSignal)` + `InteractiveViewer` entegrasyonu
- `flutter run -d linux` ile test
- Performance profiling: `flutter run --profile`
### Adım 9: Derleme
- `flutter build linux --release`
- Binary: `build/linux/x64/release/bundle/imajViewer`
## 5. pubspec.yaml Bağımlılıkları
```yaml
dependencies:
flutter:
sdk: flutter
window_manager: ^0.4.3
file_picker: ^8.0.0
path: ^1.9.0
```
## 6. Önemli Dikkat Noktaları
### Zoom Odağı (Cursor-based Zoom)
InteractiveViewer varsayılan zoom odağı merkezdir. İmleç odağında zoom için
`TransformationController` ile manuel matrix çarpımı:
```
1. Scroll event yakala (PointerScrollEvent)
2. İmlecin resim üzerindeki koordinatını hesapla
3. Matrix4.translationValues(-txt) * Matrix4.diagonal3(values(scale)) * Matrix4.translationValues(txt)
```
Detaylı implementasyon `image_canvas.dart`'da yapılacak.
### Frameless Window Sürükleme
Window_manager zaten varsayılan olarak fare ile pencerenin herhangi bir
yerinden sürüklenmesine izin vermez. Bunun için:
- CustomTitleBar alanını `DragToMoveArea` ile sarmak
- Veya `windowManager.startDragging()` çağırmak
Biz `DragToMoveArea` widget'ını kullanacağız.
### Performans
- Flutter'ın `ImageCache`'i varsayılan olarak 50 MB ve 1000 entry ile sınırlıdır
- 12 adet 4K resim için `ImageCache.maximumSizeBytes` 1 GB'a çıkarılmalı
- `RepaintBoundary` ile sadece değişen bölgelerin yeniden çizilmesi sağlanır
- `Texture` bazlı render ile GPU'da işleme devam eder
## 7. Timeline
| Adım | İş | Tahmini Süre |
|---|---|---|
| 1-2 | Proje iskeleti + window_manager | 15 dk |
| 3 | CustomTitleBar | 15 dk |
| 4 | ImageCanvas + zoom | 30 dk |
| 5-6 | Resim yönetimi + dosya açma | 30 dk |
| 7 | Kısayollar | 10 dk |
| 8-9 | Birleştirme + build | 20 dk |
| **Toplam** | | **~2 saat** |
-100
View File
@@ -1,100 +0,0 @@
# PRD — imajViewer
> Product Requirements Document
> Versiyon: 1.0
> Tarih: 2026-07-22
---
## 1. Ürün Özeti
**imajViewer**, Flutter ile geliştirilmiş, Linux masaüstünde çalışan ultra hafif bir görüntü izleyici uygulamasıdır.
12'den fazla yüksek çözünürlüklü resmi aynı anda açabilir, fare tekerleği ile anlık zoom yapabilir ve
tüm pencere alanını resim göstermek için kullanır.
## 2. Hedef Kitle
- Linux kullanıcıları
- Fotoğrafçılar, tasarımcılar (hızlı önizleme ihtiyacı)
- Yüksek sayıda resim arasında hızlıca gezinmek isteyen kullanıcılar
## 3. Fonksiyonel Gereksinimler
### F-01: Resim Açma
- Kullanıcı dosya yöneticisinden sürükle-bırak ile resim ekleyebilir
- Aynı anda 12+ resim açılabilir
- Desteklenen formatlar: PNG, JPEG, WebP, BMP, GIF (statik)
### F-02: Zoom
- Fare tekerleği (mouse scroll) ile kademesiz zoom
- Zoom odağı fare imlecinin bulunduğu nokta olmalı
- Zoom oranı: %10 - %1000 arası
### F-03: Frameless Pencere
- Native title bar yok, sadece sağ üstte küçült (minimize) ve kapat (close) butonları
- Pencere sürüklenebilir (üst kısımdan)
- Pencere boyutlandırılabilir (kenarlardan/köşelerden)
### F-04: Görüntüleme
- Scroll bar yok
- Resim pencereye sığacak şekilde başlangıçta yerleşir (fit)
- Siyah/zemin renkli arka plan
- Pencere yeniden boyutlandırılınca resim yeniden ortalanır
### F-05: Navigasyon (ileri versiyon)
- Klavye kısayolları (← → ile resim değiştirme)
- Alt kısımda thumbnail strip
## 4. Fonksiyonel Olmayan Gereksinimler
### NF-01: Performans
- 12 adet 4K (3840x2160) resim aynı anda açıkken akıcı zoom (en az 60 FPS)
- Bellek kullanımı 2 GB'ı geçmemeli
- Resimler GPU'da işlenmeli, CPU yeniden ölçekleme yapılmamalı
### NF-02: Başlangıç Süresi
- Uygulama 2 saniyeden kısa sürede açılmalı
### NF-03: Binary Boyutu
- Derlenmiş uygulama 50 MB'ı geçmemeli
### NF-04: Güvenilirlik
- Büyük resimler (10000x10000 pixel) uygulamayı çökertmemeli
- Bellek sınırı aşılınca en eski resim otomatik boşaltılmalı
## 5. UI Tasarım İlkeleri
- **Minimalist:** Sadece resim var, gereksiz hiçbir UI öğesi yok
- **Karanlık tema:** Siyah (#1a1a1a) zemin
- **Frameless:** Hiçbir pencere çerçevesi görünmez
- **Duyarlı:** Pencere boyutu değişince resim otomatik yeniden boyutlanır
## 6. Teknik Yığın
| Bileşen | Teknoloji |
|---|---|
| UI Framework | Flutter 3.44+ |
| Platform | Linux Desktop (GTK arkaplan) |
| Dil | Dart 3.12+ |
| Window Yönetimi | window_manager |
| State Yönetimi | ValueNotifier / ChangeNotifier |
| Resim İşleme | dart:ui Image + ImageCache |
| Render | Skia / Impeller (GPU) |
## 7. Kullanıcı Akışı
1. Uygulama açılır → boş siyah ekran
2. Kullanıcı dosyaları sürükler veya `Ctrl+O` ile açar
3. Resim(ler) pencereye sığacak şekilde görüntülenir
4. Fare tekerleği → zoom in/out (imleç odağında)
5. Fare sürükleme → resmi kaydırma (pan)
6. Pencere yeniden boyutlandırma → resim yeniden ortalanır
7. Sağ üst X → uygulama kapanır
## 8. Başarı Kriterleri
- [ ] 12 adet 4K resim açıkken zoom akıcı (60 FPS)
- [ ] Frameless pencere sorunsuz çalışıyor
- [ ] Scroll zoom imleç odağında çalışıyor
- [ ] Bellek kullanımı 2 GB altında
- [ ] Binary boyutu 50 MB altında
-59
View File
@@ -1,59 +0,0 @@
# Ek Görüntü Formatları Destek Planı
> Tarih: 2026-08-03 · Değerlendirme + implementasyon planı
> Not: TIFF değerlendirildi ve **vazgeçildi** (kullanıcı kararı). Bu plan yalnızca mevcut mimariye sıfır maliyetle eklenebilen formatları kapsar.
## 1. Hedef
imajViewer'ın desteklediği format listesini, **ek decode katmanı gerektirmeden** (Flutter/Skia'nın zaten açabildiği) formatlarla genişletmek.
## 2. Mevcut Durum
### Desteklenen uzantılar (kod içi, 3 yerde)
| Dosya | Satır | Liste |
|---|---|---|
| `lib/services/file_handler.dart` | 9 | png, jpg, jpeg, webp, bmp, gif |
| `lib/services/image_manager.dart` | 24 | png, jpg, jpeg, webp, bmp, gif |
| `imajviewer.desktop` + `deb/.../imajviewer.desktop` | 9 | image/png;image/jpeg;image/webp;image/bmp;image/gif;image/jpg |
| `install.sh` | 77 | aynı MimeType listesi |
### Flutter'ın yerleşik codec'leri (Skia)
Resmi dokümantasyona göre Flutter açabilir: **JPEG, PNG, GIF, WebP, BMP, WBMP**
Yani Skia zaten açabiliyor ama listemizde olmayanlar:
- `.jfif` — JPEG/JFIF içerik (aslında JPEG, magic-byte aynı)
- `.jpe` — JPEG alternatif uzantısı
- `.wbmp` — Skia'da WBMP codec var
- `.avif` — **YOK**: Flutter issue #61229 açık, native plugin (`flutter_avif`/libavif) gerekir → kapsam dışı
- `.tif/.tiff` — **YOK**: image paketi gerekir → vazgeçildi
## 3. Eklenecekler
| Uzantı | MIME | Neden açılır | Maliyet |
|---|---|---|---|
| `.jfif` | image/jpeg | JPEG codec, aynı magic byte | sadece filtre |
| `.jpe` | image/jpeg | JPEG alternatif uzantısı | sadece filtre |
| `.wbmp` | image/vnd.wap.wbmp | Skia WBMP codec | sadece filtre |
> `.avif` bilinçli olarak eklenmiyor: Flutter'ın yerleşik decoder'ı yok; native libavif bağımlılığı "ultra hafif" felsefeyi bozar. İstenirse ileride ayrı plan yapılır.
## 4. Yapılacaklar (sıralı)
1. [ ] `file_handler.dart` → allowedExtensions: `'jfif', 'jpe', 'wbmp'` ekle
2. [ ] `image_manager.dart` → supported: `'jfif', 'jpe', 'wbmp'` ekle
3. [ ] `imajviewer.desktop` → MimeType'a `image/jpeg` zaten var (jfif/jpe kapsar), `image/vnd.wap.wbmp;` ekle
4. [ ] `deb/usr/share/applications/imajviewer.desktop` → aynı
5. [ ] `install.sh` → aynı MimeType güncellemesi
6. [ ] Windows installer (`.iss`) dosya ilişkilendirme listesi kontrolü — varsa `image/jpeg` jfif/jpe'yi kapsar
## 5. Riskler / Açık Sorular
- **JFIF varyantı:** Bazı .jfif dosyaları CMYK-JPEG olabilir; Skia çoğunu açıyor ama tamamını değil → açılmayanlar `errorBuilder` ekranı gösterir (mevcut davranış, çökme yok)
- **WBMP nadir:** Çoğu kullanıcı hiç karşılaşmaz; eklemesi zararsız
- **Windows ilişkilendirme:** Inno Setup `.iss` dosyası `image/jpeg` kayıtlıysa .jfif/.jpe otomatik kapsanır; `.wbmp` için ayrı registry gerekebilir (opsiyonel)
## 6. Kabul Kriterleri
- [ ] `.jfif`, `.jpe`, `.wbmp` dosyaları dosya seçicide görünür
- [ ] JFIF dosyası pan/zoom/filtre ile görüntülenir
- [ ] Mevcut PNG/JPEG/WebP/GIF/BMP davranışı değişmez
-65
View File
@@ -1,65 +0,0 @@
# Galeri: Klasör Tabanlı Gezinme — Rapor ve Plan
Tarih: 2026-08-03
Durum: Uygulandı (2026-08-03)
## 1. Mevcut Sorunun Analizi
### RAM şişmesi — `lib/services/image_manager.dart`
- `addImages()` her eklenen yol için `precacheImage(FileImage(...))` çağırıyor (satır 33-35).
- Sonuç: toplu sürüklemede tüm resimler tam çözünürlükte decode edilip ImageCache'e yazılıyor.
- Örnek: 12MP JPEG dekode ≈ 48MB RGBA; 30 resim ≈ 1.4GB.
- Decode UI thread'inde yapılıyor → pencere donması, "çok pencere açmış gibi" hissi.
- `configureCache()` tavanı: 1000 görsel / 1GB (satır 62-65).
### Sağ tık efektlerinde çökme — `lib/widgets/image_canvas.dart`
- Her pointer move'da `setState` → `ColorFiltered` tüm görseli yeniden boyuyor (satır 224-240).
- Bellek zaten yüksekken + VM'de yazılımsal render → çökme en çok burada görülüyor.
- Kök neden RAM şişmesi; o çözülünce bu senaryo da hafifler.
## 2. Alınan Kararlar (kullanıcı onayı)
1. **Precache tamamen kaldırılacak** — hiçbir resim önceden decode edilmeyecek.
2. Görüntüleme anında decode: sıradaki resme geçişte yüklenene kadar beklenir.
3. **RAM kısıtlaması yok** — cache limitlerine dokunulmayacak, daraltılmayacak.
4. Önceki resim cache'ten düşebilir (zorunlu tutma yok). Precache olmadığı için
bellekte aynı anda yalnızca açık olan resim(ler) durur; kabarık galeri listesi
sorun yaratmaz.
## 3. Yeni Özellik — Klasör Tabanlı Galeri
Amaç: resimleri topluca seçip sürükleyerek galeri kurmak yerine, açılan herhangi
bir resmin bulunduğu dizin otomatik galeri olsun.
Davranış:
- Bir resim açıldığında (çift tık, CLI argümanı, sürükle-bırak) bulunduğu dizin taranır.
- Dizindeki desteklenen imaj dosyaları (png/jpg/jpeg/jfif/jpe/webp/bmp/gif/wbmp)
galeri listesi olarak set edilir.
- Açılan resim listedeki konumunu bulur ve o an gösterilir.
- Ctrl+Tab / Ctrl+Shift+Tab ile klasör içinde ileri-geri gezilir.
- Mevcut pencere başına singleton yapısı korunur — her pencere ayrı process,
her biri kendi klasör galerisini kurar.
### Sıralama
- Dosya yöneticisinin (Thunar/PCManFM/Nautilus) klasör sort'u OS seviyesinde
okunamaz — uygulama içi ayardır, dosya sisteminde saklanmaz. Bu seçenek pratikte yok.
- Bu yüzden **tarihe göre** sıralanacak (dosyanın değiştirilme zamanı).
- Yön: **en yeni önce, en eski sonra** (azalan).
## 4. Uygulama (uygulandı)
- `lib/services/image_manager.dart` — precache döngüsü ve `buildContext` kaldırıldı.
`addImages` yerine `setGalleryFromPaths(paths)` geldi: ilk geçerli resmin klasörünü
tarar, desteklenenleri filtreler, değiştirilme zamanına göre en yeni önce sıralar,
açılan resmi o anki konum yapar. (Aynı zaman damgasında dosya adına göre sabit sıra)
- `lib/services/file_handler.dart` — `addImages` çağrıları `setGalleryFromPaths` oldu
(Ctrl+O ve sürükle-bırak aynı akıştan geçiyor).
- `lib/screens/viewer_screen.dart` — `buildContext` ataması kaldırıldı (precache yok).
- RAM kısıtlaması eklenmedi — `configureCache` aynen duruyor.
## 5. Açık Sorular
Çözüldü:
1. ~~Tarih sıralama yönü~~ → **en yeni önce, en eski sonra**
2. ~~Pencereye birden fazla resim sürüklenirse~~ → **sürüklenen resmin klasörüne geçilir**
(galeri açıkken yeni resim gelirse de aynı: o resmin klasörü yeni galeri olur)
-187
View File
@@ -1,187 +0,0 @@
# Image Canvas Interactions — Analiz ve İmplementasyon Planı
**Tarih:** 2026-07-27
**Kapsam:** `lib/widgets/image_canvas.dart`
---
## 1. Mevcut Durum
`ImageCanvas` widget'ı şu anda:
- **Sol tık + sürükle:** Pan (imleç hareketiyle resmi kaydırma) — `GestureDetector.onPanUpdate`
- **Tekerlek:** Zoom (imleç pozisyonu merkezli) — `Listener.onPointerSignal → PointerScrollEvent`
- **Çift tık:** Fill/Fit mod geçişi — `GestureDetector.onDoubleTap`
- **Transform:** `Matrix4` ile sadece `translate(_tx, _ty)` + `scale(_sc)`, rotate yok
- **Renk/efekt filtresi:** Yok
Sorun: `GestureDetector.onPanUpdate` hangi mouse tuşuna basıldığını ayırt etmez. Tüm tuşlarla yapılan sürüklemeyi `onPanUpdate` olarak yakalar. Sağ tık için ayrı bir işlem yapılamaz.
---
## 2. Gereksinimler
| # | Etkileşim | Davranış |
|---|---|---|
| R1 | **Sağ tık + Dikey sürükle** | Rotate. Yukarı sürükle → saat yönü, aşağı sürükle → saat tersi. Viewport merkezinden dönmeli. Hassasiyet: ~100px = 90° |
| R2 | **Ctrl + Sağ tık + Dikey sürükle** | Contrast ayarı. Yukarı sürükle → artır, aşağı sürükle → azalt. Aralık: 0.0 – 2.0 (default: 1.0) |
| R3 | **Shift + Sağ tık + Dikey sürükle** | Brightness ayarı. Yukarı sürükle → artır, aşağı sürükle → azalt. Aralık: -1.0 – 1.0 (default: 0.0) |
| R4 | **Sağ çift tık** | Reset: rotate, contrast, brightness sıfırlanır (0°, 1.0, 0.0) |
| R5 | **Sol tık + sürükle** | Mevcut pan davranışı korunacak |
---
## 3. Teknik Tasarım
### 3.1 Gesture Handling: `GestureDetector` → `Listener`
Mevcut `GestureDetector` tuş ayrımı yapamadığı için, **`Listener` widget'ı** ile pointer event'leri manuel yakalayacağız:
```
Listener(
onPointerDown: _onPointerDown, // hangi tuş + modifier → mod belirle
onPointerMove: _onPointerMove, // moda göre pan / contrast / brightness
onPointerUp: _onPointerUp, // tıklama ise rotate uygula
onPointerSignal: _handleScroll, // zoom (mevcut)
)
```
**Mod State Machine:**
```
PointerDown:
if (sol tuş) → mod = pan
if (sağ tuş, no modifier) → mod = rotate, başlangıç açısını kaydet
if (sağ tuş + Ctrl) → mod = contrast, başlangıç değerini kaydet
if (sağ tuş + Shift) → mod = brightness, başlangıç değerini kaydet
PointerMove:
pan: _tx += dx, _ty += dy
rotate: _angle += dy * sensitivity (clamp yok, serbest dönüş)
contrast: _contrast += dy * sensitivity, clamp(0.0, 2.0)
brightness: _brightness += dy * sensitivity, clamp(-1.0, 1.0)
PointerUp:
modu temizle
Çift tık (sağ tuş):
_angle = 0; _contrast = 1.0; _brightness = 0.0
```
**Tuş tespiti:** `PointerEvent.buttons` bitmask'i:
- `kPrimaryMouseButton` (1) = sol
- `kSecondaryMouseButton` (2) = sağ
- `kMiddleMouseButton` (4) = orta
**Modifier tespiti:** `HardwareKeyboard.instance.isControlPressed` / `isShiftPressed`
### 3.2 Rotate — Matrix4 Güncellemesi
Rotate, viewport'un **tam merkezi** etrafında dönmeli. Matrix4 işlem sırası (son yazılan ilk uygulanır):
```dart
Matrix4.identity()
..translate(vpCenterX, vpCenterY) // 4. pivot noktasına geri taşı
..rotateZ(_angleRad) // 3. döndür
..translate(-vpCenterX, -vpCenterY) // 2. pivot'u orijine taşı
..translate(_tx, _ty) // 1. pan offset'i
..scale(_sc); // 0. zoom
```
`_angleRad = _angle * pi / 180`
### 3.3 Contrast ve Brightness — ColorFilter
`Image` widget'ını `ColorFiltered` ile saracağız. Hem contrast hem brightness tek bir matrix'te birleştirilebilir:
```dart
ColorFiltered(
colorFilter: ColorFilter.matrix(_buildColorMatrix()),
child: imageWidget,
)
```
**Birleşik matrix (contrast × brightness):**
Önce brightness, sonra contrast uygulanır:
```
b = brightness (-1.0 .. 1.0)
c = contrast (0.0 .. 2.0)
t = (1.0 - c) / 2.0
Matrix:
c, 0, 0, 0, t + b,
0, c, 0, 0, t + b,
0, 0, c, 0, t + b,
0, 0, 0, 1, 0,
```
Default değerler: `c = 1.0, b = 0.0` → birim matrix (görüntü değişmez).
### 3.4 Sensitivity (Hassasiyet)
- **Rotate:** `_angle += dy * 0.9` (100px sürükleme ≈ 90°)
- **Contrast:** `_contrast += dy * 0.005` (200px sürükleme = 1.0 birim)
- **Brightness:** `_brightness += dy * 0.005` (200px sürükleme = 1.0 birim)
Değerler `setState` içinde clamp'lenir (rotate hariç, serbest dönüş).
### 3.5 State Değişkenleri (Eklenecekler)
```dart
double _angle = 0; // derece cinsinden, serbest
double _contrast = 1.0; // 0.0 – 2.0
double _brightness = 0.0; // -1.0 – 1.0
// Gesture tracking
String _interactionMode = 'none'; // 'pan' | 'rotate' | 'contrast' | 'brightness'
Offset _pointerDownPos = Offset.zero;
double _startAngle = 0;
double _startContrast = 1.0;
double _startBrightness = 0.0;
```
---
## 4. Riskler ve Dikkat Edilecek Noktalar
| Risk | Etki | Çözüm |
|---|---|---|
| Sağ tık + sürükleme, pan ile çakışabilir | Yanlış moda girme | `rotate_pending` modunda eşik kontrolü: 5px'den az hareket → rotate, fazla → pan |
| Rotate sonrası pan yönü bozulabilir | Kullanıcı deneyimi | Rotate, pan offset'lerinden (`_tx, _ty`) önce değil sonra uygulandığı için etkilenmez. Ama fit mode'da letterbox offset'i (`m, n`) rotate'den etkilenir — test edilecek |
| ColorFilter performansı | Büyük resimlerde takılma | Flutter'da `ColorFiltered` GPU'da çalışır, performans sorunu beklenmiyor |
| Ctrl/Shift + scroll zoom ile çakışma | Zoom yerine contrast değişebilir | Scroll event'leri `onPointerSignal` ile ayrı yakalanıyor, karışmaz |
| Fill/Fit mod geçişinde rotate sıfırlanmalı mı? | UX kararı | `_toggleFillFit()` içinde `_angle = 0` yapalım — fill ↔ fit geçişinde rotate sıfırlansın |
---
## 5. İmplementasyon Planı
### Adım 1: Listener tabanlı gesture sistemine geçiş
- `GestureDetector` yerine `Listener` kullan
- `onPointerDown`, `onPointerMove`, `onPointerUp` handler'larını yaz
- Sol tık pan'i koru, sağ tık modlarını ekle
### Adım 2: Rotate
- `_angle` state değişkeni ekle
- `Matrix4`'e rotate transform'u ekle
- `_resetView()` ve `_toggleFillFit()` içinde `_angle = 0`
### Adım 3: Contrast ve Brightness
- `_contrast`, `_brightness` state değişkenleri ekle
- `ColorFiltered` widget'ı ile image'ı sar
- `_buildColorMatrix()` metodunu yaz
### Adım 4: Test ve clamp
- Tüm modlarda clamp/değer aralığı kontrolü
- Rotate + zoom + pan birlikte çalışıyor mu test et
---
## 6. Etkilenen Dosyalar
| Dosya | Değişiklik |
|---|---|
| `lib/widgets/image_canvas.dart` | Ana değişikliklerin tümü burada |
| (başka dosya yok) | Diğer widget'lar etkilenmez |
-39
View File
@@ -1,39 +0,0 @@
# imajViewer — Dokümantasyon İndeksi
| Tarih | Dosya | Konu |
||---|---|---|
| 2026-08-06 | [rapor-github-acik-kaynak.md](rapor-github-acik-kaynak.md) | **Rapor** — GitHub açık kaynak hazırlığı (gereksinimler, riskler, plan) |
| 2026-08-06 | [Implementation.md](Implementation.md) | Teknik uygulama dokümanı (kökten taşındı) |
| 2026-08-06 | [PRD.md](PRD.md) | Product Requirements Document (kökten taşındı) |
| 2026-08-06 | [update_plan_01.md](update_plan_01.md) | Pan sınırlama & zoom merkezi sorunu (kökten taşındı) |
| 2026-08-06 | [rapor-pan-kilitleme-nuks.md](rapor-pan-kilitleme-nuks.md) | **Rapor** — efekt uygulanmış görselde pan sırasında kilitlenme nüksü (analiz, açık sorular) |
| 2026-08-06 | [rapor-gezinme-ayarlar-print.md](rapor-gezinme-ayarlar-print.md) | **Rapor** — gezinme okları (hover), ayarlar/gear (sıralama), yazdırma (printing paketi) |
| 2026-08-06 | [rapor-sag-tik-donma-analizi.md](rapor-sag-tik-donma-analizi.md) | **Rapor** — sağ tuş efektlerinde donma analizi + slider UI değerlendirmesi |
| 2026-08-03 | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | **Düzeltme** — `_vpSize` sıfırlama bug'ı: çift tıklama sonrası zoom ölümü/pan serbestliği |
| 2026-08-03 | [performance_llvmpipe_issue.md](performance_llvmpipe_issue.md) | **Rapor** — llvmpipe donma analizi, kare hızı throttle, ölçüm sonuçları |
| 2026-08-03 | [rapor-brightness.md](rapor-brightness.md) | **Rapor** — brightness efekti (Ctrl+Shift+sağ tık) + 4x5 matris birleştirme |
| 2026-08-03 | [galeri-klasor-tabanli.md](galeri-klasor-tabanli.md) | **Rapor** — klasör tabanlı galeri + precache/RAM kararı |
| 2026-08-03 | [session-2026-08-03.md](session-2026-08-03.md) | **Session özeti** — jfif/jpe/wbmp, pencere cascade, konum kaydı düzeltmesi |
| 2026-08-03 | [format-destegi-ek-formatlar.md](format-destegi-ek-formatlar.md) | **Plan** — jfif/jpe/wbmp desteği (TIFF vazgeçildi) |
| 2026-08-03 | [pencere-cascade-yerlesim.md](pencere-cascade-yerlesim.md) | **Plan** — yeni pencereyi yan yana / alt satıra açma (screen_retriever) |
| 2026-07-27 | [session-windows-ci.md](session-windows-ci.md) | **Windows setup.exe yeniden derlendi, Gitea CI eklendi** |
| 2026-07-27 | [session-2026-07-27.md](session-2026-07-27.md) | **Session özeti** — rotate aktif, StartupWMClass ile taskbar gruplama |
| 2026-07-27 | [image-canvas-interactions.md](image-canvas-interactions.md) | Detaylı analiz: contrast, saturation, rotate implementasyon planı |
| 2026-08-10 | [windows-setup.md](windows-setup.md) | **Windows Development Ortam Kurulum Rehberi** — Flutter SDK, VS Build Tools, CMake, Inno Setup kurulum adımları |
| önceki | [zoom-clamp-mekanizmasi.md](zoom-clamp-mekanizmasi.md) | Zoom clamp mekanizması (letterbox-aware) |
## Geliştirme Akışı — Otomatik Build ve Kurulum
### Linux
1. Debug build: `flutter build linux --debug`
2. Sisteme kurulum: `./install.sh` (debug bundle tercih edilir → `~/.local/lib/imajviewer/`)
### Windows
1. Ortam kurulumu: [windows-setup.md](windows-setup.md) rehberini takip edin
2. Build + installer: `.\build-windows.ps1`
3. Manuel kurulum (build çıktılarından): `.\install.ps1`
4. Kaldırma: `.\uninstall.ps1`
Projede değişiklik yapıldıktan sonra otomatik yapılır (ayrıca istenmez):
1. Debug build: `flutter build linux --debug`
2. Sisteme kurulum: `./install.sh` (debug bundle tercih edilir → `~/.local/lib/imajviewer/`)
-111
View File
@@ -1,111 +0,0 @@
# Pencere Cascade Yerleşimi Planı
> Tarih: 2026-08-03 · Değerlendirme + implementasyon planı (henüz kod değişikliği yapılmadı)
## 1. Sorun
imajViewer'a yeni bir resim açıldığında pencere **hep aynı koordinat ve boyutta** açılıyor (önceki pencerenin üstüne biniyor). İstenen davranış:
- Yeni pencere, önceki pencerenin **x + width** kadar sağına açılsın (yan yana)
- Ekrandan **taşacaksa** alt satıra, satır başından (x baştan) açılsın
Bu, Windows'un klasik "cascade windows" davranışının basit hali.
## 2. Mevcut Durum Analizi
### Pencere açılış akışı (`lib/main.dart`)
```
main(args)
→ WindowPersistence.restore() // window.json'dan son konum/boyut
→ windowOptions = WindowOptions(size: saved ?? 1280x800)
→ waitUntilReadyToShow:
saved varsa → setPosition(saved.position) + setSize(saved.size)
saved yoksa → center()
→ runApp(ImajViewerApp(initialFiles: args))
```
### Konum kaydetme akışı (`lib/screens/viewer_screen.dart` + `lib/services/window_persistence.dart`)
- `_startPolling()`: 2 saniyede bir `windowManager.getBounds()` → `WindowPersistence.saveSync()` → `~/.config/imajviewer/window.json` (Linux) / `%APPDATA%/imajviewer/window.json` (Windows)
- `onWindowEvent` (move/resize/close) → debounce'lu `_doSave()`
### Sorunun kökü
- Her yeni pencere `window.json`'daki **son kaydedilen** konumu okuyor → aynı yere açılıyor
- Cascade mantığı yok; ekran genişliği bilgisi hiç sorgulanmıyor
## 3. Teknik Araştırma (doğrulandı)
### window_manager 0.4.3 (mevcut bağımlılık)
- `setPosition`, `setSize`, `getBounds`, `getPosition`, `getSize`, `center()` → var
- **Ekran/display API'si → YOK** (getScreen/getPrimaryDisplay yok)
### window_manager 0.5.2 (güncel)
- Ekran API'si yine yok — `screen_retriever`'ı bağımlılık olarak içeriyor ama expose etmiyor
### screen_retriever 0.2.2 (pub.dev, window_manager'ın zaten bağımlısı)
- `ScreenRetriever.instance.getPrimaryDisplay()` → `Display`
- `getAllDisplays()` → `List<Display>`
- `Display` alanları:
- `size` (ekran tam boyutu, logical px)
- `visiblePosition` (Offset — çalışma alanı başlangıcı)
- `visibleSize` (Size — görev çubuğu hariç çalışma alanı)
- `scaleFactor`
- Linux (X11) + Windows desteği var
**Karar: `screen_retriever` doğrudan bağımlılık olarak eklenir** (window_manager sürümü değişmez).
## 4. Önerilen Algoritma
`main.dart`'ta, `windowOptions` oluşturulmadan önce:
```
1. saved = WindowPersistence.restore()
2. display = await ScreenRetriever.instance.getPrimaryDisplay()
workArea = display.visiblePosition + display.visibleSize (yoksa display.size)
3. Eğer saved != null:
newX = saved.x + saved.width // önceki pencerenin sağına
newY = saved.y
// Yatay taşma → alt satıra, satır başına
if (newX + pencereGenişliği > workArea.right):
newX = workArea.left
newY = saved.y + saved.height
// Dikey taşma da varsa → en başa sar (0,0)
if (newY + pencereYüksekliği > workArea.bottom):
newX = workArea.left
newY = workArea.top
position = Offset(newX, newY)
4. saved yoksa → center() (mevcut davranış)
```
### Örnek
- Ekran 1920×1080, pencere 1280×800, önceki pencere (100, 50)
- 1. yeni: (1380, 50) — yan yana ✓
- 2. yeni: (100+1280*2 = hesaplanır) → 1380+1280=2660 > 1920 → (0, 850) — alt satır başı ✓
> Not: `saved` her 2 saniyede bir güncellendiği için "önceki pencere" = en son kaydedilmiş konum. Bu, en son açılan pencerenin konumu olur — istenen davranışla uyumlu.
## 5. Yapılacaklar (sıralı)
1. [ ] `pubspec.yaml` → `screen_retriever: ^0.2.2` ekle, `flutter pub get`
2. [ ] `lib/main.dart` → cascade hesabı ekle (yeni bir servis fonksiyonuna taşınabilir)
3. [ ] `lib/services/window_persistence.dart` → `restore()` dışında, cascade hesabı için yardımcı fonksiyon:
- `static Future<Offset?> cascadePosition({required Size windowSize})` (opsiyonel, temizlik için)
4. [ ] Ekran boyutu alınamazsa (hata / çoklu monitör) → mevcut davranışa düş (saved veya center)
5. [ ] Test: ardışık 3-4 resim aç → yan yana dizilim + taşınca alt satır
## 6. Riskler / Açık Sorular
- **Çoklu monitör:** `getPrimaryDisplay` birincil ekranı verir. İmleç hangi ekrandaysa oraya açılsın mı? (başlangıç: primary)
- **Pencere kapatılıp yenisi açılırsa:** `window.json` son kapanan pencerenin konumunu tutar; yeni pencere onun yanına açılır — boşluk oluşabilir, kabul edilebilir
- **Tam ekran / maximize durumu:** saved boyut maximize ise cascade hesabı yine de eski boyutla yapılır; `window.json` sadece normal durumda yazıldığı için risk düşük
- **Pencere ekrandan tamamen dışarıdaysa:** `restore()` zaten `x < -1000 || y < -1000` guard'ı var (window_persistence.dart:44) — yine de sağlamlaştırılabilir
## 7. Kabul Kriterleri
- [ ] Ardışık 2 pencere: ikincisi birincinin hemen sağında
- [ ] Yatay taşma: yeni pencere alt satıra, satır başından başlar
- [ ] İlk açılışta (kayıt yok) pencere yine ortalanır
- [ ] Pencere boyutu/konumu kaydetme davranışı bozulmaz
- [ ] Linux ve Windows'ta çalışır (screen_retriever her ikisini destekliyor)
-71
View File
@@ -1,71 +0,0 @@
# Performans: llvmpipe (Yazılımsal Render) Altında Efekt Donmaları
Tarih: 2026-08-03
Durum: Uygulandı (ölçüm sonuçları bölüm 3'te)
## 1. Teşhis
- VM'de donanım GPU'su yok: `glxinfo -B` → `Device: llvmpipe (LLVM 21.1.8, 128 bits)`,
`/dev/dri` yok. Tüm çizim 4 CPU çekirdeğinde LLVM ile yapılıyor.
- Efekt sürüklemesi (contrast/saturation/brightness/rotate): her `PointerMoveEvent`
doğrudan `setState` → tüm görsel katman `ColorFiltered` ile yeniden boyanıyor.
- Fare sürüklemesinde olay hızı 100-250+/sn; llvmpipe kare süresi 20-100ms+.
Üretim > tüketim → kuyruk birikmesi → UI thread %100 → donma. Kuyruk boşalınca
arayüz yakalıyor ("1 dk sonra normale döndü").
- Rotate'in önce kilitlenmesi normal: `Transform` ikinci bir composite geçişi ekliyor.
Contrast aynı duvara galeri akışıyla (daha büyük görseller, dolu ImageCache) çarptı.
## 2. Uygulanan Değişiklikler
### Render izolasyonu ve kare hızı eşitleme — `lib/widgets/image_canvas.dart`
- Pointer move / scroll / pan / efekt işleyicilerinden `setState` kaldırıldı.
- `ValueNotifier<int> _renderTick` + `ValueListenableBuilder` — görsel katman yalnızca
tick ile yeniden kurulur.
- `SchedulerBinding.instance.scheduleFrameCallback` — saniyede en fazla 1 rebuild/kare
(ekran yenileme hızıyla sınırlı); olaylar kareye eşitlenir, kuyruk birikmez.
- `RepaintBoundary` — efekt repaint'i pencere/title bar/üst widget render'ını etkilemez.
- Not: per-efekt `ValueNotifier` yerine tek tick notifier kullanıldı. Nedeni:
`ValueListenableBuilder` her değer değişiminde yeniden kurulur (olay hızında kalır);
throttling için tek noktadan kare başına tick gerekli.
### Image pipeline — `lib/widgets/image_canvas.dart` + `lib/services/image_manager.dart`
- `ResizeImage.resizeIfNeeded(cacheWidth: viewport×2)` — yüksek çözünürlüklü görseller
görüntüleme boyutunun ~2 katıyla sınırlı decode edilir: decode süresi, bellek ve
llvmpipe CPU yükü düşer. (Zoom 2x'e kadar net; ötesi canlı önizleme için kabul edilen takas.)
- `configureCache`: 1000 görsel / 1GB → **16 görsel / 256MB**.
- Galeri/klasör geçişinde `imageCache.clear()` — canlı (gösterilen) görsel korunur,
eski klasörün decode edilmiş `ui.Image` nesneleri bellekten düşer.
## 3. Ölçüm (llvmpipe)
Ölçüm yöntemi: 4000×3000 PNG açıldı; kullanıcı elle contrast / saturation /
brightness sürüklemeleri yaptı. Kare süreleri `addTimingsCallback` ile (geçici
enstrümantasyon, sonra kaldırıldı), CPU `pidstat` ile örneklendi.
| Metrik | Değer |
|---|---|
| Kare sayısı (contrast turu) | 76 |
| build ort / max | 3.9 ms / 78 ms |
| raster ort / max | 15.1 ms / 52 ms |
| Kare sayısı (sat/brightness turu) | 133 |
| build ort / max | 3.0 ms / 82 ms |
| raster ort / max | 14.1 ms / 48 ms |
| CPU — boştayken | %0-14 |
| CPU — aktif sürüklemede | %100-163 (1-1.6 çekirdek) |
Değerlendirme: raster ort. ~14-15 ms (16.7 ms kare bütçesinin altında), sürükleme
sırasında maksimum ~50 ms → ara sıra kare atlanır ama **kuyruk birikmesi ve
dakikalarca süren donma yok**. CPU 4 çekirdeğin tamamını asla doyurmuyor
(önceki durumda sürükleme anında tam doygunluk + 1 dk donma vardı).
## 4. VM Sistem Gereksinimleri (öneri — uygulama dışı)
llvmpipe tamamen yazılımsal; kalıcı çözüm VM tarafında donanım hızlandırmasıdır:
- **virtio-gpu + VirGL**: QEMU/libvirt'ta `-device virtio-vga-gl` / `virtio-gpu-gl`
(mesa'nın VirGL yoluyla hızlandırılmış GL'si). `/dev/dri` görünür hale gelir.
- **PCIe Passthrough**: gerçek GPU'yı VM'e bağlama (en yüksek performans, donanım gerektirir).
- Bu olmadan uygulama yukarıdaki yazılımsal önlemlerle çalışır, ancak büyük görsellerde
efekt sürüklemesi yine de kare atlayabilir.
-62
View File
@@ -1,62 +0,0 @@
# Rapor: Brightness Efekti + Sağ Tık Etkileşim Düzeni
Tarih: 2026-08-03
Durum: Rapor — uygulama için onay bekleniyor
## 1. Gönderilen Araştırmanın Özeti
- `ColorMatrixHelper`: Rec.709 luma katsayıları (0.2126 / 0.7152 / 0.0722) — projedekiyle aynı.
- Üç ayrı 4x5 renk matrisi:
- **Brightness:** RGB'ye offset ekler (`b` → -255..255)
- **Contrast:** `c` ile çarpar + `128*(1-c)` offset (0..2 arası, 1.0 orijinal)
- **Saturation:** Rec.709 ağırlıklı karışım (0..2 arası, 1.0 orijinal)
- Birleştirme: `M_final = M_contrast * M_brightness * M_saturation` — genel 4x5 matris
çarpımı; uygulama sırası saturation → brightness → contrast.
- Uygulama: `ColorFiltered` + `ColorFilter.matrix` ile GPU shader'ı — projedekiyle aynı mekanizma.
- Örnek widget (`ImageAdjusterWidget`) **slider tabanlı**; bizim uygulama jest tabanlı
olduğu için bu kısım direkt uygulanmayacak. Asıl değer matris hesabında.
## 2. Projedeki Mevcut Durum — `lib/widgets/image_canvas.dart`
- State: `_contrast` (1.0), `_saturation` (1.0) — brightness yok.
- `_buildCombinedFilter()`: saturation × contrast tek matriste elle birleştiriliyor
(contrast offset: `t = (1-c)/2` → 255 ölçeğinde 127.5*(1-c)).
- Cache: `_cachedFilter` + `_cachedSat` / `_cachedCon`.
- Etkileşim: sağ tık = contrast, Ctrl+sağ tık = saturation, Shift+sağ tık = rotate,
sağ çift tık = reset (tüm efektler).
- Modifier takibi: `_onHardwareKey` yalnızca ctrl ve shift'i izliyor.
## 3. Karşılaştırma — Araştırma vs Mevcut Kod
| Konu | Mevcut | Araştırma | Fark |
|---|---|---|---|
| Saturation matrisi | Rec.709, aynı formül | Rec.709, aynı formül | Matematik birebir aynı |
| Contrast offset | 127.5*(1-c) | 128*(1-c) | ~%0.4, görsel fark yok |
| Brightness | yok | `b` offset matrisi | **Eklenecek** |
| Birleştirme | elle 2 matris | genel 4x5 çarpım | 3 efekt için genel çarpım daha temiz |
| Arayüz | jest (sağ tık + sürükle) | slider | Widget değil, matris katmanı alınacak |
## 4. Uygulama Planı
1. `_brightness` state ekle (varsayılan 0.0, aralık -100..100).
2. `_onPointerDown`: öncelik sırası — **Ctrl+Shift → brightness**, Ctrl → saturation,
Shift → rotate, yoksa contrast. (Başlangıçta Alt denenmişti; XFCE Alt+klik'i
pencere hareketine kaptığı için **Ctrl+Shift**'e geçildi.)
3. `_onPointerMove`: brightness modunda `_brightness = _startBrightness + dy * 0.5`
(clamp -100..100; hassasiyet açık soru).
4. `_buildCombinedFilter`: araştırmadaki 4x5 çarpım mantığıyla 3 efekt birleştir
(saturation → brightness → contrast); cache anahtarına `_brightness` ekle.
5. Reset (sağ çift tık), `_resetView`, `_toggleFillFit`: brightness 0'a döner.
6. `hasFilter` koşuluna `_brightness != 0` eklenir (ColorFiltered yalnızca gerektiğinde).
## 5. Dikkat Edilecekler
- **Linux/XFCE:** Alt+klik WM tarafından pencere hareketine kaptırıldığı için
brightness **Ctrl+Shift+sağ tık**'a alındı (Alt kaldırıldı).
- **Uygulama sırası:** saturation → brightness → contrast sabit kalacak; farklı sıra farklı sonuç verir.
- Reset davranışı brightness'i de kapsar.
## 6. Açık Sorular
1. Brightness hassasiyeti: `dy * 0.5`, aralık -100..100 uygun mu?
2. Rotate Shift+sağ tık'ta kalıyor (değişiklik yok) — onay?
-80
View File
@@ -1,80 +0,0 @@
# Rapor — Gezinme Okları, Ayarlar (Sıralama) ve Yazdırma
Tarih: 2026-08-06
Durum: Uygulandı
## Amaç
Klavyede zaten çalışan sol/sağ ok tuşu gezinmesine görsel karşılık eklemek;
ayarlar (gear) penceresiyle klasör sıralamasını kullanıcıya bırakmak; resmi
yazıcıya göndermek.
## Yapılan Değişiklikler
### 1. Gezinme okları (görsel arayüz)
- Sol/sağ alt köşeye sade yuvarlak ok butonları eklendi (`_NavButton`,
`lib/screens/viewer_screen.dart`).
- Normalde görünmez; mouse görüntü alanına girince belirir
(`MouseRegion` → `_controlsVisible`).
- Hover'da kırmızı arka plan (kapatma butonuyla aynı stil).
- Tooltip yok (kullanıcı istemedi).
- `ImageManager.instance.previousImage()/nextImage()` çağırır — klavye
kısayoluyla aynı yolu kullanır.
### 2. PITFALL — MouseRegion flicker döngüsü (çözüldü)
İlk uygulamada MouseRegion yalnızca resim alanını sarıyordu, butonlar
Stack'te üstte ayrı duruyordu. Mouse butonun üzerine gelince hit-test
butona gidiyor → MouseRegion `onExit` → butonlar kayboluyor → mouse tekrar
resim alanına düşüyor → `onEnter` → sonsuz yanıp sönme ve tıklanamama.
Çözüm: `MouseRegion`'ı **tüm Stack'in** üzerine taşı (DropTarget'ın
çocuğu). Butonlar da Stack içinde kaldığı için onların üzerindeyken
bölgeden çıkılmaz; `onExit` yalnızca pencere alanı terk edilince tetiklenir.
### 3. Gear (Ayarlar) butonu
- İlk olarak görüntü alanının sol üstüne kondu; kullanıcı "title yüzünden
aşağıda çıkıyor" deyince **title bar'ın soluna** taşındı
(`CustomTitleBar(onSettings: ...)`, `lib/widgets/custom_title_bar.dart`).
- Title bar `visible` parametresiyle `_controlsVisible`'a bağlandı: mouse
resmin dışındayken title (gear dahil) saydam (AnimatedOpacity 0), mouse
girince görünür.
- Hover arka planı kırmızı (`_BarButton` varsayılanı).
### 4. Ayarlar penceresi (`_showSettings`)
Gear'a tıklayınca açılan AlertDialog:
- **Sıralama**: Zamana göre / İsme göre (RadioListTile).
`ImageManager.sortByName` — `setGalleryFromPaths` içinde sıralama
karşılaştırıcısı buna göre değişir; isim sıralaması `toLowerCase`
karşılaştırma ile (büyük/küçük harf duyarsız). Mod değişince mevcut
galeri `setGalleryFromPaths([_images[_currentIndex]])` ile yeniden
kurulur (açık resim korunur).
- Uygulamanın amacı (kısa paragraf).
- Kısayollar listesi (F11 satırı kaldırıldı — tam ekran özelliği yok).
### 5. Yazdırma (`_printImage`)
- Yeni bağımlılıklar: `printing: ^5.13.4`, `pdf: ^3.11.1` (Linux dahil
masaüstü destekli).
- Buton ayarlar penceresinin actions kısmında ("Yazdır"); ayrı köşe butonu
değil (kullanıcı istediği yerleşim).
- Akış: `File.readAsBytes` → `pw.MemoryImage` → `pw.Document` A4 sayfa,
`BoxFit.contain` → `Printing.layoutPdf` (sistem yazıcı dialogu).
### 6. `_BarButton.hoverColor` kaldırıldı
Gear title'a taşınıp tek hover rengi (kırmızı) kaldığı için opsiyonel
parametre gereksizleşti; analyze uyarısı (unused_element_parameter)
temizlendi.
## Doğrulama
- `flutter analyze`: 21 info (pre-existing baseline; yeni hata yok).
- `flutter build linux --release`: ✓ Built.
- `./install.sh` (debug bundle silinip release kuruldu): exit 0.
- `bash dist/rebuild-deb.sh`: `dist/imajviewer_1.0.1_amd64.deb` (11M) üretildi.
- Görsel davranış kullanıcı tarafından manuel test edildi (oklar,
gear/title saydamlığı, hover renkleri, sıralama, print dialogu).
-88
View File
@@ -1,88 +0,0 @@
# Rapor — imajViewer'ı GitHub'da Açık Kaynak Yapmak
> Tarih: 2026-08-06
> Konu: GitHub'a açık kaynak repo koymak için gerekenler + mevcut repo durumu + geçmiş/silinme sorusu + "pro" hale getirme planı
## 1. Sorunun cevabı: dosya silersem GitHub'a eski dosyalar gider mi?
**Çalışma ağacından gider, geçmişten GİTMEZ.**
- Aynı repo geçmişini (aynı commit zincirini) GitHub'a push edersen: sildiğin dosyalar
son sürümde (en son commit'te) görünmez, ama **git geçmişindeki eski commit'lerde
aynen durur**. GitHub'da eski commit'lere tıklayan herkes o dosyaları görür ve indirebilir.
- Üstelik bu geçmiş 391 MB binary taşıyor (aşağıda detay). GitHub'a geçmişle gitmek hem
bu yükü hem de "eski dosyalar hâlâ orada" sorununu getirir.
- **Öneri:** GitHub için geçmişsiz / tek commit'li temiz başlangıç yap. Ya `git init` ile
yeni repo (sıfır geçmiş), ya da squash ile tek commit. O zaman ne sildiysen tamamen gider.
## 2. GitHub'a açık kaynak repo koymak için gerekenler (checklist)
| Öğe | Durum | Not |
|---|---|---|
| LICENSE dosyası | ❌ YOK | README'de "MIT" yazıyor ama kökte LICENSE dosyası yok. GitHub lisansı bunu algılamaz |
| README | ⚠️ Var | Gitea URL'leri içeriyor, Türkçe, badge yok (detay: madde 4) |
| CONTRIBUTING.md | ❌ YOK | Katkı kuralları (kod stili, test, PR süreci) |
| CODE_OF_CONDUCT.md | ❌ YOK | GitHub topluluk standartları için beklenir |
| SECURITY.md | ❌ YOK | Güvenlik açığı bildirim yolu (private advisory) |
| CHANGELOG.md | ❌ YOK | 1.0.0 → 1.1.0 geçmişi yazılı değil |
| CI (GitHub Actions) | ❌ YOK | Sadece `.gitea/workflows/` var — GitHub bunu ÇALIŞTIRMAZ, `.github/workflows/` gerekir |
| Issue/PR template | ❌ YOK | `.github/ISSUE_TEMPLATE/` + `PULL_REQUEST_TEMPLATE.md` |
| .gitignore | ✅ Var | İyi durumda (build/, .dart_tool/ ignore ediliyor) |
| Repo metadata | ❌ | description + topics (image-viewer, flutter, linux, windows) + lisans seçimi |
| Releases | ⚠️ | v1.1.0 tag + .deb/.exe asset'leri GitHub Releases'e taşınmalı |
| FUNDING.yml | ❌ | Opsiyonel |
## 3. KRİTİK: Sızıntı / hijyen riskleri
1. **Gömülü token:** `git remote -v` çıktısında origin URL'si
`https://git_alhan:1254baa3...@git.softmediadesign.com/...` — token URL'e gömülü.
Bu dosya `.git/config`'te; push ile GitHub'a gitmez (config transfer edilmez).
Ama GitHub'a yeni remote eklerken bu URL'yi yanlışlıkla kopyalamamaya dikkat.
Token'ı Gitea'dan yenilemek en temizi.
2. **Kişisel dosyalar repoda:** `imajviewer.desktop` (yerel .desktop, mode 600),
`Implementation.md`, `PRD.md`, `update_plan_01.md`, `docs/session-*.md`,
`docs/rapor-*.md` — iç geliştirme notları. Açık kaynak repo'da hepsi durmamalı.
3. **Binary dosyalar git'te:** `deb/` (16 MB libflutter_linux_gtk.so vb. — build çıktısı)
ve `dist/` (.deb, .exe — sürüm paketleri) git ile takip ediliyor. Binary'ler git'e
girmez; release asset'i olur. `.gitignore`'da `*.deb`/`*.exe` var ama `!dist/*.deb`
istisnası ve `deb/` dizini bunları açıkta bırakıyor.
4. **Mutlak yol:** `pubspec.yaml` → `msix_config.logo_path: C:/projects/imajviewer/...`
— yerel Windows yolu. GitHub runner'ında msix adımı kırılır; göreli yol olmalı.
5. **Geçmiş 391 MB:** En büyük blob'lar: setup.exe 24 MB ×4 kopya, libflutter_linux_gtk.so
16 MB, .deb'ler 8-11 MB ×8, tar.gz 9 MB. GitHub'da dosya limiti 100 MB — şu an
geçmiyor ama repo 1 GB'a yaklaşırsa GitHub uyarır, şişkinlik de kötü görünür.
## 4. README düzeltmeleri
- Klon/curl komutlarındaki `git.softmediadesign.com` URL'leri GitHub URL'si olmalı
- `.deb` kurulum adımındaki sürüm `1.0.0` yazılmış, güncel `1.1.0`
- Türkçe → açık kaynak için İngilizce (veya TR+EN iki dilli) önerilir
- Badge'ler (build status, license, release) + ekran görüntüsü (imajviewer.png var)
- "Gitea Actions" bölümü → "GitHub Actions" olarak yeniden yazılmalı
## 5. "Pro" hale getirme planı (uygulama adımları, onay sonrası)
1. **Kök temizliği:** `imajviewer.desktop` sil; iç dokümanları (Implementation/PRD/
update_plan/docs) taşı ya da sil — karar gerekiyor (aşağıda).
2. **Binary'leri git'ten çıkar:** `git rm --cached deb/ dist/*.deb dist/*.exe`,
`.gitignore`'u düzelt (deb/ tamamen ignore; dist/'te sadece rebuild-deb.sh kalsın).
Not: `git rm --cached` sadece ileriye dönük temizler; geçmişteki 391 MB durur —
tam temizlik için ya filter-repo ya da temiz başlangıç gerekir.
3. **Lisans:** Köke `LICENSE` (MIT) ekle.
4. **README'yi yeniden yaz:** İngilizce, GitHub linkleri, badge, screenshot.
5. **Topluluk dosyaları:** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md,
CHANGELOG.md, `.github/` (issue/PR template + workflows/build-windows.yml).
6. **CI'ı GitHub'a uyarla:** `.gitea/workflows/build-windows.yml` → `.github/workflows/`:
`GITEA_TOKEN` → `GITHUB_TOKEN` (otomatik), release adımı
`softprops/action-gh-release` ile GitHub Release + .deb/.exe asset.
7. **pubspec.yaml:** `logo_path`'i göreli yap (CI'da msix kırılmasın).
8. **GitHub'a aktarım:** Yeni boş GitHub repo → `git remote add github <url>` →
temiz tek commit (squash/orphan) → `git push github main`. Gitea ayrı kalır.
## 6. Açık kararlar (senin onayın gerekiyor)
1. README dili: İngilizce mi, TR mi, iki dilli mi? (açık kaynak görünürlüğü için İngilizce önerilir)
2. `docs/` ve kök dokümanlar: iç raporlar/session notları ne olacak? (yerel yedek alınır mı?)
3. Geçmiş: temiz tek commit mi, yoksa tüm geçmiş GitHub'a taşınsın mı? (öneri: temiz tek commit)
4. GitHub hesabı/org: repo hangi hesaba? Repo adı: `imajviewer` mi `imajViewer` mi?
5. Lisans: MIT kesin mi? (README'de MIT yazıyor, dosya yok)
-199
View File
@@ -1,199 +0,0 @@
# Rapor — Efekt Uygulanmış Görselde Pan Sırasında Kilitlenme (Nüks)
Tarih: 2026-08-06
Durum: Analiz — hipotezler hazır, açık sorular kullanıcı yanıtını bekliyor
## 1. Semptom (yeni bildirim)
- Donma sorunu **yeniden ortaya çıktı** — bu kez **"diğer bilgisayarda"** (VM değilse
ortamı bilinmiyor; önceki testler Xubuntu VM + llvmpipe idi).
- Tetikleyici farklı: önceki nüksler **efekt sürüklemesi sırasında** idi
(contrast/saturation/brightness/rotate drag). Şimdi **efekt uygulanmış durumda
(değerler sabit) iken PAN yaparken** kilitlenme.
- "Olmadık zamanlarda" — rastgele zamanlarda, her pan'da değil.
## 2. Önceki Teşhisler ve Çözümler (kayıt özeti)
| Tarih | Teşhis | Çözüm | Durum |
|---|---|---|---|
| 03 Ağu | **Kuyruk birikmesi**: pointer event hızı (100-250/sn) > llvmpipe kare hızı → setState her event'te → UI thread %100, 30-60sn donma | `_markRenderDirty()` frame-throttle (kare başına max 1 rebuild) + `ResizeImage` (viewport×2) + provider memoize + cache 16/256MB + galeri geçişinde `imageCache.clear()` | Uygulandı, ölçüldü (raster ort ~15ms, max ~50ms, kuyruk birikmesi yok) |
| 06 Ağu | **İlk kullanım shader derlemesi (B1)**: process başına soğuk Skia/GL cache; ilk ColorFiltered/Transform kullanımı 15sn-1dk derleme | **Shader warmup**: açılışta gizli Opacity(0) katmanla filtre + rotate shader'larını derlet | Uygulandı (release build + kurulum 06 Ağu) |
## 2b. Kullanıcı Yanıtları (2026-08-06, ikinci bildirim)
- Ortam: **gerçek makine** — `Mesa Intel(R) HD Graphics 3000 (SNB GT2)` (Sandy
Bridge iGPU, 2011). mesa i965 driver GLSL'i **LLVM ile CPU'da derler** → karmaşık
shader varyantları 30sn'ye çıkabilir. 12GB RAM, son sürüm kurulu.
- Test sonuçları (kullanıcı):
- **Resim büyükse (dosya ~1MB+) oluyor**; küçük görselde olmuyor.
- **Fill modda olmuyor, fit modda oluyor.**
- **İlk açılışta fit moda geçip rotate edilirse oluyor** — tek seferlik, sonra
tekrar değil (kullanıcı "sanırım" ile ifade etti, teyit beklenecek).
- Bu bilgiler **Aday F'yi doğruluyor**: warmup 1x1 gizli katmanda yapılıyor, gerçek
kullanımın (büyük doku + fit BoxFit.contain + rotate + ColorFiltered) shader
varyantı warmup'ta derlenmiyor → ilk gerçek kullanımda 30sn derleme.
- Aday A (ColorFiltered CPU filtrelemesi) ve C (bellek) elendi — GPU var, RAM bol.
- **EKLENTİ (kullanıcı kurcalayınca):** Fill modda da oluyor; **"biraz zorlayınca
aynı resimde tekrar oldu"** — yani tek seferlik DEĞİL. Bu Aday F'yi (ilk kullanım
derlemesi, cache'li kalır) **zayıflatıyor**; varyant sayısı fazlaysa her yeni
kombinasyon yeni derleme olabilir (aşağıda Aday H/I).
Her iki çözüm de mevcut kodda aktif: `image_canvas.dart` → `_markRenderDirty`,
provider memoize (`_cachedProvider`), `_buildWarmupLayer()`.
## 3. Yeni Analiz — Neden "Efekt + Pan" Kilitlenir?
Pan akışı: `_onPanUpdate` → `_tx/_ty += delta` → `_clamp()` → `_markRenderDirty()`.
### Aday A — ColorFiltered her pan frame'inde tüm görseli CPU'da filtreliyor ⭐
- Efekt aktifken (`hasFilter == true`) her rebuild'te `ColorFiltered` → `Image` tüm
görsel 5x4 matrisle filtrelenir. Pan'da translate değişir → her frame'de yeniden
boyama + filtreleme.
- 4000×3000 görsel ≈ 12M piksel; llvmpipe'ta filtre + ölçekleme + translate ≈
20-60ms/kare. FPS 15-50'ye düşer → **"takılma" hissi**.
- `_markRenderDirty` kuyruk birikmesini engeller ama kare başına maliyet yüksekse
düşük FPS kalıcı olur. Büyük görsel + zayıf makinede bu "kilitlenme" gibi algılanabilir.
- **Rotate aktifse** (pan serbest, `_clamp` erken return) Transform + ColorFiltered
birlikte — en ağır kombinasyon.
### Aday B — `_clamp()` pan event hızında `findRenderObject()` çağırıyor ⭐
- `_onPanUpdate` her event'te `_clamp()` çağırır; fit modda `_clamp()` içinde
`_imageKey.currentContext?.findRenderObject()` (satır 77) **pointer hızında**
(throttle'ın DIŞINDA) render tree sorgusu yapar. Pan event'leri 100-250/sn →
gereksiz layout/tree traversal yükü. Kilitlenme üretmez ama takılmayı artırır.
### Aday C — Bellek/GC baskısı ("olmadık zamanlarda" rastgeleliğin en iyi adayı)
- Rastgele kilitlenme imzası GC/swap'e işaret eder. "Diğer bilgisayar"da RAM azsa
(8GB altı) veya başka işler varsa: büyük görsel decode + 256MB cache + pan sırasında
GC pause → kısa ama algılanabilir donmalar.
- Pan sırasında yeni decode yok (memoize) → cache baskısı düşük; ama makine belleği
dar ise OS seviyesinde swap thrashing olabilir.
### Aday D — Eski binary / farklı ortam ("diğer bilgisayar")
- "Diğer bilgisayarda" kurulu sürüm bilinmiyor. Eski .deb (warmup/memoize öncesi)
kuruluysa sorun zaten açıklanır. Ayrıca o makinede donanım GPU'su VARSA teşhis
tamamen değişir (llvmpipe değil) — ColorFiltered donanımda ucuzdur, o zaman aday
C veya farklı bir sebep aranır.
### Aday E — Pan sırasında yeni frame kuyruğu (zayıf)
- `scheduleFrameCallback` kare başına 1 callback garanti eder; kare süresi uzarsa FPS
düşer ama kuyruk birikmez (03 Ağu ölçümü). Kilitlenme üretmez.
### Aday F — Warmup varyant eksikliği: 1x1 katman gerçek kullanımı derlemiyor ⭐⭐⭐
- Warmup (`_buildWarmupLayer`) **1x1 boyutunda** gizli katmanla filtre + rotate
shader'larını derletiyor. Skia/GL'de shader varyantları **doku boyutu, sampling
modu, filtre kalitesi** gibi parametrelere göre ayrı derlenir.
- Gerçek kullanımda **büyük görsel** (4000x3000) + `FilterQuality.high` +
`BoxFit.cover/contain` + `ColorFiltered` + pan/zoom `Transform` — bu kombinasyonun
shader'ı 1x1 warmup'ta derlenenle **aynı varyant olmayabilir** → ilk gerçek
kullanımda derleme yine olur → 30sn kilitlenme.
- "Efekt uygulanmışken pan yaparken" tetikleyicisi: pan/zoom + ColorFiltered birlikte
ilk kez kullanıldığında varyant derlenir; sonrası cache'li → "olmadık zamanlarda"
ve "her pan'da değil" imzası birebir uyuyor.
- GPU'da shader derleme süresi genelde ms seviyesindedir; **zayıf/iç ekran kartı +
mesa/LLVM backend** ile 30sn'ye çıkabilir. "İyi kötü ekran kartı" ifadesi bu
olasılığı güçlendiriyor.
- **Warmup'ın gerçek boyutla yapılması gerekir**: gizli katman 1x1 yerine mevcut
görselin boyutunda (veya viewport boyutunda) olmalı; ayrıca pan/zoom Transform'u
da warmup'a dahil edilmeli.
### Aday G — Gerçek GPU driver / doku sınırı
- Zayıf GPU'da büyük doku (12MP RGBA ≈ 48MB) + ColorFiltered → driver'da aşırı
yavaş path veya GL sync beklemesi. `glxinfo -B` renderer string'i kritik
(NVIDIA nouveau / AMD radeon / Intel / llvmpipe?).
### Aday H — Skia varyant cache dolması / her kombinasyon yeni derleme ⭐⭐
- "Aynı resimde tekrar oldu" → tek seferlik derleme değil. Skia'da her farklı
kombinasyon (zoom seviyesi × rotate açısı × efekt değeri × fit/fill × filtre
kalitesi) farklı shader varyantı üretebilir; varyant cache'i sınırlıysa (LRU)
eski varyant atılır → aynı kombinasyon tekrar kullanılınca YENİDEN derlenir →
30sn. "Zorlayınca" = çok varyant üretmek = cache'i doldurmak = tekrar derleme.
- Bu, "ilk kullanımda" + "sonra tekrar" imzasını birlikte açıklayan hipotez.
### Aday I — Intel HD 3000 GL sync / doku upload darboğazı ⭐⭐
- Sandy Bridge iGPU paylaşımlı bellek kullanır; büyük doku upload + ColorFiltered
+ Transform her frame'de GL sync bekleyebilir. `_markRenderDirty` throttle frame
üretimini sınırlar ama **raster thread** yavaş kalırsa UI thread frame kuyruğunu
tüketemez → 30sn'lik görünür donma (olaylar işlenir, görsel gecikir — önceki
raporlardaki imza).
- Doku boyutuna duyarlılık (1MB+ görsel) + fill/fit farkı (BoxFit.cover kırpar →
daha küçük etkin doku; contain → tam doku işlenir) bu hipotezle uyumlu.
- Intel HD 3000'de `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe) ile karşılaştırma testi
ayırt edici: GL sync sorunu ise software'de farklı davranır.
## 4. Hipotez Önceliklendirme
| Aday | Açıkladığı | Gücü |
|---|---|---|
| A (ColorFiltered her frame) | Efekt+pan takılması, "kilitlenme" hissi | ⭐⭐ (efekt açıkken kaçınılmaz maliyet; azaltılabilir) |
| B (`_clamp` findRenderObject) | Ek pan yükü | ⭐ (kolay düzeltilir) |
| C (bellek/GC) | "Olmadık zamanlarda" rastgelelik | ⭐⭐ (makine belleği bilinmiyor) |
| D (ortam/eski binary) | Tamamen farklı kök | ⭐ (önce doğrulanmalı) |
## 5. Önerilen Çözüm Yolları (rapor onayı sonrası)
### Seçenek 1 — Pan sırasında filtre maliyetini azalt (Aday A)
- Efekt aktifken pan/zoom sırasında `FilterQuality.high` → `FilterQuality.medium`
(görsel kalite farkı minimal, llvmpipe yükü düşer) — tek satır.
- Pan sırasında `cacheWidth`'i düşürme (memoize zaten sabit tutuyor) — gerek yok.
- **Alternatif:** pan sırasında geçici düşük çözünürlük önizlemesi (mevcut görseli
küçük boyutta filtrele) — karmaşık, önce basit yol denenir.
### Seçenek 2 — `_clamp()` içindeki `findRenderObject` yükünü azalt (Aday B)
- `_imageKey.currentContext?.findRenderObject()` yalnızca fit modunda lazım; pan'da
`_vpSize` değişmediği sürece tekrar sorgulamaya gerek yok — `_clamp`'in render
sorgusunu her pan event'inde değil, sadece layout değişince yap. (Dikkatli:
rotate varken `_clamp` zaten erken return ediyor; fit modunda kilitlenme riski yok.)
### Seçenek 3 — Ortam doğrulaması (Aday D) — rapor öncesi şart
- "Diğer bilgisayar"da: `glxinfo -B | grep -i renderer` (llvmpipe mi GPU mu),
`free -h`, kurulu sürüm (`.deb` tarihi / `imajviewer --version` yoksa binary
timestamp). Ayrıca aynı görselle **bu VM'de** tekrar dener misin — nüks VM'de
de mi, sadece diğer makinede mi?
## 6. Kalan Açık Sorular
1. "Diğer bilgisayar" hangisi — başka VM mi, gerçek makine mi? Donanım GPU'su var mı
(`glxinfo -B` çıktısı)?
2. O makinede kurulu sürüm: son `.deb` (2026-08-06 sonrası) mi, yoksa eski mi?
3. Kilitlenme süresi ne kadar — 1-2sn mi, 10sn+ mı, yoksa "kapatmak zorunda kaldım" mı?
4. Hangi efekt uygulanmışken pan yapıyorsun — contrast mi, rotate mi (rotate en ağırı)?
5. Görsel boyutu ne — büyük fotoğraf mı (4000px+), küçük ekran görüntüsü mü?
6. Aynı görselle bu VM'de (llvmpipe) pan dener misin — nüks VM'de de oluyor mu?
## 7. Ölçüm Planı (onaylanırsa)
Skill'deki `references/llvmpipe-perf-measurement.md` akışı: geçici
`addTimingsCallback` (FRAME build/raster süreleri) + `pidstat` CPU. Ölçüm sırasında
kullanıcı elle pan yapar ("sen aç ben yaparım"). Enstrümantasyon geçicidir ve
sonrası temizlenir. VM'de ölçüm yapılabilir; "diğer bilgisayar"da enstrümantasyonlu
build kurmak gerekir (kullanıcı onayı şart).
## 8. Sonuç
İki önceki çözüm (throttle + warmup) doğru teşhislere dayanıyordu ve ilk iki donma
imzasını çözdü. Yeni imza **farklı**: efekt SABİT iken pan. En güçlü açıklama
Aday A (ColorFiltered'in her pan frame'inde tam görseli CPU'da filtrelemesi) —
özellikle büyük görsel + zayıf makinede "kilitlenme" hissi yaratır. Rastgelelik için
Aday C (bellek/GC) ve Aday D (ortam) doğrulanmalı. Kullanıcı yanıtları gelince
rapor güncellenir ve çözüm yolları netleşir.
## 9. Kapanış Notu (2026-08-06)
Kullanıcı donma sırasında `top -b -d 1` ölçümü başlattıktan sonra **nüks olmadı**
(ne denediyse kilitlenme/takılma görülmedi). Nüks koşulları belirsiz; konu bu
raporla **askıda bırakıldı** — kullanıcı talep ederse yeniden açılır. Olası ipucu:
ölçüm sırasında sistem yükü değişince davranış değişti (Aday I — GL sync/GPU yükü
ile ilişkili olabilir; kesin teşhis yapılmadı).
-127
View File
@@ -1,127 +0,0 @@
# Rapor — Sağ Tuş Efektlerinde Donma Analizi ve Slider UI Değerlendirmesi
Tarih: 2026-08-06
Durum: Analiz — B teşhisi netleşti, uygulama bekliyor
## 1. Semptom
- Sağ tuş ile kullanılan özelliklerde (contrast / saturation / brightness / rotate) uygulama **geçici olarak donuyor**.
- Donma süresi **15sn - 1dk arası** (kısa değil, ciddi bir tek seferlik maliyet).
- **Release build** kullanılıyor (.deb'den kurulu) → Dart JIT hipotezi elendi, donma release AOT'ta da var.
- Donma **kalıcı değil**: kullanıcı "kapatayım mı" diye soruyor, "bekle" deyince **kendiliğinden düzeliyor**.
- Donma sırasında yapılan işlemler (sağ tık + aşağı çekme, pan) donma açıldığında **yapılmış olarak görülüyor** — olaylar işleniyor, sadece görsel gecikiyor.
- **Sadece çok pencereli (multi-instance) kullanımda oluyor**, tek pencerede olmuyor.
- **Her pencere açılışında ilk efekt kullanımında bir kez oluyor; o donma açıldıktan sonra aynı pencerede tekrar olmuyor.**
- Eskiden sadece rotate'te olurdu; şimdi **tüm efektlerde** oluyor, en çok rotate'te.
- Donan pencere kilitli kalıyor; diğer pencereler normal çalışıyor. Task bardan kapatılabiliyor.
- Kullanıcı şüphesi: klavye tuşlarının (Ctrl/Shift) basılı tutulmasıyla ilgili olabilir — "farklı bir şey olma ihtimali yüksek".
- Soru: bu özellikleri **açılan kutu içinde slider UI** ile yapsak takılmalar çözülür mü?
## 2. Mevcut Mimari (lib/widgets/image_canvas.dart)
| Katman | Mekanizma |
|---|---|
| Mod seçimi | `_onPointerDown`: sağ tık + `_ctrlPressed`/`_shiftPressed` state'i → contrast (yok) / rotate (Shift) / saturation (Ctrl) / brightness (Ctrl+Shift) |
| Modifier takibi | `HardwareKeyboard.instance.addHandler(_onHardwareKey)` — global, `return false` (yutmaz) |
| Değer güncelleme | `_onPointerMove`: `dy * sensitivity` → `_markRenderDirty()` |
| Render eşitleme | `_renderTick` ValueNotifier + `scheduleFrameCallback` — kare başına max 1 rebuild |
| Image pipeline | `ResizeImage.resizeIfNeeded(cacheWidth: viewport×2)` + provider memoize (03 Ağu fix) |
| Efekt | `ColorFiltered` (birleşik 4x5 matrix, cache'li) + rotate için `Transform` |
| Varsayılan | `_isFilled = true` (BoxFit.cover), `FilterQuality.high` |
## 3. Kök Neden Analizi — Geliştirilmiş Teşhis (kullanıcı geri bildirimiyle)
Kullanıcının verdiği yeni bilgiler adayları eliyor:
| Gözlem | Hangi adayı eler/güçlendirir |
|---|---|
| Donma geçici, kendiliğinden düzeliyor | Aday A (kalıcı frame üretimi durması) **elendi** → B güçlendi |
| Donma sırasında yapılan işlemler sonradan görülüyor | UI thread olayları **işliyor**; sadece render/raster geride → Aday A elendi, B güçlendi |
| Sadece çok pencerede oluyor | Process başına soğuk kaynak → B güçlendi |
| Her pencerede İLK efekt kullanımında bir kez, sonra tekrar olmuyor | **İlk kullanım maliyeti** — shader derleme / kaynak ısınması → B1 (aşağıda) |
| Eskiden sadece rotate, şimdi tüm efektlerde | Efekt başına ilk-kullanım maliyeti → B1 |
| En çok rotate'te | Rotate en pahalı (Transform + ColorFiltered birlikte) → B |
### Teşhis: B1 — İlk Kullanım Render Maliyeti (shader derleme + kaynak ısınması) ⭐⭐
Semptom setinin tamamını açıklayan tek hipotez:
- Her pencere **ayrı process** (multi-instance). Her process'in kendi Skia/GL kaynakları **soğuk** başlar.
- İlk kez `ColorFiltered` (color matrix shader) ve/veya `Transform` (rotate) kullanıldığında llvmpipe **shader'ı derler + ilgili render path'i kurar** — bu tek seferlik maliyet 1-10sn+ sürebilir (yazılımsal render, 4 CPU çekirdeği).
- **Sonraki kullanımlarda shader cache'li** → donma tekrar etmez. "Bir kere oluyor, açıldıktan sonra tekrar olmuyor" = tam olarak bu.
- **Tüm efektlerde olması**: brightness (03 Ağu'da eklendi, yeni birleşik 4x5 matrix) dahil her efekt kendi shader'ını ilk kez derliyor. Eskiden sadece rotate'te olması: o günlerde contrast/saturation zaten ısınmış/kullanılmıştı; rotate her oturumda en son eklenen ve en pahalı olandı.
- **En çok rotate'te**: rotate iki shader path'ini birleştirir (ColorFiltered matrix + Transform composite) → en uzun ilk derleme.
- **Çok pencerede olması**: her pencere soğuk başlar → her pencerede ilk efekt kullanımında ayrı donma. Tek pencerede donma tek seferlik olduğu için "sorun yok" gibi görünür.
### Aday B — llvmpipe raster thread uzun kare (arka plan maliyeti)
- Donma anında UI thread çalışıyor (işlemler işleniyor) ama görsel gecikiyor → raster/kompozisyon kuyruğu geride.
- llvmpipe'ta her kare 15-50ms (03 Ağu ölçümü); ilk shader derlemesi bu kareleri 100x şişirir.
- B1'in "ilk kullanım" tetikleyicisi, B'nin "uzun kare" mekanizması — ikisi aynı olayın iki yüzü: **soğuk kaynakla ilk efekt frame'i çok pahalı**.
### Aday C — Bellek baskısı / swap thrashing
- Çok pencerede olması C'yi güçlendirir (her process ayrı decode + doku). Ama "tekrar olmuyor" imzası C ile tam uymuyor (bellek baskısı her seferinde olur). İkincil faktör olabilir.
### Aday D — `_clamp()` build sırasında `findRenderObject()`
- Donma yapmaz (null döner), performans etkisi minimal. Kırmızı ringa.
## 4. Klavye Hipotezi Değerlendirmesi (kullanıcının şüphesi)
**Donmaya klavye neden olmuyor.** `_onHardwareKey` handler'ı sadece iki boolean set/clear ediyor — maliyeti ihmal edilebilir, render tetiklemiyor, event yutmuyor. Kullanıcının "farklı bir şey" sezgisi doğru.
Ancak klavye **yanlış mod tetikleme** (takılma hissi) üretebilir:
- X11'de Shift durumu pointer event sırasında güvenilmez (skill'de belgeli: `isShiftPressed` false dönebilir). `_onHardwareKey` ile takip doğru çözüm ama yine de key repeat / focus kaybı durumlarında state bozulabilir.
- Shift basılıyken sağ tık → kullanıcı farkında olmadan **rotate** modu açılır → en pahalı efekt + beklenmedik davranış → "takılıyor" hissi.
- Alt + tık XFCE WM tarafından yutulur (bilinen pitfall) — zaten Ctrl/Ctrl+Shift'e geçilmişti.
## 5. Slider UI Değerlendirmesi (kullanıcının önerisi)
**Kısa cevap: B1 (ilk kullanım shader derlemesi) teşhisi doğruysa, slider UI donmayı ÇÖZMEZ — çünkü donma render maliyetinden değil, ilk kullanımdaki tek seferlik shader derlemesinden kaynaklanıyor.** Slider ile contrast'ı ilk kez açtığın anda yine aynı shader derlenecek ve yine aynı donma olacak. Ama doğru tasarımla yine de değerli:
| Bileşen | Slider UI etkisi |
|---|---|
| İlk kullanım shader derlemesi (B1) | **Çözmez** — shader yine o an derlenir. Çözüm warmup (aşağıda) |
| Olay hızı | **Aynı kalır** — slider da sürüklerken `onChanged` pointer hızında çağrılır. Ama ilk derleme bittikten sonra gerisi akıcıdır |
| Mod bilinirliği | **Çözer** — hangi modda olduğun görünür; gizli modifier mapping'i biter. "Takılıyor" hissinin büyük kısmı modun ne olduğunu bilmemektir |
| Klavye bağımlılığı | **Tamamen kaldırır** — `HardwareKeyboard` handler'ına, X11 Shift güvenilmezliğine, Alt yutma pitfall'ına gerek kalmaz |
| Render maliyeti (ilk derleme sonrası) | **Ancak tasarımla düşer** — `onChangeEnd`'de uygula + sürükleme sırasında düşük çözünürlüklü önizleme (küçük cacheWidth) yapılırsa render başına maliyet ciddi düşer |
**Sonuç:** Slider UI tek başına "sihirli çözüm" değil. Donmanın asıl çözümü B1'e karşı: **shader warmup + ilk derlemeyi kullanıcı etkileşiminden önce yaptırmak**.
## 6. Önerilen Çözüm Yolları (B1 teşhisine göre)
### Seçenek 1 — Shader Warmup (ilk derlemeyi öne çek) ⭐ UYGULANDI (2026-08-06)
Açılışta, kullanıcı daha sağ tıka basmadan efekt shader'ları derletilir:
- `ImageCanvas.initState` → ilk frame sonrası `_startWarmup()`.
- Görünmez 1x1 gizli katman (`_buildWarmupLayer`): adım 1'de `ColorFiltered` (filtre shader'ı), adım 2'de `ColorFiltered` + `Transform` rotate (rotate shader'ı) render edilir; sonra `_warmupDone = true` ile katman kaldırılır.
- Kullanıcı ilk sağ tıka bastığında shader'lar hazırdır → donma hissi kaybolmalı.
- Maliyet: pencere açılışında 2-3 ekstra gizli frame (arka planda, etkileşim yokken).
- Not: çok pencerede her process kendi warmup'ını yapar — aynı mantık.
- Doğrulama: release build + kurulum yapıldı (2026-08-06). Kullanıcı test edecek — ilk sağ tık donması yoksa teşhis doğrulanır.
### Seçenek 2 — Release build ile test (JIT yükü teyidi)
- **GÜNCELLEME:** Kullanıcı zaten release build kullanıyor (.deb'den kurulu) — JIT hipotezi **elendi**. Donma release AOT'ta da oluyor.
- 15sn-1dk süre, llvmpipe'ta ilk render path kurulumunun (shader derleme + layer/doku hazırlığı) toplam maliyeti olabilir; "bir kez oluyor, tekrar olmuyor" imzası korunuyor.
### Seçenek 3 — Rotate maliyetini düşür
- Rotate aktifken `FilterQuality.high` → `FilterQuality.medium` (rotate sırasında görsel kalite farkı minimal, maliyet düşer).
- `Transform` + `ColorFiltered` aynı anda aktifken tek composite path önerilir (mevcut yapı zaten tek ColorFiltered kullanıyor).
### Seçenek 4 — Slider UI (UX netliği, donma çözümü değil)
- Donma B1'den kaynaklanıyorsa slider UI donmayı çözmez; ama mod bilinirliği + klavye bağımsızlığı sağlar.
- Warmup (Seçenek 1) uygulandıktan sonra slider UI istenirse eklenir — ikisi birlikte: donma yok + kullanım net.
## 7. Kalan Açık Sorular
1. Donma süresi yaklaşık ne kadar — 1-2sn mi, 10sn+ mi? (B1 shader derleme süresi tahminini doğrular)
2. Debug build mi kullanıyorsun (install.sh ile kurulan), yoksa release mi? → Seçenek 2'yi doğrular
3. Slider UI hâlâ isteniyor mu, yoksa önce donma çözümü mü? (Sıra: warmup → slider opsiyonel)
4. Warmup için "açılışta arkada 1-3sn hazırlık" kabul edilir mi? (Alternatif: ilk efekt kullanımında 1 kez donma kabul edilir mi?)
-79
View File
@@ -1,79 +0,0 @@
# Session Özeti — 2026-07-27
## Eklenen Özellikler
### Çalışan
| Özellik | Tetikleyici | Durum |
|---|---|---|
| Pan (sürükleme) | Sol tık | ✓ |
| Zoom (mouse tekeri) | Scroll | ✓ |
| Fill/Fit geçişi | Sol çift tık | ✓ |
| Contrast | Sağ tık + dikey sürükleme | ✓ (matrix row-major) |
| Saturation | Ctrl + sağ tık + dikey sürükleme | ✓ (desaturate + oversaturate) |
| Reset (tüm efektler) | Sağ çift tık | ✓ |
### Tamamlanmamış / İptal
| Özellik | Durum |
|---|---|
|| Rotate | Shift + Sağ tık + dikey sürükleme | ✓ (viewport merkezi, ~100px=90°) |
| Brightness | **Kaldırıldı** — hiç çalışmadı |
| Single-instance | **İptal** — kullanıcı istemedi. Bunun yerine StartupWMClass ile taskbar gruplama |
---
## Performans Durumu
- **Çıplak mod (pan + zoom)**: Süper hızlı ✓
- **ColorFiltered (contrast + saturation)**: Ciddi performans düşüşü ✗
- **Çözüm**: `hasFilter` mantığı eklendi. `_saturation == 1.0 && _contrast == 1.0` ise `ColorFiltered` widget'ı **hiç render edilmez**. Efekt uygulanınca devreye girer.
|- **Rotate**: Ayrı `Transform` katmanı, sadece `_angle != 0` iken render edilir. Sıfır ek yük.
---
## Teknik Detaylar
### Color Matrix
- **Format**: Row-major 5x4 (column-major çalışmadı!)
- **Birleşik matrix**: `M_combined = M_contrast × M_saturation` (tek GPU pass)
- **Cache**: `_cachedFilter` ile değerler değişmediğinde yeniden oluşturulmaz
- **Lazy render**: `hasFilter` false ise `ColorFiltered` widget ağacına eklenmez
### Klavye Modifier Tracking
- `HardwareKeyboard.instance.addHandler(_onHardwareKey)` ile global seviyede
- `HardwareKeyboard.instance.isShiftPressed` / `isControlPressed` **çalışmıyor** (Linux bug'ı)
- Kendi `_ctrlPressed` / `_shiftPressed` state'imizi tuttuk
### install.sh
- Önce `build/linux/x64/debug/bundle/`, yoksa `release/bundle/` yolunu dener
### Gesture Sistemi
- `Listener` (sağ tık event'leri) + `GestureDetector` (sol tık pan/çift tık) hibrit yapısı
- `Listener.onPointerDown/Move/Up` ile manuel gesture handling
---
## Bilinen Sorunlar
1. **Zoom throttle kaldırıldı** — her scroll event'inde `setState`, bu da performansı etkiliyor olabilir
2. **Rotate düzeltildi** — viewport merkezi, lazy Transform. Pan/zoom ile uyumlu.
3. **BlendMode saturation oversaturate yapamıyor** — matrix'e geçildi (çalışıyor)
---
## Dosya Yapısı
| Dosya | Açıklama |
|---|---|
| `lib/widgets/image_canvas.dart` | Tüm görüntü işleme, filtreler, gesture'lar |
| `install.sh` | Kurulum scripti (debug/release otomatik seçim) |
| `docs/image-canvas-interactions.md` | Detaylı analiz raporu |
| `docs/index.md` | Dokümantasyon indeksi |
---
## Sonraki Session İçin
1. Single-instance implementasyonu (lock file / Unix socket)
2. Rotate'i düzeltip tekrar ekleme (performans ve pan yönü sorunları)
3. Zoom performans optimizasyonu (throttle veya GestureDetector.onScaleUpdate)
4. `.deb` ve Windows installer güncellemesi
-33
View File
@@ -1,33 +0,0 @@
# Session Özeti — 2026-08-03
## Yapılanlar
### 1. Ek Görüntü Formatları (jfif / jpe / wbmp)
- TIFF değerlendirildi, **vazgeçildi** (kullanıcı kararı — image paketi/decode katmanı istenmedi)
- AVIF değerlendirildi, **eklenmedi** (Flutter'da yerleşik decoder yok — native libavif gerekir, "ultra hafif" felsefeye aykırı)
- Eklendi: `.jfif`, `.jpe` (JPEG içerikli, Skia zaten açıyor), `.wbmp` (Skia WBMP codec)
- Değişen yerler: file_handler (seçici), image_manager (filtre), 2× .desktop + install.sh (MimeType)
### 2. Pencere Cascade Yerleşimi
- Yeni pencere artık öncekinin **x + width** kadar sağında açılıyor
- Ekrandan taşarsa → alt satıra, satır başından
- O da taşarsa → ekran sol-üst köşesine
- Ekran bilgisi: `screen_retriever` paketi (window_manager'ın zaten transitive bağımlısıydı, doğrudan bağımlılık yapıldı)
- Yeni: `WindowPersistence.cascadePosition()` — getPrimaryDisplay → visibleSize/visiblePosition
### 3. Pencere Konum Kaydı Düzeltmesi (kök neden)
- **Sorun:** Konum kaydedilemiyordu → çözüm olarak 5 yere kayıt koyulmuştu (initState + 2sn polling + move/resize debounce + close + main). Birden fazla pencere açıkken polling konum yarışı yaratıyordu → yeni pencere eski konuma göre hesaplanıp üstüne düşüyordu.
- **Çözüm:** Polling kaldırıldı. Tek akış:
1. `main.dart` — pencere açılışında konum anında saveSync (sıradaki pencere güncel referans okur)
2. `viewer_screen` move/resize — son bilinen bounds hemen `_lastBounds`'a, 300ms debounce ile saveSync
3. `viewer_screen` close — `_lastBounds` **senkron** saveSync (async çağrı kapanışta tamamlanmıyordu)
## Doğrulama
- `flutter analyze` — 18 info uyarı (mevcut kod stil/deprecated), hata yok
- `flutter build linux --release` ✓
- Kurulum: release bundle `~/.local/lib/imajviewer/` (install.sh debug bundle'ı önceliklendirdiği için elle kopyalandı)
- Kullanıcı manuel test: WebP açılıyor ✓, cascade düzgün çalışıyor ✓
## Not
- `docs/session-windows-ci.md` bozuk encoding (mojibake) — içerik git log'dan biliniyor, düzeltilmedi
- Windows setup.exe **yeniden derlenmedi** — sadece Linux build + .deb güncellendi (Windows CI zaten Gitea Actions'ta)
-30
View File
@@ -1,30 +0,0 @@
# Session — Windows CI & Installer
**Tarih:** 2026-07-27
## Yapılanlar
1. **Gitea'dan son değişiklikler çekildi**
- `feat: rotate (Shift+sag tik), StartupWMClass taskbar gruplama`
- `feat: right-click contrast, Ctrl+saturation, Shift+rotate filters`
- `feat: use imajviewericon.png as app icon`
- `chore: reorganize dist/, remove release/, update install paths`
2. **Windows release yeniden derlendi**
- `flutter pub get` ile bağımlılıklar güncellendi
- `flutter build windows --release` ile yeni özellikleri (contrast, saturation, rotate) içeren binary oluşturuldu
- Yeni ikon (imajviewericon.png) build'e dahil edildi
3. **Inno Setup installer yeniden oluşturuldu**
- `dist/imajviewer_setup_1.0.0.exe` — 24 MB
- İçerir: imajviewer.exe + tüm DLL'ler + data dosyaları
- Dil desteği: Türkçe + İngilizce
- Dosya ilişkilendirmeleri: .png, .jpg, .jpeg, .webp, .bmp, .gif
- İsteğe bağlı masaüstü kısayolu
4. **Gitea CI workflow güncellendi** (`.gitea/workflows/build-windows.yml`)
- Flutter build adımı korundu
- Inno Setup kurulumu (`choco install innosetup`) eklendi
- `ISCC.exe` ile installer build adımı eklendi
- Release artifact'e hem build çıktısı hem de `dist/*.exe` eklendi
- Tag bazlı release job'ı eklendi (opsiyonel, `GITEA_TOKEN` secret gerektirir)
-45
View File
@@ -1,45 +0,0 @@
# ImajViewer — Pan Sınırlama & Zoom Merkezi Sorunu ✅ ÇÖZÜLDÜ
> **Tarih:** 2026-07-23
> **Dosya:** `lib/widgets/image_canvas.dart`
> **Durum:** ✅ Çözüldü
---
## Nihai Çözüm
### 1. Zoom — `Matrix4.copy(m)..translate..scale..translate`
Cursor merkezli zoomAroundPoint. Hem fill hem fit modunda çalışır çünkü `localPosition` her zaman Listener'a göre doğru koordinat verir. `_currentScale` listener'da güncellenir.
### 2. `_clampAfterZoom` — Image offset + sadece boundary clamp
**Kritik:** Fit modunda (BoxFit.contain) Image, Center widget içinde **ortalanır**. Offset hesaplanarak image'ın viewport'taki gerçek sınırları bulunur:
```dart
imgOffsetX = (viewportW - imageW) / 2 // fill modunda 0, fit modunda >0
imgLeft = tx + imgOffsetX * scale
imgRight = tx + (imgOffsetX + imageW) * scale
```
**SADECE** image viewport'tan büyükse clamp yapılır. Küçükse **dokunulmaz** — zoom-around-point merkezi korunur. Kullanıcı double-tap ile istediği zaman ortalamayı sıfırlayabilir (`Matrix4.identity()`).
### 3. `translate` — asla `setTranslationRaw` kullanma
`setTranslationRaw` matrisin translation sütununu ezer → zoom merkezini bozar. `translate(dx,dy)` ekleme yapar → zoom merkezi korunur.
### 4. Listener'da clamp YOK
Sadece `_currentScale` takip edilir. Pan → `boundaryMargin: EdgeInsets.zero`, Zoom → `_handleScroll` içinde `_clampAfterZoom`.
---
## Akış
```
Scroll → _handleScroll → zoomAroundPoint → _controller.value
↓
_clampAfterZoom()
├─ image > viewport → translate ile clamp
└─ image ≤ viewport → dokunma
Pan → InteractiveViewer → _controller.value (boundaryMargin sınırlar)
↓
_onTransformChanged → sadece _currentScale
```
-217
View File
@@ -1,217 +0,0 @@
# Windows Development Ortam Kurulum Rehberi
Bu doküman, ImajViewer Flutter projesini Windows üzerinde geliştirmek için gerekli ortamın sıfırdan kurulmasını adım adım anlatır.
## Gereksinimler
| Araç | Minimum Versiyon | Açıklama |
|------|-----------------|----------|
| Flutter SDK | 3.27+ (stable) | UI framework |
| Visual Studio 2022 | Build Tools veya Community | C++ derleyici (MSVC) |
| CMake | 3.14+ | Build sistemi |
| Inno Setup | 6.x | Windows installer (.exe) oluşturma |
## Adım 1: Flutter SDK Kurulumu
### Yöntem A: Resmi kurulum (Önerilen)
1. [Flutter SDK'yı indirin](https://docs.flutter.dev/get-started/install/windows)
2. ZIP'i `C:\src\flutter` dizinine çıkarın
3. PATH'e ekleyin:
```powershell
# PowerShell (Admin)
[Environment]::SetEnvironmentVariable(
"PATH",
"$([Environment]::GetEnvironmentVariable("PATH", "Machine"))\;C:\src\flutter\bin",
"Machine"
)
```
4. Yeni terminalde doğrulayın:
```powershell
flutter doctor
```
### Yöntem B: Winget ile kurulum
```powershell
winget install --id OpenFlutter.FlutterSDK
```
### Flutter doctor kontrolleri
```powershell
flutter doctor
```
Çıktıda `[✗]` işaretli bileşenler için önerilen adımları uygulayın. En azından şunlar `✓` olmalı:
- Flutter
- Android toolchain (opsiyonel, sadece mobile geliştirme için)
- Visual Studio (eğer kuruluysa)
- Connected device (Windows)
## Adım 2: Visual Studio 2022 Build Tools Kurulumu
Flutter Windows build'i için MSVC C++ derleyicisi gerekir.
### Seçenek A: Visual Studio Build Tools (Sadece build araçları)
1. [Visual Studio Build Tools 2022](https://visualstudio.microsoft.com/visual-cpp-build-tools/) indirin
2. Kurulum sihirbazında **"Desktop development with C++"** workload'unu işaretleyin
3. Aşağıdaki bileşenler otomatik seçilecektir:
- MSVC v143 - VS 2022 C++ x64/x86 build tools
- Windows 10/11 SDK
- CMake tools (opsiyonel ama önerilir)
### Seçenek B: Visual Studio Community (Tam IDE)
1. [Visual Studio Community 2022](https://visualstudio.microsoft.com/vs/community/) indirin
2. Kurulumda **"Desktop development with C++"** workload'unu seçin
### Doğrulama
```powershell
cl
```
Çıktıda `Microsoft (R) C/C++ Optimizing Compiler` görünmelidir.
## Adım 3: CMake Kurulumu
Visual Studio Build Tools ile birlikte gelir ancak bağımsız kurulum da yapılabilir.
### Winget ile:
```powershell
winget install --id Kitware.CMake
```
### Doğrulama:
```powershell
cmake --version
```
## Adım 4: Inno Setup 6 Kurulumu
Windows installer (.exe) oluşturmak için gereklidir.
1. [Inno Setup 6](http://www.jrsoftware.org/isdl.php#stable) indirin (`is.exe`)
2. Kurulumu tamamlayın (varsayılan: `C:\Program Files (x86)\Inno Setup 6`)
### Doğrulama:
```powershell
Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
```
## Adım 5: Projeyi Hazırlama
```powershell
cd C:\projects\imajviewer
# Bağımlılıkları indir
flutter pub get
# Analiz et
flutter analyze
# Test çalıştır
flutter test
```
## Adım 6: Windows Build
### Debug build:
```powershell
flutter build windows --debug
```
Çıktı: `build\windows\x64\runner\Debug\`
### Release build:
```powershell
flutter build windows --release
```
Çıktı: `build\windows\x64\runner\Release\`
## Adım 7: Installer Oluşturma (Inno Setup)
```powershell
# Release build yapıldıktan sonra
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" windows\installer.iss
```
Çıktı: `dist\imajviewer_setup_1.1.0.exe`
## Tek Komutla Her Şey
Tüm build + installer pipeline'ını tek komutla çalıştırın:
```powershell
.\build-windows.ps1
```
Bu script:
1. `flutter pub get`
2. `flutter build windows --release`
3. Inno Setup ile `.exe` installer oluşturur
## Sorun Giderme
### "Unable to find Visual Studio installation" hatası
```powershell
flutter config --enable-windows-desktop
```
Visual Studio 2022'nin doğru kurulduğunu `flutter doctor` ile kontrol edin.
### CMake hataları
CMake'in PATH'te olduğundan emin olun:
```powershell
$env:PATH += ";C:\Program Files\CMake\bin"
cmake --version
```
### Inno Setup bulunamadı
`build-windows.ps1` script'i otomatik arar. Manuel çalıştırmak için:
```powershell
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" windows\installer.iss
```
### Flutter doctor'da Android SDK uyarısı
Sadece desktop geliştirme yapıyorsanız bu uyarıları görmezden gelebilirsiniz.
## Kurulum Sonrası
Ortam hazır olduğunda:
```powershell
# Build + installer
.\build-windows.ps1
# Manuel kurulum (build çıktılarından)
.\install.ps1
# Uninstall
.\uninstall.ps1
```
## Sistem Gereksinimleri (Minimum)
- Windows 10 (1809) veya üzeri
- 4 GB RAM (8 GB önerilir)
- ~4 GB disk alanı (Flutter SDK + VS Build Tools + proje)
- x64 mimari
-233
View File
@@ -1,233 +0,0 @@
# Zoom / Pan / Clamp Mekanizması — Referans Doküman
**Dosya:** `lib/widgets/image_canvas.dart`
**Son güncelleme:** 23 Temmuz 2026
---
## 1. Mimari Karar: Neden InteractiveViewer Yok?
Başlangıçta `InteractiveViewer` kullanılıyordu. Ancak InteractiveViewer'ın built-in pan sınırlaması (`boundaryMargin`) çocuk widget'ın **tam boyutuna** göre çalışır. Fit modda `Center` kullanılınca, Center widget'ı viewport boyutunda olduğu halde içindeki Image daha küçüktür. IV, Image'ın değil Center'ın kenarlarına göre sınırlama yapar → Image'ın etrafında boşluk kalır.
**Çözüm:** InteractiveViewer tamamen kaldırıldı. Yerine:
```
ClipRect → GestureDetector → Listener → Transform → Center/SizedBox → Image
```
- `Transform`: zoom/pan uygular (`_tx`, `_ty`, `_sc`)
- `ClipRect`: taşan kısımları kırpar
- `GestureDetector`: pan (drag) algılar
- `Listener`: scroll (tekerlek) algılar
---
## 2. Durum Değişkenleri
```dart
double _tx = 0; // viewport-space X kaydırma (piksel)
double _ty = 0; // viewport-space Y kaydırma (piksel)
double _sc = 1.0; // ölçek (1.0 = orijinal boyut)
bool _isFilled; // true=fill mod, false=fit mod
Size _vpSize; // LayoutBuilder'dan alınan viewport boyutu
```
**Dönüşüm matrisi** (Transform widget'ına verilen):
```
M = T(_tx, _ty) · S(_sc)
```
Yani: child-space'deki (cx, cy) noktası → viewport-space: `vx = cx * _sc + _tx`
---
## 3. Widget Ağacı (build metodu, satır 111–154)
```
LayoutBuilder
ClipRect
GestureDetector
onDoubleTap → _toggleFillFit()
onPanUpdate → _onPanUpdate()
Listener
onPointerSignal → _handleScroll()
Transform(translate(_tx,_ty) · scale(_sc))
_isFilled ?
SizedBox.expand(Image.file(fit: BoxFit.cover)) // FILL
: Center(Image.file(fit: BoxFit.contain)) // FIT
```
- Viewport boyutu (`_vpSize`), `LayoutBuilder`'ın `constraints.biggest`'inden okunur (satır 115). Her build'te güncellenir. `context.findRenderObject()` kullanılmaz çünkü LayoutBuilder'ın verdiği constraints daha güvenilirdir.
- `_imageKey`, `Image.file` widget'ına bağlıdır. Sadece errorBuilder için kullanılır (clamping'te kullanılmaz).
---
## 4. Scroll Zoom Mekanizması (satır 50–71)
```
_handleScroll(PointerScrollEvent e)
```
```dart
// 1. İmleci viewport-space'den child-space'e çevir
cx = (vx - _tx) / _sc
cy = (vy - _ty) / _sc
// 2. Yeni ölçek (her tick'te %10)
factor = 1.1
newSc = (_sc * (tekerlek_yönüne_göre factor veya 1/factor)).clamp(0.1, 10.0)
// 3. Zoom'u imleç merkezli uygula
_tx = _tx + cx * _sc - cx * newSc
_ty = _ty + cy * _sc - cy * newSc
_sc = newSc
```
**Formülün türetilmesi:** Zoom öncesi imleç altındaki child-space noktası `(cx, cy)`, viewport'ta `(vx, vy)` konumundadır. Zoom sonrası aynı noktanın yine `(vx, vy)`'de kalması için:
```
vx = _tx_old + cx * _sc_old (zoom öncesi)
vx = _tx_new + cx * _sc_new (zoom sonrası, aynı nokta)
→ _tx_new = vx - cx * _sc_new
→ _tx_new = (_tx_old + cx * _sc_old) - cx * _sc_new
→ _tx_new = _tx_old + cx * _sc_old - cx * _sc_new
```
---
## 5. Pan Mekanizması (satır 75–80)
```
_onPanUpdate(DragUpdateDetails d)
```
```dart
_tx += d.delta.dx; // her drag update'te delta biriktir
_ty += d.delta.dy;
if (_isFilled) _clampFill(_viewport());
setState(() {});
```
`d.delta`, son update'ten beri olan değişimi verir (toplam değil). Her update'te delta eklenir, clamp yapılır, setState ile yeniden çizilir.
---
## 6. Clamp Sistemi — SADECE Fill Mod (satır 84–107)
Fit modda hiçbir sınırlama yoktur. Kullanıcı özgürce pan/zoom yapabilir.
### `_clampFill(Size vp)`
```
imgL = _tx → Image'ın viewport-space sol kenarı
imgR = _tx + vpw * _sc → Image'ın viewport-space sağ kenarı
vw = imgR - imgL → Image'ın viewport-space genişliği
```
**Mantık:** Fill modda `SizedBox.expand(BoxFit.cover)` kullanıldığı için Image her zaman viewport'u tam doldurur. Yani Image'ın child-space genişliği = vpw. Ölçeklenmiş genişliği = `vpw * _sc`.
**Yatay sınırlama:**
```
if (vw > vpw + 0.5) // Image viewport'tan geniş mi?
if (imgL > 0) // sol kenar viewport içinde → sola çek
_tx -= imgL
else if (imgR < vpw) // sağ kenar viewport içinde → sağa çek
_tx += vpw - imgR
```
**Dikey sınırlama:** Aynı mantık `imgT`/`imgB` ve `vph` ile.
**Epsilon (`0.5`):** Floating-point hatalarını önlemek için. Tam eşitlik durumunda (vw = vpw) hiçbir işlem yapılmaz.
---
## 7. Fill / Fit Geçişi (satır 39–45)
```
_toggleFillFit()
```
```dart
_isFilled = !_isFilled;
_tx = 0; _ty = 0; _sc = 1.0; // sıfırla
```
Çift tıklandığında:
- Mod değiştirilir
- Transform sıfırlanır (identity)
- Viewport cache'i sıfırlanır (sonraki build'te yeniden okunur)
Fill → Fit: Image Center içinde BoxFit.contain ile ortalanır, sınırlama yok.
Fit → Fill: Image SizedBox.expand içinde BoxFit.cover ile doldurur, sınırlama aktif.
---
## 8. Viewport Boyutu (satır 46, 115)
```dart
Size _viewport() => _vpSize;
```
`_vpSize` değeri `LayoutBuilder`'ın `builder` callback'inde `constraints.biggest` olarak alınır (satır 115). Her build'te güncellenir. Neden `context.findRenderObject()` değil?
- `LayoutBuilder`'ın constraints'i, widget'ın parent'tan aldığı en güncel boyut bilgisidir
- `context.findRenderObject()?.size` bazen null veya eski değer döndürebilir (henüz layout olmamışsa)
- `constraints.biggest` her zaman doğru ve günceldir
---
## 9. Görüntü Boyutu Nereden Alınıyor?
**Fill mod:** `SizedBox.expand(BoxFit.cover)` → Image viewport'u tam doldurur. Görüntü boyutu = viewport boyutu. Clamp'ta `vp.width` ve `vp.height` kullanılır.
**Fit mod:** `Center(BoxFit.contain)` → Image fitted boyutta çizilir. Clamp OLMADIĞI için görüntü boyutuna ihtiyaç yoktur.
Eski versiyonlarda `_imageKey.currentContext?.findRenderObject()` ile render box okunuyordu. Bunun iki sorunu vardı:
1. `frameBuilder` içinde çağrılınca render object henüz oluşmamış olabiliyor → null veya 0 boyut dönüyor
2. `SizedBox.expand` içinde render box viewport boyutunu döndürüyor (fitted boyutu değil)
---
## 10. Özet: Hangi Fonksiyon Ne Yapıyor?
| Fonksiyon | Satır | Görevi |
|-----------|-------|--------|
| `_handleScroll()` | 50 | Mouse tekerleği ile zoom (imleç merkezli) |
| `_onPanUpdate()` | 75 | Sürükleme ile pan |
| `_clampFill()` | 84 | Fill modda kenar sınırlaması |
| `_toggleFillFit()` | 39 | Fill/Fit geçişi ve sıfırlama |
| `_resetView()` | 33 | Yeni resim açıldığında sıfırlama |
| `_viewport()` | 46 | Viewport boyutunu döndürür |
| `build()` | 112 | Widget ağacını kurar, `_vpSize`'ı günceller |
---
## 11. Widget Tree (görsel)
```
ImageCanvas
└─ LayoutBuilder ─(constraints.biggest→_vpSize)─
└─ ClipRect
└─ GestureDetector (doubleTap, panUpdate)
└─ Listener (onPointerSignal→scroll)
└─ Transform [T(_tx,_ty)·S(_sc)]
└─ [_isFilled ? SizedBox.expand : Center]
└─ Image.file(key:_imageKey, fit:cover/contain)
```
---
## 12. Geçmiş / Alınan Dersler
1. **InteractiveViewer çıkarıldı** — boundaryMargin çocuk widget'ın tam boyutuna göre çalışıyor, Center içindeki Image'ın asıl boyutunu bilmiyor.
2. **Center yerine SizedBox.expand denenmesi** — `_baseContentRect` ile fitted boyut hesaplandı ama `_intrinsicSize` asenkron yükleniyor, ilk frame'lerde yanlış boyut kullanılıyordu.
3. **Render box'tan boyut okuma** — `frameBuilder` içinde render object henüz oluşmamış olabiliyor (build → layout sırası). Retry mekanizması eklendi ama güvenilir olmadı.
4. **Fit modda sınırlamanın tamamen kaldırılması** — En temiz çözüm. Center widget'ı zaten layout seviyesinde ortalar. Zoom sonrası kullanıcı istediği gibi pan yapabilir.
5. **translateByDouble / scaleByDouble hatası** — `w` parametresi `1` olmalıydı, `0` kullanılınca transform bozuldu. Eski `translate`/`scale` API'sine geri dönüldü.
6. **_vpSize = Size.zero sıfırlaması (2026-08-03 düzeltildi)** — `_resetView()` ve `_toggleFillFit()` içinde `_vpSize = Size.zero` vardı. Çift tıklama (fill/fit toggle) `setState` yerine yalnızca `_markRenderDirty()` çağırdığı için LayoutBuilder yeniden çalışmıyor, `_vpSize` sıfır kalıyordu → `_handleScroll` ve `_clamp` erken return edip zoom ölüyor, pan sınırsız oluyordu. Çözüm: metot içi sıfırlama kaldırıldı; `_vpSize` yalnızca LayoutBuilder'ın `constraints.biggest`'inden beslenir (alan tanımındaki ilk değer korunur). Linux'ta da mevcuttu, Windows testinde fark edildi.
+18 -1
View File
@@ -4,11 +4,28 @@
"sortBy": "Sort by",
"sortByTime": "By time",
"sortByName": "By name",
"sizeSection": "Size",
"appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.",
"shortcuts": "Shortcuts",
"shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness",
"shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+O — open file\n• Ctrl+S — save visible area (JPEG 95)\n• Ctrl+R — flip horizontally (clears effects, press again to undo)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• F11 — toggle fullscreen\n• Esc — exit fullscreen\n• Scroll — zoom (10% per step)\n• Ctrl+scroll — fine zoom (1% per step)\n• Left-drag — pan\n• Double click — toggle fill/fit\n• Right double click — reset effects\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness",
"print": "Print",
"fileProperties": "File properties",
"propResolution": "Resolution",
"propFileSize": "File size",
"propFormat": "Format",
"propModified": "Modified",
"propPath": "Path",
"close": "Close",
"saveCrop": "Save visible area (JPEG 95)",
"cropSaved": "Saved: {path}",
"@cropSaved": {
"placeholders": {
"path": { "type": "String" }
}
},
"cropFileExists": "A file with the same name already exists — nothing was written",
"cropUnsupported": "This image format cannot be saved as a crop",
"cropFailed": "Failed to save crop",
"dragDropHint": "Drag images here\nor press Ctrl+O to open",
"dropHere": "Drop",
"imageLoadError": "Failed to load image"
+73 -1
View File
@@ -122,6 +122,12 @@ abstract class AppLocalizations {
/// **'By name'**
String get sortByName;
/// No description provided for @sizeSection.
///
/// In en, this message translates to:
/// **'Size'**
String get sizeSection;
/// No description provided for @appDescription.
///
/// In en, this message translates to:
@@ -137,7 +143,7 @@ abstract class AppLocalizations {
/// No description provided for @shortcutsList.
///
/// In en, this message translates to:
/// **'• Left/Right arrow — previous/next image\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'**
/// **'• Left/Right arrow — previous/next image\n• Ctrl+O — open file\n• Ctrl+S — save visible area (JPEG 95)\n• Ctrl+R — flip horizontally (clears effects, press again to undo)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• F11 — toggle fullscreen\n• Esc — exit fullscreen\n• Scroll — zoom (10% per step)\n• Ctrl+scroll — fine zoom (1% per step)\n• Left-drag — pan\n• Double click — toggle fill/fit\n• Right double click — reset effects\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness'**
String get shortcutsList;
/// No description provided for @print.
@@ -146,12 +152,78 @@ abstract class AppLocalizations {
/// **'Print'**
String get print;
/// No description provided for @fileProperties.
///
/// In en, this message translates to:
/// **'File properties'**
String get fileProperties;
/// No description provided for @propResolution.
///
/// In en, this message translates to:
/// **'Resolution'**
String get propResolution;
/// No description provided for @propFileSize.
///
/// In en, this message translates to:
/// **'File size'**
String get propFileSize;
/// No description provided for @propFormat.
///
/// In en, this message translates to:
/// **'Format'**
String get propFormat;
/// No description provided for @propModified.
///
/// In en, this message translates to:
/// **'Modified'**
String get propModified;
/// No description provided for @propPath.
///
/// In en, this message translates to:
/// **'Path'**
String get propPath;
/// No description provided for @close.
///
/// In en, this message translates to:
/// **'Close'**
String get close;
/// No description provided for @saveCrop.
///
/// In en, this message translates to:
/// **'Save visible area (JPEG 95)'**
String get saveCrop;
/// No description provided for @cropSaved.
///
/// In en, this message translates to:
/// **'Saved: {path}'**
String cropSaved(String path);
/// No description provided for @cropFileExists.
///
/// In en, this message translates to:
/// **'A file with the same name already exists — nothing was written'**
String get cropFileExists;
/// No description provided for @cropUnsupported.
///
/// In en, this message translates to:
/// **'This image format cannot be saved as a crop'**
String get cropUnsupported;
/// No description provided for @cropFailed.
///
/// In en, this message translates to:
/// **'Failed to save crop'**
String get cropFailed;
/// No description provided for @dragDropHint.
///
/// In en, this message translates to:
+40 -1
View File
@@ -20,6 +20,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sortByName => 'By name';
@override
String get sizeSection => 'Size';
@override
String get appDescription =>
'Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.';
@@ -29,14 +32,50 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get shortcutsList =>
'• Left/Right arrow — previous/next image\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• Double click — toggle fill/fit\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness';
'• Left/Right arrow — previous/next image\n• Ctrl+O — open file\n• Ctrl+S — save visible area (JPEG 95)\n• Ctrl+R — flip horizontally (clears effects, press again to undo)\n• Ctrl+Q — close window\n• Ctrl+Shift+Q — close all windows\n• F11 — toggle fullscreen\n• Esc — exit fullscreen\n• Scroll — zoom (10% per step)\n• Ctrl+scroll — fine zoom (1% per step)\n• Left-drag — pan\n• Double click — toggle fill/fit\n• Right double click — reset effects\n• Right-drag — contrast\n• Shift+right-drag — rotate\n• Ctrl+right-drag — saturation\n• Ctrl+Shift+right-drag — brightness';
@override
String get print => 'Print';
@override
String get fileProperties => 'File properties';
@override
String get propResolution => 'Resolution';
@override
String get propFileSize => 'File size';
@override
String get propFormat => 'Format';
@override
String get propModified => 'Modified';
@override
String get propPath => 'Path';
@override
String get close => 'Close';
@override
String get saveCrop => 'Save visible area (JPEG 95)';
@override
String cropSaved(String path) {
return 'Saved: $path';
}
@override
String get cropFileExists =>
'A file with the same name already exists — nothing was written';
@override
String get cropUnsupported => 'This image format cannot be saved as a crop';
@override
String get cropFailed => 'Failed to save crop';
@override
String get dragDropHint => 'Drag images here\nor press Ctrl+O to open';
+39 -1
View File
@@ -20,6 +20,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get sortByName => 'İsme göre';
@override
String get sizeSection => 'Boyut';
@override
String get appDescription =>
'Ultra hafif ve hızlı resim görüntüleyici. Görselleri açın, gezinti için okları veya klavye tuşlarını kullanın.';
@@ -29,14 +32,49 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get shortcutsList =>
'• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık';
'• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+O — dosya aç\n• Ctrl+S — görünen alanı kaydet (JPEG 95)\n• Ctrl+R — yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• F11 — tam ekran / normal geçişi\n• Esc — tam ekrandan çık\n• Kaydırma — yakınlaştır (tık başına %10)\n• Ctrl+kaydırma — ince yakınlaştırma (tık başına %1)\n• Sol tık sürükle — kaydır\n• Çift tık — doldur/sığdır geçişi\n• Sağ çift tık — efektleri sıfırla\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık';
@override
String get print => 'Yazdır';
@override
String get fileProperties => 'Dosya özellikleri';
@override
String get propResolution => 'Çözünürlük';
@override
String get propFileSize => 'Dosya boyutu';
@override
String get propFormat => 'Biçim';
@override
String get propModified => 'Değiştirilme';
@override
String get propPath => 'Yol';
@override
String get close => 'Kapat';
@override
String get saveCrop => 'Görünen alanı kaydet (JPEG 95)';
@override
String cropSaved(String path) {
return 'Kaydedildi: $path';
}
@override
String get cropFileExists => 'Aynı adlı dosya zaten var — yazılmadı';
@override
String get cropUnsupported => 'Bu resim formatı crop olarak kaydedilemiyor';
@override
String get cropFailed => 'Crop kaydedilemedi';
@override
String get dragDropHint =>
'Resimleri buraya sürükleyin\nveya Ctrl+O ile açın';
+18 -1
View File
@@ -4,11 +4,28 @@
"sortBy": "Sıralama",
"sortByTime": "Zamana göre",
"sortByName": "İsme göre",
"sizeSection": "Boyut",
"appDescription": "Ultra hafif ve hızlı resim görüntüleyici. Görselleri açın, gezinti için okları veya klavye tuşlarını kullanın.",
"shortcuts": "Kısayollar",
"shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• Çift tık — doldur/sığdır geçişi\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık",
"shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+O — dosya aç\n• Ctrl+S — görünen alanı kaydet (JPEG 95)\n• Ctrl+R — yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)\n• Ctrl+Q — pencereyi kapat\n• Ctrl+Shift+Q — tüm pencereleri kapat\n• F11 — tam ekran / normal geçişi\n• Esc — tam ekrandan çık\n• Kaydırma — yakınlaştır (tık başına %10)\n• Ctrl+kaydırma — ince yakınlaştırma (tık başına %1)\n• Sol tık sürükle — kaydır\n• Çift tık — doldur/sığdır geçişi\n• Sağ çift tık — efektleri sıfırla\n• Sağ tık sürükle — kontrast\n• Shift+sağ tık — döndür\n• Ctrl+sağ tık — doygunluk\n• Ctrl+Shift+sağ tık — parlaklık",
"print": "Yazdır",
"fileProperties": "Dosya özellikleri",
"propResolution": "Çözünürlük",
"propFileSize": "Dosya boyutu",
"propFormat": "Biçim",
"propModified": "Değiştirilme",
"propPath": "Yol",
"close": "Kapat",
"saveCrop": "Görünen alanı kaydet (JPEG 95)",
"cropSaved": "Kaydedildi: {path}",
"@cropSaved": {
"placeholders": {
"path": { "type": "String" }
}
},
"cropFileExists": "Aynı adlı dosya zaten var — yazılmadı",
"cropUnsupported": "Bu resim formatı crop olarak kaydedilemiyor",
"cropFailed": "Crop kaydedilemedi",
"dragDropHint": "Resimleri buraya sürükleyin\nveya Ctrl+O ile açın",
"dropHere": "Bırakın",
"imageLoadError": "Resim yüklenemedi"
+1 -1
View File
@@ -18,7 +18,7 @@ void main(List<String> args) async {
final windowOptions = WindowOptions(
size: saved?.size ?? defaultSize,
minimumSize: const Size(400, 300),
minimumSize: const Size(160, 100),
backgroundColor: Colors.transparent,
titleBarStyle: TitleBarStyle.hidden,
);
+170 -1
View File
@@ -6,15 +6,20 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../services/image_header.dart';
import '../services/image_manager.dart';
import '../services/file_handler.dart';
import '../services/crop_save_service.dart';
import '../widgets/custom_title_bar.dart';
import '../widgets/image_canvas.dart';
import '../widgets/window_resize_zones.dart';
import '../widgets/window_size_selector.dart';
import '../shortcuts/app_shortcuts.dart';
import '../services/window_persistence.dart';
import '../l10n/app_localizations.dart';
const appVersion = '1.8.0';
class ViewerScreen extends StatefulWidget {
final List<String> initialFiles;
const ViewerScreen({super.key, this.initialFiles = const []});
@@ -28,6 +33,8 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
bool _controlsVisible = false;
Timer? _saveDebounce;
Rect _lastBounds = Rect.zero;
final ValueNotifier<double> _zoomPercent = ValueNotifier<double>(100);
final GlobalKey<ImageCanvasState> _canvasKey = GlobalKey<ImageCanvasState>();
@override
void initState() {
@@ -47,6 +54,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
windowManager.removeListener(this);
ImageManager.instance.removeListener(_onImageManagerChanged);
_saveDebounce?.cancel();
_zoomPercent.dispose();
super.dispose();
}
@@ -130,7 +138,37 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
},
),
const Divider(color: Colors.white24, height: 24),
const Text('imajViewer', style: TextStyle(color: Colors.white, fontSize: 15)),
WindowSizeSelector(
workArea: _workArea,
onPick: _applyWindowSize,
),
const Divider(color: Colors.white24, height: 24),
if (_hasImages) ...[
Text(AppLocalizations.of(context)!.fileProperties,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
const SizedBox(height: 4),
FutureBuilder<List<(String, String)>>(
future: _fileProperties(ImageManager.instance.currentImagePath),
builder: (context, snap) {
if (!snap.hasData) return const SizedBox(height: 20);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: snap.data!.map((r) => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text.rich(
TextSpan(children: [
TextSpan(text: '${r.$1}: ', style: const TextStyle(color: Colors.white70, fontSize: 13)),
TextSpan(text: r.$2, style: const TextStyle(color: Colors.white, fontSize: 13)),
]),
),
)).toList(),
);
},
),
const SizedBox(height: 12),
],
Text('imajViewer $appVersion',
style: const TextStyle(color: Colors.white, fontSize: 15)),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.appDescription,
@@ -188,9 +226,107 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
);
}
Future<List<(String, String)>> _fileProperties(String path) async {
final l10n = AppLocalizations.of(context)!;
final file = File(path);
final rows = <(String, String)>[];
final Size? size = pixelSizeOfFile(path);
rows.add((l10n.propResolution, size == null ? '—' : '${size.width.toInt()} × ${size.height.toInt()}'));
try {
rows.add((l10n.propFileSize, _fmtSize(file.statSync().size)));
} catch (_) {
rows.add((l10n.propFileSize, '—'));
}
rows.add((l10n.propFormat, path.toLowerCase().split('.').last.toUpperCase()));
try {
rows.add((l10n.propModified, _fmtDate(file.statSync().modified)));
} catch (_) {
rows.add((l10n.propModified, '—'));
}
rows.add((l10n.propPath, path));
return rows;
}
String _fmtSize(int bytes) {
if (bytes < 1024) return '$bytes B';
const units = ['KB', 'MB', 'GB', 'TB'];
double v = bytes / 1024;
var i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return '${v.toStringAsFixed(v >= 100 ? 0 : 1)} ${units[i]}';
}
String _fmtDate(DateTime t) {
String two(int n) => n.toString().padLeft(2, '0');
return '${t.year}-${two(t.month)}-${two(t.day)} ${two(t.hour)}:${two(t.minute)}';
}
void _flipHorizontal() => _canvasKey.currentState?.toggleFlipH();
Future<Size?> _workArea() async {
final pos = await windowManager.getPosition();
return WindowPersistence.workAreaSize(windowPosition: pos);
}
Future<void> _applyWindowSize(int rows, int cols, Size work) async {
try {
await windowManager.setSize(Size(work.width / cols, work.height / rows));
} catch (_) {
// Boyutlandırma başarısız olursa sessizce geç (buton saf eylem).
}
}
Future<void> _cropSave() async {
final path = ImageManager.instance.currentImagePath;
final state = _canvasKey.currentState;
if (path.isEmpty || state == null) return;
final l10n = AppLocalizations.of(context)!;
String? message;
try {
final source = await CropSaveService.load(path);
final vp = state.cropViewParams(source.width, source.height);
if (vp == null) {
message = l10n.cropFailed;
} else {
final outPath = await CropSaveService.saveCrop(
srcPath: path,
source: source,
vp: vp,
matrix: state.activeColorMatrix,
);
message = l10n.cropSaved(outPath);
}
} on CropSaveError catch (e) {
switch (e.code) {
case 'file_exists':
message = l10n.cropFileExists;
case 'decode':
message = l10n.cropUnsupported;
default:
message = l10n.cropFailed;
}
} catch (_) {
message = l10n.cropFailed;
}
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message, style: const TextStyle(fontSize: 13)),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
width: 520,
));
}
@override
Widget build(BuildContext context) {
return AppShortcuts(
onCropSave: _hasImages ? _cropSave : null,
onFlipH: _hasImages ? _flipHorizontal : null,
child: Scaffold(
backgroundColor: Colors.transparent,
body: WindowResizeZones(
@@ -223,7 +359,9 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
color: Colors.transparent,
child: _hasImages
? ImageCanvas(
key: _canvasKey,
filePath: ImageManager.instance.currentImagePath,
zoomPercent: _zoomPercent,
)
: Center(
child: Column(
@@ -242,6 +380,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
),
CustomTitleBar(
onSettings: _showSettings,
onSaveCrop: _hasImages ? _cropSave : null,
visible: _controlsVisible,
),
if (_hasImages && _controlsVisible) ...[
@@ -261,6 +400,36 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
onTap: () => ImageManager.instance.nextImage(),
),
),
Positioned(
left: 0,
right: 0,
bottom: 12,
child: IgnorePointer(
child: Center(
child: ValueListenableBuilder<double>(
valueListenable: _zoomPercent,
builder: (context, zoom, _) {
final pct = zoom.round();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(13),
),
child: Text(
pct < 1 ? '<1%' : '$pct%',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
},
),
),
),
),
],
if (_isDragging)
Positioned.fill(
+153
View File
@@ -0,0 +1,153 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
class CropSaveError implements Exception {
final String code;
const CropSaveError(this.code);
@override
String toString() => 'CropSaveError($code)';
}
/// Viewport -> tam cozunurluk kaynak eslemesi (image_canvas'tan gelir).
class CropViewParams {
final int w, h; // kaynak dosya boyutu
final double vw, vh; // viewport boyutu
final double tx, ty, sc; // pan/zoom
final double angleDeg; // donus (derece)
final bool flipH; // yatay aynalama (goruntunun kendisi degil, gorunum)
final double cx, cy; // donus merkezi (viewport ortasi)
final double k; // kaynak -> layout olcegi
final double ox, oy; // layout offset
const CropViewParams({
required this.w,
required this.h,
required this.vw,
required this.vh,
required this.tx,
required this.ty,
required this.sc,
required this.angleDeg,
this.flipH = false,
required this.cx,
required this.cy,
required this.k,
required this.ox,
required this.oy,
});
}
class CropSaveService {
/// Tam cozunurlukte decode + EXIF yonunu gomme (engine goruntusuyle tutarli).
static Future<img.Image> load(String srcPath) async {
final bytes = await File(srcPath).readAsBytes();
final src = img.decodeImage(bytes);
if (src == null) throw const CropSaveError('decode');
return img.bakeOrientation(src);
}
/// Hedef yol: `resim.png` -> `resim_143025.jpg` (hep JPEG; cakismada null).
static String? buildOutputPath(String srcPath) {
final base = p.basenameWithoutExtension(srcPath);
final dir = p.dirname(srcPath);
final now = DateTime.now();
final hh = now.hour.toString().padLeft(2, '0');
final mm = now.minute.toString().padLeft(2, '0');
final ss = now.second.toString().padLeft(2, '0');
final candidate = p.join(dir, '${base}_$hh$mm$ss.jpg');
return File(candidate).existsSync() ? null : candidate;
}
/// Ekranda gorunen bolgeyi birebir (donus dahil) tam cozunurlukte kaydedir.
/// Duzlem: viewport -> (tx,ty,sc) -> (vp ortasi etrafinda donus) -> (k,ox,oy) -> kaynak px.
static Future<String> saveCrop({
required String srcPath,
required img.Image source,
required CropViewParams vp,
List<double>? matrix,
int jpegQuality = 95,
}) async {
final outPath = buildOutputPath(srcPath);
if (outPath == null) throw const CropSaveError('file_exists');
final out = vp.angleDeg == 0
? _cropAxisAligned(source, vp)
: _cropRotated(source, vp);
// Ekranda yatay aynalama acikken cikti da aynalanir (cikti = viewport'un birebir eslemesi).
if (vp.flipH) img.flipHorizontal(out);
// Çıktıda yön fiziksel — EXIF tag'ini temizle (çift dönüşü önler).
out.exif.imageIfd.orientation = null;
if (matrix != null) _applyMatrix(out, matrix);
final jpeg = img.JpegEncoder(quality: jpegQuality).encode(out);
await File(outPath).writeAsBytes(jpeg);
return outPath;
}
/// Donus yok: gorulen dikdortgeni birebir kes (hizli yol).
static img.Image _cropAxisAligned(img.Image src, CropViewParams vp) {
double toX(double vx) => ((vx - vp.tx) / vp.sc - vp.ox) / vp.k;
double toY(double vy) => ((vy - vp.ty) / vp.sc - vp.oy) / vp.k;
final x0 = toX(0), x1 = toX(vp.vw), y0 = toY(0), y1 = toY(vp.vh);
final x = x0.round().clamp(0, src.width - 1);
final y = y0.round().clamp(0, src.height - 1);
final xr = x1.round().clamp(x + 1, src.width);
final yr = y1.round().clamp(y + 1, src.height);
return img.copyCrop(src, x: x, y: y, width: xr - x, height: yr - y);
}
/// Donus var: ekran grid'ini kaynaga birebir ornekler (bilinear).
static img.Image _cropRotated(img.Image src, CropViewParams vp) {
final step = vp.sc * vp.k; // ekran px basina kaynak px
if (step <= 0) throw const CropSaveError('empty_rect');
final ow = (vp.vw / step).round().clamp(1, 1 << 14);
final oh = (vp.vh / step).round().clamp(1, 1 << 14);
final angle = vp.angleDeg * math.pi / 180;
final cos = math.cos(-angle), sin = math.sin(-angle);
(double, double) toSrc(double vx, double vy) {
final x = (vx - vp.tx) / vp.sc - vp.cx;
final y = (vy - vp.ty) / vp.sc - vp.cy;
return ((cos * x - sin * y + vp.cx - vp.ox) / vp.k,
(sin * x + cos * y + vp.cy - vp.oy) / vp.k);
}
final out = img.Image(width: ow, height: oh);
for (var y = 0; y < oh; y++) {
final vy = (y + 0.5) * step;
for (var x = 0; x < ow; x++) {
final s = toSrc((x + 0.5) * step, vy);
final c = _sampleBilinear(src, s.$1, s.$2);
out.setPixelRgb(x, y, c.$1, c.$2, c.$3);
}
}
return out;
}
/// Manuel bilinear ornekleme (package:image 4.x API'si).
static (int, int, int) _sampleBilinear(img.Image src, double fx, double fy) {
final x0 = fx.floor(), y0 = fy.floor();
final tx = (fx - x0).clamp(0.0, 1.0), ty = (fy - y0).clamp(0.0, 1.0);
final c00 = src.getPixelClamped(x0, y0), c10 = src.getPixelClamped(x0 + 1, y0),
c01 = src.getPixelClamped(x0, y0 + 1), c11 = src.getPixelClamped(x0 + 1, y0 + 1);
int lerp(num a, num b, double t) => (a + (b - a) * t).toInt();
return (
lerp(lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty),
lerp(lerp(c00.g, c10.g, tx), lerp(c01.g, c11.g, tx), ty),
lerp(lerp(c00.b, c10.b, tx), lerp(c01.b, c11.b, tx), ty),
);
}
/// Flutter'la ayni 0-255 alan/semantigindaki 4x5 matrisi uygular.
static void _applyMatrix(img.Image image, List<double> m) {
for (final pixel in image) {
final r = pixel.r.toDouble(), g = pixel.g.toDouble(), b = pixel.b.toDouble();
pixel.r = (m[0] * r + m[1] * g + m[2] * b + m[4]).clamp(0, 255).round();
pixel.g = (m[5] * r + m[6] * g + m[7] * b + m[9]).clamp(0, 255).round();
pixel.b = (m[10] * r + m[11] * g + m[12] * b + m[14]).clamp(0, 255).round();
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/widgets.dart' show Size;
import 'package:image/image.dart' as img;
/// Görüntü dosyasının gerçek piksel boyutu — tam decode yapmadan.
///
/// PNG/GIF/BMP/WebP: boyut ilk 33 baytta → elle parse.
/// JPEG: SOF büyük APP/EXIF segmentlerinin ardından gelebilir → package:image startDecode.
Size? pixelSizeOfFile(String path) {
try {
final file = File(path);
final stat = file.statSync();
const maxHead = 512 * 1024;
final raf = file.openSync();
final header = raf.readSync(stat.size < maxHead ? stat.size : maxHead);
raf.closeSync();
return _headerPixelSize(header);
} catch (_) {}
return null;
}
Size? _headerPixelSize(Uint8List b) {
try {
if (b.length >= 24 && b[0] == 0x89 && b[1] == 0x50) {
// PNG
final w = (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19];
final h = (b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23];
return Size(w.toDouble(), h.toDouble());
}
if (b.length >= 10 && b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46) {
// GIF (WBMP da GIF tabanlı olabilir)
final w = b[6] | (b[7] << 8);
final h = b[8] | (b[9] << 8);
return Size(w.toDouble(), h.toDouble());
}
if (b.length >= 26 && b[0] == 0x42 && b[1] == 0x4D) {
// BMP
final w = b[18] | (b[19] << 8) | (b[20] << 16) | (b[21] << 24);
final h = b[22] | (b[23] << 8) | (b[24] << 16) | (b[25] << 24);
return Size(w.toDouble(), h.abs().toDouble());
}
if (b.length >= 30 &&
b[0] == 0x52 &&
b[1] == 0x49 &&
b[2] == 0x46 &&
b[3] == 0x46 &&
b[8] == 0x57 &&
b[9] == 0x45 &&
b[10] == 0x42 &&
b[11] == 0x50) {
// WebP
if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x58) {
// VP8X
final w = b[24] | (b[25] << 8) | (b[26] << 16);
final h = b[27] | (b[28] << 8) | (b[29] << 16);
return Size((w + 1).toDouble(), (h + 1).toDouble());
}
if (b.length >= 25 && b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x4C && b[20] == 0x2F) {
// VP8L
final w = (b[21] & 0x3F) | ((b[22] & 0x0F) << 6);
final h = (b[23] & 0x3F) | ((b[24] & 0x0F) << 6);
return Size((w + 1).toDouble(), (h + 1).toDouble());
}
if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x20) {
// VP8 (lossy)
final w = (b[26] | (b[27] << 8)) & 0x3FFF;
final h = (b[28] | (b[29] << 8)) & 0x3FFF;
return Size(w.toDouble(), h.toDouble());
}
}
} catch (_) {}
try {
// JPEG
final info = img.JpegDecoder().startDecode(b);
if (info != null) return Size(info.width.toDouble(), info.height.toDouble());
} catch (_) {}
return null;
}
-2
View File
@@ -69,8 +69,6 @@ class ImageManager extends ChangeNotifier {
}
if (_currentIndex < 0) _currentIndex = 0;
// Eski klasörün decode edilmiş görsellerini bırak; canlı görsel korunur
PaintingBinding.instance.imageCache.clear();
notifyListeners();
}
+68 -7
View File
@@ -15,28 +15,59 @@ class WindowPersistence {
return File('$dir/window.json');
}
/// Görüntü ekran modu: true = pencereyi kapla (cover), false = sığdır (contain).
/// Anahtar yoksa/bozuksa true (eski davranış).
static bool get filled {
final v = _read()?['filled'];
return v is bool ? v : true;
}
/// Sadece modu günceller; x/y/w/h korunur.
static void saveFilled(bool value) => _write({'filled': value});
/// Save window rect synchronously (safe to call before destroy).
static void saveSync({
required Offset position,
required Size size,
}) {
final file = _configFile;
file.parent.createSync(recursive: true);
file.writeAsStringSync(jsonEncode({
_write({
'x': position.dx.toInt(),
'y': position.dy.toInt(),
'width': size.width.toInt(),
'height': size.height.toInt(),
}));
});
}
/// Dosyayı oku (yok/bozuksa null).
static Map<String, dynamic>? _read() {
final file = _configFile;
if (!file.existsSync()) return null;
try {
return jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
/// Verilen anahtarları yaz; dosyadaki diğer anahtarlar korunur.
static void _write(Map<String, dynamic> values) {
try {
final file = _configFile;
file.parent.createSync(recursive: true);
final data = _read() ?? <String, dynamic>{};
data.addAll(values);
file.writeAsStringSync(jsonEncode(data));
} catch (_) {
// Disk hatası uygulamayı durdurmasın.
}
}
/// Restore saved window rect. Returns null if no saved config.
static ({Offset position, Size size})? restore() {
final file = _configFile;
if (!file.existsSync()) return null;
final data = _read();
if (data == null) return null;
try {
final data = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
final x = (data['x'] as num?)?.toDouble() ?? 0;
final y = (data['y'] as num?)?.toDouble() ?? 0;
final w = (data['width'] as num?)?.toDouble() ?? 1280;
@@ -53,6 +84,36 @@ class WindowPersistence {
}
}
/// Work area (taskbar excluded) of the display containing the window,
/// in logical px. Returns null if screen info is unavailable.
static Future<Size?> workAreaSize({
required Offset windowPosition,
}) async {
try {
final displays = await screenRetriever.getAllDisplays();
// Pencerenin bulunduğu monitörü bul (çoklu monitör desteği)
Display display = await screenRetriever.getPrimaryDisplay();
for (final d in displays) {
final pos = d.visiblePosition;
final size = d.visibleSize ?? d.size;
if (pos != null &&
windowPosition.dx >= pos.dx &&
windowPosition.dx < pos.dx + size.width &&
windowPosition.dy >= pos.dy &&
windowPosition.dy < pos.dy + size.height) {
display = d;
break;
}
}
return display.visibleSize ?? display.size;
} catch (_) {
// Fallback: screen info unavailable → caller silently no-ops.
return null;
}
}
/// Cascade: place new window to the right of the previous one.
/// Falls to the next row (row start) when it would overflow the screen,
/// and wraps to the top-left if the next row also overflows.
+29 -2
View File
@@ -7,8 +7,15 @@ import '../services/file_handler.dart';
class AppShortcuts extends StatelessWidget {
final Widget child;
final VoidCallback? onCropSave;
final VoidCallback? onFlipH;
const AppShortcuts({super.key, required this.child});
const AppShortcuts({
super.key,
required this.child,
this.onCropSave,
this.onFlipH,
});
@override
Widget build(BuildContext context) {
@@ -35,6 +42,25 @@ class AppShortcuts extends StatelessWidget {
return KeyEventResult.handled;
}
// Ctrl+S: görünen alanı kaydet (crop save)
if (ctrl && !shift && event.logicalKey == LogicalKeyboardKey.keyS) {
if (onCropSave != null) {
onCropSave!();
return KeyEventResult.handled;
}
}
// Ctrl+R: yatay aynala / geri al (fiziksel R: bazi klavye haritalarinda
// mantiksal tus T gibi farkli cozuluyor)
if (ctrl &&
(event.logicalKey == LogicalKeyboardKey.keyR ||
event.physicalKey == PhysicalKeyboardKey.keyR)) {
if (onFlipH != null) {
onFlipH!();
return KeyEventResult.handled;
}
}
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
ImageManager.instance.previousImage();
@@ -42,9 +68,10 @@ class AppShortcuts extends StatelessWidget {
case LogicalKeyboardKey.arrowRight:
ImageManager.instance.nextImage();
return KeyEventResult.handled;
case LogicalKeyboardKey.f11:
case LogicalKeyboardKey.escape:
windowManager.isFullScreen().then((isFullscreen) {
if (isFullscreen) windowManager.setFullScreen(false);
windowManager.setFullScreen(event.logicalKey == LogicalKeyboardKey.f11 ? !isFullscreen : false);
});
return KeyEventResult.handled;
}
+7 -1
View File
@@ -3,9 +3,10 @@ import 'package:window_manager/window_manager.dart';
class CustomTitleBar extends StatelessWidget {
final VoidCallback? onSettings;
final VoidCallback? onSaveCrop;
final bool visible;
const CustomTitleBar({super.key, this.onSettings, this.visible = true});
const CustomTitleBar({super.key, this.onSettings, this.onSaveCrop, this.visible = true});
@override
Widget build(BuildContext context) {
@@ -43,6 +44,11 @@ class CustomTitleBar extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onSaveCrop != null)
_BarButton(
icon: Icons.save_outlined,
onTap: onSaveCrop!,
),
_BarButton(
icon: Icons.horizontal_rule,
onTap: () => windowManager.minimize(),
+207 -34
View File
@@ -5,16 +5,22 @@ 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/image_header.dart';
import '../services/window_persistence.dart';
class ImageCanvas extends StatefulWidget {
final String filePath;
const ImageCanvas({super.key, required this.filePath});
/// Görüntünün gerçek piksel boyutuna göre ölçek yüzdesi (100% = 1:1).
final ValueNotifier<double>? zoomPercent;
const ImageCanvas({super.key, required this.filePath, this.zoomPercent});
@override
State<ImageCanvas> createState() => _ImageCanvasState();
State<ImageCanvas> createState() => ImageCanvasState();
}
class _ImageCanvasState extends State<ImageCanvas> {
bool _isFilled = true;
class ImageCanvasState extends State<ImageCanvas> {
bool _isFilled = WindowPersistence.filled;
final GlobalKey _imageKey = GlobalKey();
double _tx = 0, _ty = 0, _sc = 1.0;
Size _vpSize = Size.zero;
@@ -24,6 +30,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
double _contrast = 1.0;
double _saturation = 1.0;
double _brightness = 0;
bool _flipH = false;
// ── Gesture tracking ──
String _interactionMode = 'none'; // 'contrast' | 'rotate' | 'saturation' | 'brightness'
@@ -34,11 +41,59 @@ class _ImageCanvasState extends State<ImageCanvas> {
double _startBrightness = 0;
DateTime? _lastRightTapTime;
Offset _lastRightTapPos = Offset.zero;
/// Sağ tuş basılıyken anlamlı hareket oldu mu? (tıklama mı sürükleme mi)
bool _rightDragMoved = false;
int? _rightPointerId;
/// Sürükleme sayılma eşiği (px) — rotate/contrast ince ayarında el titremesi.
static const double _dragClickSlop = 4;
// ── Klavye modifier tracking ──
bool _ctrlPressed = false;
bool _shiftPressed = false;
// ── Downsampling + swap ──
bool _usingFullRes = false;
Size _imageSize = Size.zero;
static const int _downsampleTarget = 2048;
// ── Gerçek piksel boyutu + zoom yüzdesi ──
Size? _origSize;
bool _zoomSyncScheduled = false;
/// Yüzdeyi bir sonraki kare sonunda yayınla (build sırasında setState tetiklememek için).
void _syncZoom() {
if (_zoomSyncScheduled) return;
_zoomSyncScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_zoomSyncScheduled = false;
if (!mounted) return;
final n = widget.zoomPercent;
if (n == null) return;
final v = _zoomPercentValue();
if (n.value != v) n.value = v;
});
}
/// Ekrandaki ölçeğin görüntünün gerçek piksel boyutuna oranı (100% = 1:1).
double _zoomPercentValue() {
final o = _origSize;
if (o == null || o.isEmpty || _vpSize.isEmpty) return _sc * 100;
final double ratio;
if (_isFilled) {
// cover: kutu viewport, boya ölçeği max oran
ratio = math.max(_vpSize.width / o.width, _vpSize.height / o.height);
} else {
// contain: yerleşmiş görüntü kutusu (downsample sınırı dahil) → gerçek ölçek
final box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null && box.hasSize && box.size.width > 0) {
ratio = box.size.width / o.width;
} else {
ratio = math.min(_vpSize.width / o.width, _vpSize.height / o.height);
}
}
return _sc * ratio * 100;
}
// ── Kare hızına eşitlenmiş render tick ──
final ValueNotifier<int> _renderTick = ValueNotifier(0);
bool _frameScheduled = false;
@@ -120,8 +175,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
@override
void initState() {
super.initState();
_origSize = pixelSizeOfFile(widget.filePath);
HardwareKeyboard.instance.addHandler(_onHardwareKey);
_startWarmup();
_syncZoom();
}
/// İlk kullanım shader derlemesini açılışa çek: gizli katmanda sırayla
@@ -151,6 +208,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
void dispose() {
HardwareKeyboard.instance.removeHandler(_onHardwareKey);
_renderTick.dispose();
_imageSizeStream?.removeListener(_imageSizeListener!);
super.dispose();
}
@@ -179,18 +237,43 @@ class _ImageCanvasState extends State<ImageCanvas> {
@override
void didUpdateWidget(ImageCanvas old) {
super.didUpdateWidget(old);
if (old.filePath != widget.filePath) _resetView();
if (old.filePath != widget.filePath) {
_origSize = pixelSizeOfFile(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;
_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();
_syncZoom();
}
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();
_syncZoom();
}
/// 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();
}
@@ -202,7 +285,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
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 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) {
@@ -216,8 +299,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
_ty -= dh / 2;
}
_sc = newSc;
_checkFullResSwap();
_clamp();
_markRenderDirty();
_syncZoom();
}
void _onPanUpdate(DragUpdateDetails d) {
@@ -231,22 +316,8 @@ class _ImageCanvasState extends State<ImageCanvas> {
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;
_rightDragMoved = false;
_rightPointerId = e.pointer;
if (_ctrlPressed && _shiftPressed) {
_interactionMode = 'brightness';
@@ -265,6 +336,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
void _onPointerMove(PointerMoveEvent e) {
if (e.buttons != kSecondaryMouseButton) return;
if (!_rightDragMoved &&
(e.localPosition - _pointerDownPos).distance > _dragClickSlop) {
_rightDragMoved = true;
}
final dy = e.localPosition.dy - _pointerDownPos.dy;
@@ -286,7 +361,30 @@ class _ImageCanvasState extends State<ImageCanvas> {
}
void _onPointerUp(PointerUpEvent e) {
if (e.pointer != _rightPointerId) return;
_rightPointerId = null;
_interactionMode = 'none';
// Sürükleme ile biten etkileşim sağ çift tık zincirini keser; yalnızca
// gerçek tıklamalar (hareketsiz bas-bırak) çift tık sayılır.
if (_rightDragMoved) {
_lastRightTapTime = null;
_rightDragMoved = false;
return;
}
final now = DateTime.now();
final isDouble = _lastRightTapTime != null &&
now.difference(_lastRightTapTime!) < const Duration(milliseconds: 400) &&
(e.localPosition - _lastRightTapPos).distance < 20;
_lastRightTapTime = isDouble ? null : now;
_lastRightTapPos = e.localPosition;
if (isDouble) {
_angle = 0;
_contrast = 1.0;
_saturation = 1.0;
_brightness = 0;
_flipH = false;
_markRenderDirty();
}
}
// ── Combined color filter (brightness × saturation × contrast) ──
@@ -296,6 +394,7 @@ class _ImageCanvasState extends State<ImageCanvas> {
static const double _bLum = 0.0722;
ColorFilter? _cachedFilter;
List<double>? _cachedMatrix;
double _cachedSat = -1;
double _cachedCon = -1;
double _cachedBri = double.nan;
@@ -362,26 +461,96 @@ class _ImageCanvasState extends State<ImageCanvas> {
];
// M_final = M_contrast * M_brightness * M_saturation
_cachedFilter = ColorFilter.matrix(
_multiply4x5(_multiply4x5(contrastMatrix, brightnessMatrix), saturationMatrix));
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<double>? 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<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 longest = _vpSize.longestSide;
final cacheWidth = longest > 0 ? (longest * _sc * 2).round() : null;
if (_cachedPath != widget.filePath || _cachedCacheWidth != cacheWidth) {
final needFullRes = _usingFullRes;
final effectiveWidth = needFullRes ? null : _downsampleTarget;
if (_cachedPath != widget.filePath || _cachedCacheWidth != effectiveWidth) {
_cachedPath = widget.filePath;
_cachedCacheWidth = cacheWidth;
_cachedCacheWidth = effectiveWidth;
_cachedFileImage = FileImage(File(widget.filePath));
_cachedProvider = cacheWidth != null
? ResizeImage.resizeIfNeeded(cacheWidth, null, _cachedFileImage!)
: null;
_cachedProvider = effectiveWidth != null
? ResizeImage(
_cachedFileImage!,
width: effectiveWidth,
height: effectiveWidth,
policy: ResizeImagePolicy.fit,
)
: _cachedFileImage;
_readImageSize(_cachedProvider!);
}
final ImageProvider<Object> provider = _cachedProvider ?? _cachedFileImage!;
return Image(
@@ -398,7 +567,10 @@ class _ImageCanvasState extends State<ImageCanvas> {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_vpSize = constraints.biggest;
if (constraints.biggest != _vpSize) {
_vpSize = constraints.biggest;
_syncZoom();
}
_clamp();
return RepaintBoundary(
child: ValueListenableBuilder<int>(
@@ -427,12 +599,13 @@ class _ImageCanvasState extends State<ImageCanvas> {
},
);
if (_angle != 0) {
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,
);
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
/// 8 sütun × 4 satır ekran oranı buton ızgarası.
///
/// Buton (sütun `b`, satır `a`) etiketi `"$a:$b"` → hedef boyut
/// `W/b × H/a` (a dikeyi, b yatayı böler). Sol-üst hücre (1:1) boş
/// placeholder — 1:1 yok (title bar çift tık zaten tam ekran).
class WindowSizeSelector extends StatelessWidget {
final Future<Size?> Function() workArea;
final void Function(int rows, int cols, Size work) onPick;
const WindowSizeSelector({
super.key,
required this.workArea,
required this.onPick,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context)!.sizeSection,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
const SizedBox(height: 4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var a = 1; a <= 4; a++)
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var b = 1; b <= 8; b++)
Padding(
padding: const EdgeInsets.only(right: 4, bottom: 4),
child: a == 1 && b == 1
? const SizedBox(width: 46, height: 26)
: GestureDetector(
onTap: () async {
final work = await workArea();
if (work != null) onPick(a, b, work);
},
child: Container(
width: 46,
height: 26,
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.center,
child: Text(
'$a:$b',
style: const TextStyle(
color: Colors.white, fontSize: 11),
),
),
),
),
],
),
],
),
),
],
);
}
}
+8
View File
@@ -150,6 +150,14 @@ static void my_application_class_init(MyApplicationClass* klass) {
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// GTK3 ignores gtk_window_move under Wayland (window opens centered).
// Force X11 backend when an X server is available (XWayland) so window
// position/size persistence works.
if (g_getenv("WAYLAND_DISPLAY") != nullptr &&
g_getenv("DISPLAY") != nullptr) {
g_setenv("GDK_BACKEND", "x11", FALSE);
}
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
+11 -11
View File
@@ -198,7 +198,7 @@ packages:
source: hosted
version: "4.1.2"
image:
dependency: transitive
dependency: "direct main"
description:
name: image
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
@@ -209,10 +209,10 @@ packages:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
version: "0.20.3"
json_annotation:
dependency: transitive
description:
@@ -257,10 +257,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -273,10 +273,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
msix:
dependency: "direct main"
description:
@@ -462,10 +462,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@@ -486,10 +486,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.3"
vm_service:
dependency: transitive
description:
+2 -1
View File
@@ -1,7 +1,7 @@
name: imajviewer
description: "Ultra-lightweight image viewer for Linux and Windows."
publish_to: 'none'
version: 1.1.0+3
version: 1.8.0+19
msix_config:
display_name: ImajViewer
@@ -24,6 +24,7 @@ dependencies:
file_picker: ^8.0.0
path: ^1.9.0
desktop_drop: ^0.7.1
image: ^4.0.0
msix: ^3.18.0
printing: ^5.13.4
pdf: ^3.11.1
+93
View File
@@ -0,0 +1,93 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:imajviewer/shortcuts/app_shortcuts.dart';
import 'package:imajviewer/widgets/image_canvas.dart';
/// Aynalama uygulandiginda render zincirinde negatif x olcekli bir Transform bulunur.
bool _mirrored(WidgetTester tester) => tester
.widgetList<Transform>(find.byType(Transform))
.any((t) => t.transform.entry(0, 0) < 0);
Future<void> _pressCtrlR(WidgetTester tester) async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyR);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyR);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
await tester.pump();
}
Future<void> _pressCtrlRWrongLayout(WidgetTester tester) async {
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyT,
physicalKey: PhysicalKeyboardKey.keyR);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyT,
physicalKey: PhysicalKeyboardKey.keyR);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
await tester.pump();
}
Widget _app(GlobalKey<ImageCanvasState> key, String path, double w, double h) {
return MaterialApp(
home: AppShortcuts(
onFlipH: () => key.currentState?.toggleFlipH(),
child: SizedBox(width: w, height: h, child: ImageCanvas(key: key, filePath: path)),
),
);
}
void main() {
late Directory tmp;
late String pngPath;
setUpAll(() {
tmp = Directory.systemTemp.createTempSync('flip_test');
pngPath = '${tmp.path}/a.png';
File(pngPath).writeAsBytesSync(img.encodePng(img.Image(width: 4, height: 2))!);
});
tearDownAll(() => tmp.deleteSync(recursive: true));
testWidgets('Ctrl+R yatay aynalamayi acar, tekrar basinca kapatir', (tester) async {
final key = GlobalKey<ImageCanvasState>();
await tester.pumpWidget(_app(key, pngPath, 400, 300));
await tester.pump();
expect(_mirrored(tester), isFalse, reason: 'baslangicta aynalama olmamali');
await _pressCtrlR(tester);
expect(_mirrored(tester), isTrue, reason: 'Ctrl+R sonrasi aynalama acik olmali');
await _pressCtrlR(tester);
expect(_mirrored(tester), isFalse, reason: 'ikinci Ctrl+R sonrasi eski haline donmeli');
});
testWidgets('Ctrl+R crop-save parametrelerine aynalamayi tasir', (tester) async {
final key = GlobalKey<ImageCanvasState>();
await tester.pumpWidget(_app(key, pngPath, 100, 50));
await tester.pump();
await _pressCtrlR(tester);
final vp = key.currentState!.cropViewParams(4, 2);
expect(vp?.flipH, isTrue, reason: 'crop-save aynalamayi almali');
});
testWidgets('fiziksel R tusu mantiksal T olarak gelse de Ctrl+R calisir', (tester) async {
final key = GlobalKey<ImageCanvasState>();
await tester.pumpWidget(_app(key, pngPath, 400, 300));
await tester.pump();
await _pressCtrlRWrongLayout(tester);
expect(_mirrored(tester), isTrue,
reason: 'klavye haritasi R tusunu T olarak cozse bile aynalama acilmali');
await _pressCtrlRWrongLayout(tester);
expect(_mirrored(tester), isFalse, reason: 'tekrar basinca geri donmeli');
});
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:io';
import 'package:image/image.dart' as img;
import 'package:imajviewer/services/crop_save_service.dart';
Future<void> main() async {
final tmp = Directory.systemTemp.createTempSync('crop_test');
final srcPath = '${tmp.path}/test.png';
final src = img.Image(width: 100, height: 50);
void fill(int x, int y, int r, int g, int b) {
for (var dy = 0; dy < 5; dy++)
for (var dx = 0; dx < 5; dx++) src.setPixelRgb(x + dx, y + dy, r, g, b);
}
fill(0, 0, 255, 0, 0); // sol-ust: KIRMIZI
fill(95, 0, 0, 255, 0); // sag-ust: YESIL
fill(0, 45, 0, 0, 255); // sol-alt: MAVI
fill(95, 45, 255, 255, 0); // sag-alt: SARI
File(srcPath).writeAsBytesSync(img.encodePng(src)!);
var fails = 0;
void checkPix(String label, (int, int, int) px, int r, int g, int b) {
int d(int a, int c) => (a - c).abs();
final ok = d(px.$1, r) <= 60 && d(px.$2, g) <= 60 && d(px.$3, b) <= 60;
if (!ok) fails++;
print('${ok ? 'OK ' : 'FAIL'} $label: got ${px.$1},${px.$2},${px.$3} want ~$r,$g,$b');
}
// 1) Duz, fit, zoom yok: gorunen = kaynakin kendisi
final outDir1 = '${tmp.path}/a';
Directory(outDir1).createSync();
final f1 = '${outDir1}/src.jpg';
File(f1).writeAsBytesSync(File(srcPath).readAsBytesSync());
final p1 = CropViewParams(w: 100, h: 50, vw: 100, vh: 50, tx: 0, ty: 0, sc: 1,
angleDeg: 0, cx: 50, cy: 25, k: 1, ox: 0, oy: 0);
final path1 = await CropSaveService.saveCrop(srcPath: f1, source: await CropSaveService.load(f1), vp: p1);
final r1 = img.decodeImage(File(path1).readAsBytesSync())!;
print('1) dims ${r1.width}x${r1.height} (100x50 beklenir)');
if (r1.width != 100 || r1.height != 50 || !path1.endsWith('.jpg')) fails++;
checkPix('1) sol-ust', imgPx(r1, 2, 2), 255, 0, 0);
checkPix('1) sag-alt', imgPx(r1, 97, 47), 255, 255, 0);
// 2) 90 derece saat yelkolevi: cikti 50x100, orij sol-alt -> yeni sol-ust
final out2 = '${tmp.path}/b';
Directory(out2).createSync();
final f2 = '$out2/src.jpg';
File(f2).writeAsBytesSync(File(srcPath).readAsBytesSync());
final p2 = CropViewParams(w: 100, h: 50, vw: 50, vh: 100, tx: 0, ty: 0, sc: 1,
angleDeg: 90, cx: 25, cy: 50, k: 1, ox: -25, oy: 25);
final path2 = await CropSaveService.saveCrop(srcPath: f2, source: await CropSaveService.load(f2), vp: p2);
final r2 = img.decodeImage(File(path2).readAsBytesSync())!;
print('2) dims ${r2.width}x${r2.height} (50x100 beklenir)');
if (r2.width != 50 || r2.height != 100) fails++;
checkPix('2) sol-ust (orij sol-alt MAVI)', imgPx(r2, 2, 2), 0, 0, 255);
checkPix('2) sag-ust (orij sol-ust KIRMIZI)', imgPx(r2, 47, 2), 255, 0, 0);
checkPix('2) sol-alt (orij sag-alt SARI)', imgPx(r2, 2, 97), 255, 255, 0);
checkPix('2) sag-alt (orij sag-ust YESIL)', imgPx(r2, 47, 97), 0, 255, 0);
// 3) Zoom x2, 45 derece, fill: orneklenen alan ortada
final out3 = '${tmp.path}/c';
Directory(out3).createSync();
final f3 = '$out3/src.jpg';
File(f3).writeAsBytesSync(File(srcPath).readAsBytesSync());
final p3 = CropViewParams(w: 100, h: 50, vw: 100, vh: 100, tx: 0, ty: 0, sc: 2,
angleDeg: 45, cx: 50, cy: 50, k: 2, ox: 0, oy: -25);
final path3 = await CropSaveService.saveCrop(srcPath: f3, source: await CropSaveService.load(f3), vp: p3);
final r3 = img.decodeImage(File(path3).readAsBytesSync())!;
print('3) dims ${r3.width}x${r3.height} (25x25 beklenir: zoom x2 + fill k=2 -> 4 src px/ekran px)');
if (r3.width != 25 || r3.height != 25) fails++;
// 4) Yatay aynalama (flipH): gorunum aynalanir, cikti da aynali olmali
final out4 = '${tmp.path}/d';
Directory(out4).createSync();
final f4 = '$out4/src.jpg';
File(f4).writeAsBytesSync(File(srcPath).readAsBytesSync());
final p4 = CropViewParams(w: 100, h: 50, vw: 100, vh: 50, tx: 0, ty: 0, sc: 1,
angleDeg: 0, flipH: true, cx: 50, cy: 25, k: 1, ox: 0, oy: 0);
final path4 = await CropSaveService.saveCrop(srcPath: f4, source: await CropSaveService.load(f4), vp: p4);
final r4 = img.decodeImage(File(path4).readAsBytesSync())!;
print('4) dims ${r4.width}x${r4.height} (100x50 beklenir)');
if (r4.width != 100 || r4.height != 50) fails++;
checkPix('4) sol-ust (orij sag-ust YESIL)', imgPx(r4, 2, 2), 0, 255, 0);
checkPix('4) sag-ust (orij sol-ust KIRMIZI)', imgPx(r4, 97, 2), 255, 0, 0);
checkPix('4) sol-alt (orij sag-alt SARI)', imgPx(r4, 2, 47), 255, 255, 0);
checkPix('4) sag-alt (orij sol-alt MAVI)', imgPx(r4, 97, 47), 0, 0, 255);
// 5) Donus + aynalama: 2. senaryonun aynasi
final out5 = '${tmp.path}/e';
Directory(out5).createSync();
final f5 = '$out5/src.jpg';
File(f5).writeAsBytesSync(File(srcPath).readAsBytesSync());
final p5 = CropViewParams(w: 100, h: 50, vw: 50, vh: 100, tx: 0, ty: 0, sc: 1,
angleDeg: 90, flipH: true, cx: 25, cy: 50, k: 1, ox: -25, oy: 25);
final path5 = await CropSaveService.saveCrop(srcPath: f5, source: await CropSaveService.load(f5), vp: p5);
final r5 = img.decodeImage(File(path5).readAsBytesSync())!;
print('5) dims ${r5.width}x${r5.height} (50x100 beklenir)');
if (r5.width != 50 || r5.height != 100) fails++;
checkPix('5) sol-ust (orij sol-ust KIRMIZI)', imgPx(r5, 2, 2), 255, 0, 0);
checkPix('5) sag-ust (orij sol-alt MAVI)', imgPx(r5, 47, 2), 0, 0, 255);
checkPix('5) sol-alt (orij sag-ust YESIL)', imgPx(r5, 2, 97), 0, 255, 0);
checkPix('5) sag-alt (orij sag-alt SARI)', imgPx(r5, 47, 97), 255, 255, 0);
tmp.deleteSync(recursive: true);
print(fails == 0 ? 'ALL PASS' : '$fails FAILURES');
exitCode = fails == 0 ? 0 : 1;
}
// jpeg kaybi oldugu icin 5x5 bolge ortalamasi al
(int, int, int) imgPx(img.Image im, int x, int y) {
int avg(int f) {
var s = 0, n = 0;
for (var dy = -2; dy <= 2; dy++)
for (var dx = -2; dx <= 2; dx++) {
final px = im.getPixelClamped(x + dx, y + dy);
s += f == 0 ? px.r.toInt() : (f == 1 ? px.g.toInt() : px.b.toInt());
n++;
}
return s ~/ n;
}
return (avg(0), avg(1), avg(2));
}