20 Commits
Author SHA1 Message Date
Alhan ae0a32543c chore: rebuild v1.9.2 .deb with updated deps, update pubspec.lock
Build & Release / build-linux (push) Canceled after 0s
Build & Release / build-windows (push) Canceled after 0s
Build & Release / create-release (push) Canceled after 0s
- Rebuilt dist/imajviewer_1.9.2_amd64.deb (10.7M) with current dependency set
- Updated pubspec.lock (intl, matcher, meta version changes)
- v1.9.2 release already published on Gitea (id 5, tag v1.9.2)
- No source code changes, dependency rebuild only
2026-11-10 07:59:12 +03:00
Alhan 96dc8c67c1 chore: dist swap — track 1.9.2 .deb, untrack 1.9.0 (still on Gitea v1.9.0 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 08:23:51 +03:00
Alhan 701eafa744 feat: tabbed settings dialog (pill tabs, full-bleed panel), hover color setting, title bar grab cursor (v1.9.2) 2026-10-31 08:23:49 +03:00
Alhan 1a6de71ff6 chore: dist swap — track 1.9.0 .deb, untrack 1.8.0 (still on Gitea v1.8.0 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 04:16:20 +03:00
Alhan a5969cf28f feat: taskbar title shows file name, icon sizes 16-256, settings order, fixed 4-col size grid (v1.9.0) 2026-10-31 04:16:19 +03:00
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
33 changed files with 1828 additions and 221 deletions

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ app.*.map.json
*.deb
*.tar.gz
*.exe
!dist/imajviewer_1.5.0_amd64.deb
!dist/imajviewer_1.9.2_amd64.deb
!dist/imajviewer_setup_1.1.0.exe
deb/usr/lib/imajviewer/
release/
+123
View File
@@ -4,6 +4,129 @@ All notable changes to imajViewer are documented in this file.
## [Unreleased]
## [1.9.2] - 2026-10-31
### Changed
- Settings dialog is now tabbed (General / Size / Shortcuts) instead of one
long scrolling column; settings dialog panel has no outer margin, no rounded
corners and fills the window on small sizes — separated from the canvas by a
thin border.
- Tab bar spans the panel width; size grid cells stretch to fill the tab.
- Size grid: top-left cell is the `H:W` axis label; cells highlight on hover
with a pointer cursor.
- Sort labels are single words (Time / Name) so they never wrap on narrow
windows; section titles are bold.
- Shortcut key column uses the UI font (monospace removed) and is narrower.
- Footer tagline: "Simple, clean, light, fast image viewer."
- Language row (English / Türkçe / Français) added to the General tab as a
disabled placeholder — switching is not wired yet.
- Title bar: crop-save icon moved from the window controls to the left group,
right next to the settings gear.
- Hover highlight color is selectable in Settings > General (8-color palette,
default red) and applies to title bar buttons, navigation arrows and size
grid cells; stored as `hoverColor` in `window.json`.
- Title bar drag area: grab cursor and a faint hover tint while the pointer is
on the bar (buttons keep the click cursor and their own highlight).
## [1.9.1] - 2026-10-31
### Changed
- Settings dialog: compact single top row — "Settings" title on the left,
print and close as icon buttons on the right (bottom action row removed).
- Settings dialog facelift: capped 520×560 centered dialog, M3 section cards,
segmented sort control, two-column shortcuts, fixed footer, size grid
cells 52×28.
## [1.9.0] - 2026-10-31
### Added
- Taskbar/alt-tab title now shows the opened image's file name (falls back to
"imajViewer" when no image is open).
- Full hicolor icon size set (16–256 px) instead of only 64 px, so small-icon
contexts (Thunar "Open With", Xfce app menu) keep the symbol visible.
### Changed
- `install.sh` generates the full icon size set and rewrites the hicolor
index; uninstall hint updated.
- Settings size grid is now a fixed 4-column layout (the wide/narrow
transposition introduced in this release was removed).
## [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
+25 -14
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,20 +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 93) next to the source file (`photo.jpg` →
`photo_143025.jpg`); active effects (contrast/saturation/brightness) and rotation are baked
into the output; if that name already exists, nothing is written and a warning is shown
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
@@ -37,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
@@ -49,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
@@ -80,23 +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 + S | Save visible area (JPEG 93, original resolution) |
| 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+30 -28
View File
@@ -11,8 +11,8 @@ fi
INSTALL_DIR="${HOME}/.local/lib/${APP}"
BIN_LINK="${HOME}/.local/bin/${APP}"
DESKTOP_FILE="${HOME}/.local/share/applications/${APP}.desktop"
ICON_DIR="${HOME}/.local/share/icons/hicolor/64x64/apps"
ICON_FILE="${ICON_DIR}/${APP}.png"
ICON_BASE="${HOME}/.local/share/icons/hicolor"
ICON_SIZES="16 22 24 32 48 64 128 256"
# ── Renkler ──
RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'
@@ -36,7 +36,7 @@ if [ ! -f "$BUNDLE_DIR/${APP}" ]; then
fi
# ── Kurulum dizinleri ──
mkdir -p "$INSTALL_DIR" "$(dirname "$BIN_LINK")" "$(dirname "$DESKTOP_FILE")" "$ICON_DIR"
mkdir -p "$INSTALL_DIR" "$(dirname "$BIN_LINK")" "$(dirname "$DESKTOP_FILE")"
# ── Binary + kütüphaneler ──
info "Binary ve kütüphaneler kopyalanıyor…"
@@ -47,37 +47,39 @@ chmod +x "$INSTALL_DIR/${APP}"
ln -sf "$INSTALL_DIR/${APP}" "$BIN_LINK"
ok "Symlink: $BIN_LINK → $INSTALL_DIR/${APP}"
# ── İkon (64×64 PNG) ──
if [ ! -f "$ICON_FILE" ]; then
if [ -f "$SCRIPT_DIR/imajviewericon.png" ]; then
info "İkon kopyalanıyor (64x64)…"
# ── İkon (hicolor boyut seti: 16–256) ──
if [ -f "$SCRIPT_DIR/imajviewericon.png" ]; then
info "İkon boyutları üretiliyor (16–256)…"
for s in $ICON_SIZES; do
d="${ICON_BASE}/${s}x${s}/apps"
mkdir -p "$d"
python3 -c "
from PIL import Image
im = Image.open('$SCRIPT_DIR/imajviewericon.png')
im.resize((64,64), Image.LANCZOS).save('$ICON_FILE')
" && ok "İkon: $ICON_FILE" || err "İkon dönüştürme başarısız!"
else
err "imajviewericon.png bulunamadı!"
fi
(im if $s == 256 else im.resize(($s, $s), Image.LANCZOS)).save('$d/${APP}.png')
" || err "İkon dönüştürme başarısız! ($s)"
done
ok "İkon: ${ICON_BASE} (${ICON_SIZES} px)"
else
ok "İkon zaten mevcut"
err "imajviewericon.png bulunamadı!"
fi
# ── Hicolor tema indeksi (GTK ikon temasını tanısın) ──
if [ ! -f "${HOME}/.local/share/icons/hicolor/index.theme" ]; then
mkdir -p "${HOME}/.local/share/icons/hicolor/64x64/apps"
cat > "${HOME}/.local/share/icons/hicolor/index.theme" << 'EOF'
[Icon Theme]
Name=Hicolor
Comment=Fallback icon theme
Directories=64x64/apps
[64x64/apps]
Size=64
Context=Applications
Type=Fixed
EOF
fi
{
echo "[Icon Theme]"
echo "Name=Hicolor"
echo "Comment=Fallback icon theme"
dirs=""
for s in $ICON_SIZES; do dirs="${dirs:+$dirs,}${s}x${s}/apps"; done
echo "Directories=$dirs"
for s in $ICON_SIZES; do
echo ""
echo "[${s}x${s}/apps]"
echo "Size=$s"
echo "Context=Applications"
echo "Type=Fixed"
done
} > "${ICON_BASE}/index.theme"
# ── .desktop dosyası ──
info ".desktop dosyası oluşturuluyor…"
@@ -113,4 +115,4 @@ echo -e "${GREEN} Bir PNG/JPG/WebP dosyasına çift tıklayın${NC}"
echo -e "${GREEN} veya 'xdg-open <dosya>' ile test edin${NC}"
echo -e "${GREEN}══════════════════════════════════════════${NC}"
echo ""
echo "Kaldırmak için: rm -rf ${INSTALL_DIR} ${BIN_LINK} ${DESKTOP_FILE} ${ICON_FILE}"
echo "Kaldırmak için: rm -rf \"$INSTALL_DIR\" \"$BIN_LINK\" \"$DESKTOP_FILE\" && rm -f ${ICON_BASE}/*x*/apps/${APP}.png"
+48 -5
View File
@@ -1,15 +1,58 @@
{
"@@locale": "en",
"settingsTitle": "Settings",
"tabGeneral": "General",
"language": "Language",
"sortBy": "Sort by",
"sortByTime": "By time",
"sortByName": "By name",
"appDescription": "Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.",
"hoverColor": "Hover color",
"sortByTime": "Time",
"sortByName": "Name",
"sizeSection": "Size",
"appDescription": "Simple, clean, light, fast image viewer.",
"shortcuts": "Shortcuts",
"shortcutsList": "• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\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",
"scNavKey": "Left/Right arrow",
"scNavAction": "previous/next image",
"scOpenKey": "Ctrl+O",
"scOpenAction": "open file",
"scSaveKey": "Ctrl+S",
"scSaveAction": "save visible area (JPEG 95)",
"scFlipKey": "Ctrl+R",
"scFlipAction": "flip horizontally (clears effects, press again to undo)",
"scCloseKey": "Ctrl+Q",
"scCloseAction": "close window",
"scCloseAllKey": "Ctrl+Shift+Q",
"scCloseAllAction": "close all windows",
"scFullscreenKey": "F11",
"scFullscreenAction": "toggle fullscreen",
"scExitFullscreenKey": "Esc",
"scExitFullscreenAction": "exit fullscreen",
"scZoomKey": "Scroll",
"scZoomAction": "zoom (10% per step)",
"scFineZoomKey": "Ctrl+scroll",
"scFineZoomAction": "fine zoom (1% per step)",
"scPanKey": "Left-drag",
"scPanAction": "pan",
"scFillKey": "Double click",
"scFillAction": "toggle fill/fit",
"scResetKey": "Right double click",
"scResetAction": "reset effects",
"scContrastKey": "Right-drag",
"scContrastAction": "contrast",
"scRotateKey": "Shift+right-drag",
"scRotateAction": "rotate",
"scSaturationKey": "Ctrl+right-drag",
"scSaturationAction": "saturation",
"scBrightnessKey": "Ctrl+Shift+right-drag",
"scBrightnessAction": "brightness",
"print": "Print",
"fileProperties": "File properties",
"propResolution": "Resolution",
"propFileSize": "File size",
"propFormat": "Format",
"propModified": "Modified",
"propPath": "Path",
"close": "Close",
"saveCrop": "Save visible area (JPEG 93)",
"saveCrop": "Save visible area (JPEG 95)",
"cropSaved": "Saved: {path}",
"@cropSaved": {
"placeholders": {
+265 -7
View File
@@ -104,28 +104,52 @@ abstract class AppLocalizations {
/// **'Settings'**
String get settingsTitle;
/// No description provided for @tabGeneral.
///
/// In en, this message translates to:
/// **'General'**
String get tabGeneral;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @sortBy.
///
/// In en, this message translates to:
/// **'Sort by'**
String get sortBy;
/// No description provided for @hoverColor.
///
/// In en, this message translates to:
/// **'Hover color'**
String get hoverColor;
/// No description provided for @sortByTime.
///
/// In en, this message translates to:
/// **'By time'**
/// **'Time'**
String get sortByTime;
/// No description provided for @sortByName.
///
/// In en, this message translates to:
/// **'By name'**
/// **'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:
/// **'Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.'**
/// **'Simple, clean, light, fast image viewer.'**
String get appDescription;
/// No description provided for @shortcuts.
@@ -134,11 +158,209 @@ abstract class AppLocalizations {
/// **'Shortcuts'**
String get shortcuts;
/// No description provided for @shortcutsList.
/// No description provided for @scNavKey.
///
/// In en, this message translates to:
/// **'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\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'**
String get shortcutsList;
/// **'Left/Right arrow'**
String get scNavKey;
/// No description provided for @scNavAction.
///
/// In en, this message translates to:
/// **'previous/next image'**
String get scNavAction;
/// No description provided for @scOpenKey.
///
/// In en, this message translates to:
/// **'Ctrl+O'**
String get scOpenKey;
/// No description provided for @scOpenAction.
///
/// In en, this message translates to:
/// **'open file'**
String get scOpenAction;
/// No description provided for @scSaveKey.
///
/// In en, this message translates to:
/// **'Ctrl+S'**
String get scSaveKey;
/// No description provided for @scSaveAction.
///
/// In en, this message translates to:
/// **'save visible area (JPEG 95)'**
String get scSaveAction;
/// No description provided for @scFlipKey.
///
/// In en, this message translates to:
/// **'Ctrl+R'**
String get scFlipKey;
/// No description provided for @scFlipAction.
///
/// In en, this message translates to:
/// **'flip horizontally (clears effects, press again to undo)'**
String get scFlipAction;
/// No description provided for @scCloseKey.
///
/// In en, this message translates to:
/// **'Ctrl+Q'**
String get scCloseKey;
/// No description provided for @scCloseAction.
///
/// In en, this message translates to:
/// **'close window'**
String get scCloseAction;
/// No description provided for @scCloseAllKey.
///
/// In en, this message translates to:
/// **'Ctrl+Shift+Q'**
String get scCloseAllKey;
/// No description provided for @scCloseAllAction.
///
/// In en, this message translates to:
/// **'close all windows'**
String get scCloseAllAction;
/// No description provided for @scFullscreenKey.
///
/// In en, this message translates to:
/// **'F11'**
String get scFullscreenKey;
/// No description provided for @scFullscreenAction.
///
/// In en, this message translates to:
/// **'toggle fullscreen'**
String get scFullscreenAction;
/// No description provided for @scExitFullscreenKey.
///
/// In en, this message translates to:
/// **'Esc'**
String get scExitFullscreenKey;
/// No description provided for @scExitFullscreenAction.
///
/// In en, this message translates to:
/// **'exit fullscreen'**
String get scExitFullscreenAction;
/// No description provided for @scZoomKey.
///
/// In en, this message translates to:
/// **'Scroll'**
String get scZoomKey;
/// No description provided for @scZoomAction.
///
/// In en, this message translates to:
/// **'zoom (10% per step)'**
String get scZoomAction;
/// No description provided for @scFineZoomKey.
///
/// In en, this message translates to:
/// **'Ctrl+scroll'**
String get scFineZoomKey;
/// No description provided for @scFineZoomAction.
///
/// In en, this message translates to:
/// **'fine zoom (1% per step)'**
String get scFineZoomAction;
/// No description provided for @scPanKey.
///
/// In en, this message translates to:
/// **'Left-drag'**
String get scPanKey;
/// No description provided for @scPanAction.
///
/// In en, this message translates to:
/// **'pan'**
String get scPanAction;
/// No description provided for @scFillKey.
///
/// In en, this message translates to:
/// **'Double click'**
String get scFillKey;
/// No description provided for @scFillAction.
///
/// In en, this message translates to:
/// **'toggle fill/fit'**
String get scFillAction;
/// No description provided for @scResetKey.
///
/// In en, this message translates to:
/// **'Right double click'**
String get scResetKey;
/// No description provided for @scResetAction.
///
/// In en, this message translates to:
/// **'reset effects'**
String get scResetAction;
/// No description provided for @scContrastKey.
///
/// In en, this message translates to:
/// **'Right-drag'**
String get scContrastKey;
/// No description provided for @scContrastAction.
///
/// In en, this message translates to:
/// **'contrast'**
String get scContrastAction;
/// No description provided for @scRotateKey.
///
/// In en, this message translates to:
/// **'Shift+right-drag'**
String get scRotateKey;
/// No description provided for @scRotateAction.
///
/// In en, this message translates to:
/// **'rotate'**
String get scRotateAction;
/// No description provided for @scSaturationKey.
///
/// In en, this message translates to:
/// **'Ctrl+right-drag'**
String get scSaturationKey;
/// No description provided for @scSaturationAction.
///
/// In en, this message translates to:
/// **'saturation'**
String get scSaturationAction;
/// No description provided for @scBrightnessKey.
///
/// In en, this message translates to:
/// **'Ctrl+Shift+right-drag'**
String get scBrightnessKey;
/// No description provided for @scBrightnessAction.
///
/// In en, this message translates to:
/// **'brightness'**
String get scBrightnessAction;
/// No description provided for @print.
///
@@ -146,6 +368,42 @@ 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:
@@ -155,7 +413,7 @@ abstract class AppLocalizations {
/// No description provided for @saveCrop.
///
/// In en, this message translates to:
/// **'Save visible area (JPEG 93)'**
/// **'Save visible area (JPEG 95)'**
String get saveCrop;
/// No description provided for @cropSaved.
+135 -7
View File
@@ -11,34 +11,162 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsTitle => 'Settings';
@override
String get tabGeneral => 'General';
@override
String get language => 'Language';
@override
String get sortBy => 'Sort by';
@override
String get sortByTime => 'By time';
String get hoverColor => 'Hover color';
@override
String get sortByName => 'By name';
String get sortByTime => 'Time';
@override
String get appDescription =>
'Ultra-lightweight and fast image viewer. Open images and use arrows or keyboard keys to navigate.';
String get sortByName => 'Name';
@override
String get sizeSection => 'Size';
@override
String get appDescription => 'Simple, clean, light, fast image viewer.';
@override
String get shortcuts => 'Shortcuts';
@override
String get shortcutsList =>
'• Left/Right arrow — previous/next image\n• Ctrl+S — save visible area (JPEG 93)\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';
String get scNavKey => 'Left/Right arrow';
@override
String get scNavAction => 'previous/next image';
@override
String get scOpenKey => 'Ctrl+O';
@override
String get scOpenAction => 'open file';
@override
String get scSaveKey => 'Ctrl+S';
@override
String get scSaveAction => 'save visible area (JPEG 95)';
@override
String get scFlipKey => 'Ctrl+R';
@override
String get scFlipAction =>
'flip horizontally (clears effects, press again to undo)';
@override
String get scCloseKey => 'Ctrl+Q';
@override
String get scCloseAction => 'close window';
@override
String get scCloseAllKey => 'Ctrl+Shift+Q';
@override
String get scCloseAllAction => 'close all windows';
@override
String get scFullscreenKey => 'F11';
@override
String get scFullscreenAction => 'toggle fullscreen';
@override
String get scExitFullscreenKey => 'Esc';
@override
String get scExitFullscreenAction => 'exit fullscreen';
@override
String get scZoomKey => 'Scroll';
@override
String get scZoomAction => 'zoom (10% per step)';
@override
String get scFineZoomKey => 'Ctrl+scroll';
@override
String get scFineZoomAction => 'fine zoom (1% per step)';
@override
String get scPanKey => 'Left-drag';
@override
String get scPanAction => 'pan';
@override
String get scFillKey => 'Double click';
@override
String get scFillAction => 'toggle fill/fit';
@override
String get scResetKey => 'Right double click';
@override
String get scResetAction => 'reset effects';
@override
String get scContrastKey => 'Right-drag';
@override
String get scContrastAction => 'contrast';
@override
String get scRotateKey => 'Shift+right-drag';
@override
String get scRotateAction => 'rotate';
@override
String get scSaturationKey => 'Ctrl+right-drag';
@override
String get scSaturationAction => 'saturation';
@override
String get scBrightnessKey => 'Ctrl+Shift+right-drag';
@override
String get scBrightnessAction => '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 93)';
String get saveCrop => 'Save visible area (JPEG 95)';
@override
String cropSaved(String path) {
+135 -6
View File
@@ -11,34 +11,163 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get settingsTitle => 'Ayarlar';
@override
String get tabGeneral => 'Genel';
@override
String get language => 'Dil';
@override
String get sortBy => 'Sıralama';
@override
String get sortByTime => 'Zamana göre';
String get hoverColor => 'Hover rengi';
@override
String get sortByName => 'İsme göre';
String get sortByTime => 'Zaman';
@override
String get sortByName => 'İsim';
@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.';
'Basit, temiz, hafif, hızlı resim görüntüleyici.';
@override
String get shortcuts => 'Kısayollar';
@override
String get shortcutsList =>
'• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\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';
String get scNavKey => 'Sol/Sağ ok';
@override
String get scNavAction => 'önceki/sonraki resim';
@override
String get scOpenKey => 'Ctrl+O';
@override
String get scOpenAction => 'dosya aç';
@override
String get scSaveKey => 'Ctrl+S';
@override
String get scSaveAction => 'görünen alanı kaydet (JPEG 95)';
@override
String get scFlipKey => 'Ctrl+R';
@override
String get scFlipAction =>
'yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)';
@override
String get scCloseKey => 'Ctrl+Q';
@override
String get scCloseAction => 'pencereyi kapat';
@override
String get scCloseAllKey => 'Ctrl+Shift+Q';
@override
String get scCloseAllAction => 'tüm pencereleri kapat';
@override
String get scFullscreenKey => 'F11';
@override
String get scFullscreenAction => 'tam ekran / normal geçişi';
@override
String get scExitFullscreenKey => 'Esc';
@override
String get scExitFullscreenAction => 'tam ekrandan çık';
@override
String get scZoomKey => 'Kaydırma';
@override
String get scZoomAction => 'yakınlaştır (tık başına %10)';
@override
String get scFineZoomKey => 'Ctrl+kaydırma';
@override
String get scFineZoomAction => 'ince yakınlaştırma (tık başına %1)';
@override
String get scPanKey => 'Sol tık sürükle';
@override
String get scPanAction => 'kaydır';
@override
String get scFillKey => 'Çift tık';
@override
String get scFillAction => 'doldur/sığdır geçişi';
@override
String get scResetKey => 'Sağ çift tık';
@override
String get scResetAction => 'efektleri sıfırla';
@override
String get scContrastKey => 'Sağ tık sürükle';
@override
String get scContrastAction => 'kontrast';
@override
String get scRotateKey => 'Shift+sağ tık';
@override
String get scRotateAction => 'döndür';
@override
String get scSaturationKey => 'Ctrl+sağ tık';
@override
String get scSaturationAction => 'doygunluk';
@override
String get scBrightnessKey => 'Ctrl+Shift+sağ tık';
@override
String get scBrightnessAction => '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 93)';
String get saveCrop => 'Görünen alanı kaydet (JPEG 95)';
@override
String cropSaved(String path) {
+48 -5
View File
@@ -1,15 +1,58 @@
{
"@@locale": "tr",
"settingsTitle": "Ayarlar",
"tabGeneral": "Genel",
"language": "Dil",
"sortBy": "Sıralama",
"sortByTime": "Zamana göre",
"sortByName": "İsme göre",
"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.",
"hoverColor": "Hover rengi",
"sortByTime": "Zaman",
"sortByName": "İsim",
"sizeSection": "Boyut",
"appDescription": "Basit, temiz, hafif, hızlı resim görüntüleyici.",
"shortcuts": "Kısayollar",
"shortcutsList": "• Sol/Sağ ok — önceki/sonraki resim\n• Ctrl+S — görünen alanı kaydet (JPEG 93)\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",
"scNavKey": "Sol/Sağ ok",
"scNavAction": "önceki/sonraki resim",
"scOpenKey": "Ctrl+O",
"scOpenAction": "dosya aç",
"scSaveKey": "Ctrl+S",
"scSaveAction": "görünen alanı kaydet (JPEG 95)",
"scFlipKey": "Ctrl+R",
"scFlipAction": "yatay aynala (efektleri sıfırlar, tekrar basınca geri alır)",
"scCloseKey": "Ctrl+Q",
"scCloseAction": "pencereyi kapat",
"scCloseAllKey": "Ctrl+Shift+Q",
"scCloseAllAction": "tüm pencereleri kapat",
"scFullscreenKey": "F11",
"scFullscreenAction": "tam ekran / normal geçişi",
"scExitFullscreenKey": "Esc",
"scExitFullscreenAction": "tam ekrandan çık",
"scZoomKey": "Kaydırma",
"scZoomAction": "yakınlaştır (tık başına %10)",
"scFineZoomKey": "Ctrl+kaydırma",
"scFineZoomAction": "ince yakınlaştırma (tık başına %1)",
"scPanKey": "Sol tık sürükle",
"scPanAction": "kaydır",
"scFillKey": "Çift tık",
"scFillAction": "doldur/sığdır geçişi",
"scResetKey": "Sağ çift tık",
"scResetAction": "efektleri sıfırla",
"scContrastKey": "Sağ tık sürükle",
"scContrastAction": "kontrast",
"scRotateKey": "Shift+sağ tık",
"scRotateAction": "döndür",
"scSaturationKey": "Ctrl+sağ tık",
"scSaturationAction": "doygunluk",
"scBrightnessKey": "Ctrl+Shift+sağ tık",
"scBrightnessAction": "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 93)",
"saveCrop": "Görünen alanı kaydet (JPEG 95)",
"cropSaved": "Kaydedildi: {path}",
"@cropSaved": {
"placeholders": {
+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,
);
+431 -75
View File
@@ -1,21 +1,28 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:window_manager/window_manager.dart';
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 '../services/hover_color.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.9.2';
class ViewerScreen extends StatefulWidget {
final List<String> initialFiles;
const ViewerScreen({super.key, this.initialFiles = const []});
@@ -29,6 +36,7 @@ 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
@@ -40,7 +48,10 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
if (widget.initialFiles.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
FileHandler.loadImagesFromPaths(widget.initialFiles);
_updateTitle();
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) => _updateTitle());
}
}
@@ -49,6 +60,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
windowManager.removeListener(this);
ImageManager.instance.removeListener(_onImageManagerChanged);
_saveDebounce?.cancel();
_zoomPercent.dispose();
super.dispose();
}
@@ -79,6 +91,18 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
void _onImageManagerChanged() {
setState(() => _hasImages = ImageManager.instance.images.isNotEmpty);
_updateTitle();
}
/// Taskbar/alt-tab başlığı = açılan dosyanın adı (görsel yoksa uygulama adı).
Future<void> _updateTitle() async {
final image = ImageManager.instance.currentImagePath;
final name = image.isEmpty ? 'imajViewer' : p.basename(image);
try {
await windowManager.setTitle(name);
} catch (_) {
// Başlık güncellenemezse sessizce geç.
}
}
Future<void> _doSave() async {
@@ -92,83 +116,327 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
}
}
static const double _shortcutKeyColumnWidth = 126;
int _settingsTab = 0;
void _showSettings() {
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
backgroundColor: const Color(0xFF2a2a2a),
title: Text(AppLocalizations.of(context)!.settingsTitle,
style: const TextStyle(color: Colors.white, fontSize: 18)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context)!.sortBy,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
RadioListTile<bool>(
value: false,
groupValue: ImageManager.instance.sortByName,
dense: true,
activeColor: Colors.blueAccent,
title: Text(AppLocalizations.of(context)!.sortByTime,
style: const TextStyle(color: Colors.white, fontSize: 14)),
onChanged: (v) {
ImageManager.instance.setSortByName(v!);
setDialogState(() {});
},
builder: (context, setDialogState) {
final l10n = AppLocalizations.of(context)!;
return Dialog(
insetPadding: EdgeInsets.zero,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
child: LayoutBuilder(
builder: (context, c) => Container(
width: math.min(c.maxWidth, 520),
height: math.min(c.maxHeight, 560),
padding: const EdgeInsets.fromLTRB(14, 4, 14, 10),
decoration: BoxDecoration(
color: const Color(0xFF2a2a2a),
border: Border.all(color: Colors.white12),
),
RadioListTile<bool>(
value: true,
groupValue: ImageManager.instance.sortByName,
dense: true,
activeColor: Colors.blueAccent,
title: Text(AppLocalizations.of(context)!.sortByName,
style: const TextStyle(color: Colors.white, fontSize: 14)),
onChanged: (v) {
ImageManager.instance.setSortByName(v!);
setDialogState(() {});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(l10n.settingsTitle,
style: const TextStyle(color: Colors.white, fontSize: 18)),
const Spacer(),
IconButton(
tooltip: l10n.print,
icon: const Icon(Icons.print, color: Colors.white70, size: 18),
onPressed: () {
Navigator.pop(context);
_printImage();
},
),
IconButton(
tooltip: l10n.close,
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 4),
Align(
alignment: Alignment.center,
child: SegmentedButton<int>(
expandedInsets: EdgeInsets.zero,
segments: [
ButtonSegment(
value: 0,
label: Text(l10n.tabGeneral,
style: const TextStyle(fontSize: 13)),
),
ButtonSegment(
value: 1,
label: Text(l10n.sizeSection,
style: const TextStyle(fontSize: 13)),
),
ButtonSegment(
value: 2,
label: Text(l10n.shortcuts,
style: const TextStyle(fontSize: 13)),
),
],
selected: {_settingsTab},
showSelectedIcon: false,
style: _segmentedStyle,
onSelectionChanged: (selection) =>
setDialogState(() => _settingsTab = selection.first),
),
),
const SizedBox(height: 10),
Expanded(
child: _settingsTabBody(l10n, setDialogState),
),
const Divider(color: Colors.white10, height: 12),
Row(
children: [
Text('imajViewer $appVersion',
style: const TextStyle(color: Colors.white54, fontSize: 11)),
const SizedBox(width: 16),
Expanded(
child: Text(l10n.appDescription,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
),
],
),
],
),
const Divider(color: Colors.white24, height: 24),
const Text('imajViewer', style: TextStyle(color: Colors.white, fontSize: 15)),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.appDescription,
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.5),
),
const SizedBox(height: 12),
Text(AppLocalizations.of(context)!.shortcuts,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)!.shortcutsList,
style: const TextStyle(color: Colors.white70, fontSize: 13, height: 1.6),
),
],
),
),
),
actions: [
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_printImage();
},
icon: const Icon(Icons.print, color: Colors.white70, size: 18),
label: Text(AppLocalizations.of(context)!.print,
style: const TextStyle(color: Colors.white70)),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close, style: const TextStyle(color: Colors.white70)),
),
],
),
);
},
),
);
}
Widget _settingsTabBody(AppLocalizations l10n, StateSetter setDialogState) {
switch (_settingsTab) {
case 1:
return WindowSizeSelector(workArea: _workArea, onPick: _applyWindowSize);
case 2:
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (key, action) in _shortcutRows(l10n))
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _shortcutKeyColumnWidth,
child: Text(key,
style: const TextStyle(fontSize: 12, color: Colors.white)),
),
const SizedBox(width: 8),
Expanded(
child: Text(action,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
),
],
),
),
],
),
);
default:
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_hasImages) ...[
_settingsCard(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.fileProperties),
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),
],
_settingsCard(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.sortBy),
const SizedBox(height: 4),
SegmentedButton<bool>(
segments: [
ButtonSegment(
value: false,
label: Text(l10n.sortByTime, style: const TextStyle(fontSize: 13)),
),
ButtonSegment(
value: true,
label: Text(l10n.sortByName, style: const TextStyle(fontSize: 13)),
),
],
selected: {ImageManager.instance.sortByName},
showSelectedIcon: false,
style: _segmentedStyle,
onSelectionChanged: (selection) {
ImageManager.instance.setSortByName(selection.first);
setDialogState(() {});
},
),
],
),
),
const SizedBox(height: 12),
_settingsCard(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.hoverColor),
const SizedBox(height: 8),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final color in HoverColor.palette)
GestureDetector(
onTap: () {
HoverColor.set(color);
setDialogState(() {});
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: HoverColor.notifier.value.toARGB32() ==
color.toARGB32()
? Colors.white
: Colors.white24,
width: 2,
),
),
),
),
),
],
),
],
),
),
const SizedBox(height: 12),
_settingsCard(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.language),
const SizedBox(height: 6),
SegmentedButton<String>(
segments: const [
ButtonSegment(
value: 'en',
label: Text('English', style: TextStyle(fontSize: 13))),
ButtonSegment(
value: 'tr',
label: Text('Türkçe', style: TextStyle(fontSize: 13))),
ButtonSegment(
value: 'fr',
label: Text('Français', style: TextStyle(fontSize: 13))),
],
selected: const {'tr'},
showSelectedIcon: false,
),
],
),
),
],
),
);
}
}
Widget _sectionTitle(String text) {
return Text(text,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600));
}
ButtonStyle get _segmentedStyle => ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? Colors.blueAccent.withValues(alpha: 0.25)
: Colors.white10,
),
foregroundColor: const WidgetStatePropertyAll(Colors.white),
side: const WidgetStatePropertyAll(BorderSide(color: Colors.white24)),
);
Widget _settingsCard(Widget child) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: child,
);
}
List<(String, String)> _shortcutRows(AppLocalizations l10n) {
return [
(l10n.scNavKey, l10n.scNavAction),
(l10n.scOpenKey, l10n.scOpenAction),
(l10n.scSaveKey, l10n.scSaveAction),
(l10n.scFlipKey, l10n.scFlipAction),
(l10n.scCloseKey, l10n.scCloseAction),
(l10n.scCloseAllKey, l10n.scCloseAllAction),
(l10n.scFullscreenKey, l10n.scFullscreenAction),
(l10n.scExitFullscreenKey, l10n.scExitFullscreenAction),
(l10n.scZoomKey, l10n.scZoomAction),
(l10n.scFineZoomKey, l10n.scFineZoomAction),
(l10n.scPanKey, l10n.scPanAction),
(l10n.scFillKey, l10n.scFillAction),
(l10n.scResetKey, l10n.scResetAction),
(l10n.scContrastKey, l10n.scContrastAction),
(l10n.scRotateKey, l10n.scRotateAction),
(l10n.scSaturationKey, l10n.scSaturationAction),
(l10n.scBrightnessKey, l10n.scBrightnessAction),
];
}
Future<void> _printImage() async {
final path = ImageManager.instance.currentImagePath;
if (path.isEmpty) return;
@@ -190,6 +458,59 @@ 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;
@@ -237,6 +558,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
Widget build(BuildContext context) {
return AppShortcuts(
onCropSave: _hasImages ? _cropSave : null,
onFlipH: _hasImages ? _flipHorizontal : null,
child: Scaffold(
backgroundColor: Colors.transparent,
body: WindowResizeZones(
@@ -271,6 +593,7 @@ class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
? ImageCanvas(
key: _canvasKey,
filePath: ImageManager.instance.currentImagePath,
zoomPercent: _zoomPercent,
)
: Center(
child: Column(
@@ -309,6 +632,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(
@@ -359,16 +712,19 @@ class _NavButtonState extends State<_NavButton> {
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _hovered
? Colors.red.withValues(alpha: 0.8)
: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
child: ValueListenableBuilder<Color>(
valueListenable: HoverColor.notifier,
builder: (context, hover, _) => Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _hovered
? hover.withValues(alpha: 0.8)
: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: Icon(widget.icon, color: Colors.white, size: 22),
),
child: Icon(widget.icon, color: Colors.white, size: 22),
),
),
);
+5 -1
View File
@@ -17,6 +17,7 @@ class CropViewParams {
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
@@ -29,6 +30,7 @@ class CropViewParams {
required this.ty,
required this.sc,
required this.angleDeg,
this.flipH = false,
required this.cx,
required this.cy,
required this.k,
@@ -65,7 +67,7 @@ class CropSaveService {
required img.Image source,
required CropViewParams vp,
List<double>? matrix,
int jpegQuality = 93,
int jpegQuality = 95,
}) async {
final outPath = buildOutputPath(srcPath);
if (outPath == null) throw const CropSaveError('file_exists');
@@ -73,6 +75,8 @@ class CropSaveService {
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;
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'window_persistence.dart';
/// Arayüzdeki tüm hover vurgularının rengi. Ayarlar > Genel'den seçilir,
/// window.json'da kalıcıdır.
class HoverColor {
HoverColor._();
static const List<Color> palette = [
Color(0xFFF44336), // kırmızı
Color(0xFFFF9800), // turuncu
Color(0xFFFFD54F), // sarı
Color(0xFF4CAF50), // yeşil
Color(0xFF009688), // turkuaz
Color(0xFF2196F3), // mavi
Color(0xFF9C27B0), // mor
Color(0xFFE91E63), // pembe
];
static final ValueNotifier<Color> notifier =
ValueNotifier<Color>(Color(WindowPersistence.hoverColor));
static void set(Color color) {
notifier.value = color;
WindowPersistence.saveHoverColor(color.toARGB32());
}
}
+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;
}
+35
View File
@@ -25,6 +25,11 @@ class WindowPersistence {
/// Sadece modu günceller; x/y/w/h korunur.
static void saveFilled(bool value) => _write({'filled': value});
/// Tüm arayüzdeki hover vurgu rengi (ARGB). Anahtar yoksa varsayılan kırmızı.
static int get hoverColor => (_read()?['hoverColor'] as num?)?.toInt() ?? 0xFFF44336;
static void saveHoverColor(int value) => _write({'hoverColor': value});
/// Save window rect synchronously (safe to call before destroy).
static void saveSync({
required Offset position,
@@ -84,6 +89,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.
+20 -2
View File
@@ -8,8 +8,14 @@ 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, this.onCropSave});
const AppShortcuts({
super.key,
required this.child,
this.onCropSave,
this.onFlipH,
});
@override
Widget build(BuildContext context) {
@@ -44,6 +50,17 @@ class AppShortcuts extends StatelessWidget {
}
}
// 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();
@@ -51,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;
}
+71 -48
View File
@@ -1,13 +1,22 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
class CustomTitleBar extends StatelessWidget {
import '../services/hover_color.dart';
class CustomTitleBar extends StatefulWidget {
final VoidCallback? onSettings;
final VoidCallback? onSaveCrop;
final bool visible;
const CustomTitleBar({super.key, this.onSettings, this.onSaveCrop, this.visible = true});
@override
State<CustomTitleBar> createState() => _CustomTitleBarState();
}
class _CustomTitleBarState extends State<CustomTitleBar> {
bool _barHovered = false;
@override
Widget build(BuildContext context) {
return Positioned(
@@ -15,55 +24,66 @@ class CustomTitleBar extends StatelessWidget {
left: 0,
right: 0,
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
opacity: widget.visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
child: DragToMoveArea(
child: SizedBox(
height: 24,
child: Stack(
children: [
if (onSettings != null)
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_BarButton(
icon: Icons.settings,
onTap: onSettings!,
),
],
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
onEnter: (_) => setState(() => _barHovered = true),
onExit: (_) => setState(() => _barHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
color: _barHovered
? Colors.white.withValues(alpha: 0.06)
: Colors.transparent,
child: DragToMoveArea(
child: SizedBox(
height: 24,
child: Stack(
children: [
if (onSaveCrop != null)
_BarButton(
icon: Icons.save_outlined,
onTap: onSaveCrop!,
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.onSettings != null)
_BarButton(
icon: Icons.settings,
onTap: widget.onSettings!,
),
if (widget.onSaveCrop != null)
_BarButton(
icon: Icons.save_outlined,
onTap: widget.onSaveCrop!,
),
],
),
_BarButton(
icon: Icons.horizontal_rule,
onTap: () => windowManager.minimize(),
),
_BarButton(
icon: Icons.close,
onTap: () => windowManager.close(),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_BarButton(
icon: Icons.horizontal_rule,
onTap: () => windowManager.minimize(),
),
_BarButton(
icon: Icons.close,
onTap: () => windowManager.close(),
),
],
),
),
],
),
),
],
),
),
),
),
),
);
}
@@ -93,14 +113,17 @@ class _BarButtonState extends State<_BarButton> {
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
width: 32,
height: 24,
color: _hovered ? Colors.red.withValues(alpha: 0.8) : Colors.transparent,
child: Icon(
widget.icon,
color: Colors.white,
size: 14,
child: ValueListenableBuilder<Color>(
valueListenable: HoverColor.notifier,
builder: (context, hover, _) => Container(
width: 32,
height: 24,
color: _hovered ? hover.withValues(alpha: 0.8) : Colors.transparent,
child: Icon(
widget.icon,
color: Colors.white,
size: 14,
),
),
),
),
+102 -20
View File
@@ -6,11 +6,15 @@ 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();
}
@@ -26,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'
@@ -36,6 +41,11 @@ 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;
@@ -46,6 +56,44 @@ class ImageCanvasState extends State<ImageCanvas> {
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;
@@ -127,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
@@ -188,6 +238,7 @@ class ImageCanvasState extends State<ImageCanvas> {
void didUpdateWidget(ImageCanvas old) {
super.didUpdateWidget(old);
if (old.filePath != widget.filePath) {
_origSize = pixelSizeOfFile(widget.filePath);
_cachedFileImage?.evict();
_cachedProvider?.evict();
_cachedFileImage = null;
@@ -202,16 +253,28 @@ class ImageCanvasState extends State<ImageCanvas> {
void _resetView() {
_tx = 0; _ty = 0; _sc = 1.0; _isFilled = WindowPersistence.filled;
_angle = 0; _contrast = 1.0; _saturation = 1.0; _brightness = 0;
_flipH = false;
_usingFullRes = false;
_imageSize = Size.zero;
_markRenderDirty();
_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();
}
void _handleDoubleTap() => _toggleFillFit();
@@ -222,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) {
@@ -239,6 +302,7 @@ class ImageCanvasState extends State<ImageCanvas> {
_checkFullResSwap();
_clamp();
_markRenderDirty();
_syncZoom();
}
void _onPanUpdate(DragUpdateDetails d) {
@@ -252,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';
@@ -286,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;
@@ -307,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) ──
@@ -416,6 +493,7 @@ class ImageCanvasState extends State<ImageCanvas> {
ty: _ty,
sc: _sc,
angleDeg: _angle,
flipH: _flipH,
cx: _vpSize.width / 2,
cy: _vpSize.height / 2,
k: k,
@@ -489,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>(
@@ -518,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,
);
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import '../services/hover_color.dart';
/// Ekran oranı buton ızgarası: 31 buton `a:b` (a dikeyi, b yatayı böler;
/// hedef boyut `W/b × H/a`). Sol-üst hücre `H:W` etiketi — buton değil
/// (1:1 yok: title bar çift tık zaten tam ekran).
///
/// Yerleşim: 8 satır × 4 sütun; hücreler mevcut alanı kaplayacak şekilde
/// esner (pencere/sekme alanı büyüdükçe butonlar büyür).
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(
children: [
for (var b = 1; b <= 8; b++)
Expanded(
child: Row(
children: [
for (var a = 1; a <= 4; a++) Expanded(child: _cell(a, b)),
],
),
),
],
);
}
/// Satır = `b` (1..8, yatay bölün); satır içinde `a` (1..4, dikey bölün).
Widget _cell(int a, int b) {
return Padding(
padding: const EdgeInsets.only(right: 3, bottom: 3),
child: a == 1 && b == 1
? _gridLabel('H:W')
: _SizeCell(
a: a,
b: b,
onTap: () async {
final work = await workArea();
if (work != null) onPick(a, b, work);
},
),
);
}
Widget _gridLabel(String text) {
return Container(
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.center,
child: Text(
text,
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
);
}
}
class _SizeCell extends StatefulWidget {
final int a;
final int b;
final VoidCallback onTap;
const _SizeCell({required this.a, required this.b, required this.onTap});
@override
State<_SizeCell> createState() => _SizeCellState();
}
class _SizeCellState extends State<_SizeCell> {
bool _hover = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: ValueListenableBuilder<Color>(
valueListenable: HoverColor.notifier,
builder: (context, hover, _) => AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: _hover ? hover.withValues(alpha: 0.4) : Colors.white10,
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.center,
child: Text(
'${widget.a}:${widget.b}',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
),
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: imajviewer
description: "Ultra-lightweight image viewer for Linux and Windows."
publish_to: 'none'
version: 1.5.0+11
version: 1.9.2+22
msix_config:
display_name: ImajViewer
+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');
});
}
+32
View File
@@ -67,6 +67,38 @@ Future<void> main() async {
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;