Initial commit: imajViewer Flutter Linux image viewer

This commit is contained in:
Alhan
2026-07-22 03:59:14 +03:00
commit 8369ec2a5d
33 changed files with 2165 additions and 0 deletions

24
lib/app.dart Normal file
View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'screens/viewer_screen.dart';
class ImajViewerApp extends StatelessWidget {
final List<String> initialFiles;
const ImajViewerApp({super.key, this.initialFiles = const []});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'imajViewer',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF1a1a1a),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
),
),
home: ViewerScreen(initialFiles: initialFiles),
);
}
}

42
lib/main.dart Normal file
View File

@@ -0,0 +1,42 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'services/image_manager.dart';
import 'services/window_persistence.dart';
void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
ImageManager.configureCache();
await windowManager.ensureInitialized();
// Restore saved window position/size or use defaults
final saved = WindowPersistence.restore();
final defaultSize = const Size(1280, 800);
final windowOptions = WindowOptions(
size: saved?.size ?? defaultSize,
minimumSize: const Size(400, 300),
backgroundColor: Colors.transparent,
titleBarStyle: TitleBarStyle.hidden,
);
await windowManager.waitUntilReadyToShow(windowOptions, () async {
if (saved != null) {
await windowManager.setPosition(saved.position);
await windowManager.setSize(saved.size);
} else {
await windowManager.center();
}
await windowManager.setResizable(true);
await windowManager.show();
await windowManager.focus();
});
// Extract file paths from command-line arguments
final filePaths = args.where((a) => !a.startsWith('-') && a != Platform.script.path).toList();
runApp(ImajViewerApp(initialFiles: filePaths));
}

View File

@@ -0,0 +1,165 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'package:desktop_drop/desktop_drop.dart';
import '../services/image_manager.dart';
import '../services/file_handler.dart';
import '../widgets/custom_title_bar.dart';
import '../widgets/image_canvas.dart';
import '../widgets/window_resize_zones.dart';
import '../shortcuts/app_shortcuts.dart';
import '../services/window_persistence.dart';
class ViewerScreen extends StatefulWidget {
final List<String> initialFiles;
const ViewerScreen({super.key, this.initialFiles = const []});
@override
State<ViewerScreen> createState() => _ViewerScreenState();
}
class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
bool _hasImages = false;
bool _isDragging = false;
Timer? _saveDebounce;
Timer? _pollTimer;
Rect _lastBounds = Rect.zero;
@override
void initState() {
super.initState();
windowManager.addListener(this);
ImageManager.instance.addListener(_onImageManagerChanged);
if (widget.initialFiles.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
FileHandler.loadImagesFromPaths(widget.initialFiles);
});
}
// İlk kayıt + periyodik polling başlat
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 200));
await _doSave();
_startPolling();
});
}
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
try {
final bounds = await windowManager.getBounds();
if (bounds != _lastBounds) {
_lastBounds = bounds;
WindowPersistence.saveSync(
position: bounds.topLeft,
size: bounds.size,
);
}
} catch (_) {}
});
}
@override
void dispose() {
windowManager.removeListener(this);
ImageManager.instance.removeListener(_onImageManagerChanged);
_saveDebounce?.cancel();
_pollTimer?.cancel();
super.dispose();
}
/// Universal event handler
@override
void onWindowEvent(String eventName) {
switch (eventName) {
case 'close':
_doSave();
break;
case 'resize':
case 'move':
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 300), () {
_doSave();
});
break;
}
}
void _onImageManagerChanged() {
setState(() => _hasImages = ImageManager.instance.images.isNotEmpty);
}
Future<void> _doSave() async {
try {
final pos = await windowManager.getPosition();
final size = await windowManager.getSize();
_lastBounds = Rect.fromLTWH(pos.dx, pos.dy, size.width, size.height);
WindowPersistence.saveSync(position: pos, size: size);
} catch (_) {
// Silently ignore save errors
}
}
@override
Widget build(BuildContext context) {
ImageManager.instance.buildContext = context;
return AppShortcuts(
child: Scaffold(
backgroundColor: Colors.transparent,
body: WindowResizeZones(
child: DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) {
setState(() => _isDragging = false);
FileHandler.loadImagesFromPaths(
details.files.map((f) => f.path).toList());
},
child: Stack(
children: [
Container(color: const Color(0xFF1a1a1a)),
Container(
color: Colors.transparent,
child: _hasImages
? ImageCanvas(filePath: ImageManager.instance.currentImagePath)
: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image, size: 64, color: Colors.white24),
SizedBox(height: 16),
Text('Resimleri buraya sürükleyin\nveya Ctrl+O ile açın',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 16)),
],
),
),
),
const CustomTitleBar(),
if (_isDragging)
Positioned.fill(
child: Container(
color: Colors.blue.withValues(alpha: 0.1),
child: Center(
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.blueAccent, width: 2),
),
child: const Text('Bırakın',
style: TextStyle(color: Colors.white, fontSize: 24)),
),
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:file_picker/file_picker.dart';
import 'image_manager.dart';
class FileHandler {
/// Open file picker dialog and load images
static Future<void> openFileDialog() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'],
allowMultiple: true,
);
if (result != null && result.files.isNotEmpty) {
final paths = result.files
.where((f) => f.path != null)
.map((f) => f.path!)
.toList();
ImageManager.instance.addImages(paths);
}
}
/// Load images from given file paths
static void loadImagesFromPaths(List<String> paths) {
if (paths.isNotEmpty) {
ImageManager.instance.addImages(paths);
}
}
}

View File

@@ -0,0 +1,76 @@
import 'dart:io';
import 'package:flutter/material.dart';
class ImageManager extends ChangeNotifier {
static final ImageManager _instance = ImageManager._();
static ImageManager get instance => _instance;
ImageManager._();
final List<String> _images = [];
int _currentIndex = 0;
List<String> get images => List.unmodifiable(_images);
int get currentIndex => _currentIndex;
int get imageCount => _images.length;
String get currentImagePath {
if (_images.isEmpty) return '';
return _images[_currentIndex];
}
void addImages(List<String> paths) {
final validPaths = paths.where((p) {
final ext = p.toLowerCase().split('.').last;
const supported = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'];
return supported.contains(ext) && File(p).existsSync();
}).toList();
if (validPaths.isEmpty) return;
_images.addAll(validPaths);
// Precache images
for (final path in validPaths) {
precacheImage(FileImage(File(path)), context!);
}
notifyListeners();
}
void setCurrentIndex(int index) {
if (index < 0 || index >= _images.length) return;
_currentIndex = index;
notifyListeners();
}
void nextImage() {
if (_images.isEmpty) return;
_currentIndex = (_currentIndex + 1) % _images.length;
notifyListeners();
}
void previousImage() {
if (_images.isEmpty) return;
_currentIndex = (_currentIndex - 1 + _images.length) % _images.length;
notifyListeners();
}
// Precache needs a BuildContext
BuildContext? context;
/// Configure image cache for high-res images
static void configureCache() {
PaintingBinding.instance.imageCache.maximumSize = 1000;
PaintingBinding.instance.imageCache.maximumSizeBytes = 1024 * 1024 * 1024; // 1 GB
}
set buildContext(BuildContext ctx) {
context = ctx;
}
void clear() {
_images.clear();
_currentIndex = 0;
notifyListeners();
}
}

View File

@@ -0,0 +1,49 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
/// Persists and restores window position and size.
class WindowPersistence {
static File get _configFile {
final home = Platform.environment['HOME'] ?? '/tmp';
return File('$home/.config/imajviewer/window.json');
}
/// 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({
'x': position.dx.toInt(),
'y': position.dy.toInt(),
'width': size.width.toInt(),
'height': size.height.toInt(),
}));
}
/// Restore saved window rect. Returns null if no saved config.
static ({Offset position, Size size})? restore() {
final file = _configFile;
if (!file.existsSync()) 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;
final h = (data['height'] as num?)?.toDouble() ?? 800;
if (w < 200 || h < 200 || x < -1000 || y < -1000) return null;
return (
position: Offset(x, y),
size: Size(w, h),
);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import '../services/image_manager.dart';
import '../services/file_handler.dart';
class AppShortcuts extends StatelessWidget {
final Widget child;
const AppShortcuts({super.key, required this.child});
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: {
// Navigate images
const SingleActivator(LogicalKeyboardKey.arrowLeft): () {
ImageManager.instance.previousImage();
},
const SingleActivator(LogicalKeyboardKey.arrowRight): () {
ImageManager.instance.nextImage();
},
// Open file dialog
const SingleActivator(
LogicalKeyboardKey.keyO,
control: true,
): () {
FileHandler.openFileDialog();
},
// Fullscreen toggle
const SingleActivator(LogicalKeyboardKey.f11): () async {
final isFullscreen = await windowManager.isFullScreen();
await windowManager.setFullScreen(!isFullscreen);
},
// Exit fullscreen
const SingleActivator(LogicalKeyboardKey.escape): () async {
final isFullscreen = await windowManager.isFullScreen();
if (isFullscreen) {
await windowManager.setFullScreen(false);
}
},
// Quit
const SingleActivator(
LogicalKeyboardKey.keyQ,
control: true,
): () {
windowManager.close();
},
},
child: Focus(
autofocus: true,
child: child,
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
class CustomTitleBar extends StatelessWidget {
const CustomTitleBar({super.key});
@override
Widget build(BuildContext context) {
return Positioned(
top: 0,
left: 0,
right: 0,
child: DragToMoveArea(
child: SizedBox(
height: 40,
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.6),
Colors.black.withValues(alpha: 0.0),
],
),
),
),
),
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(),
),
],
),
),
],
),
),
),
);
}
}
class _BarButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _BarButton({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SizedBox(
width: 44,
height: 40,
child: Icon(icon, color: Colors.white54, size: 18),
),
),
);
}
}

View File

@@ -0,0 +1,125 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
class ImageCanvas extends StatefulWidget {
final String filePath;
const ImageCanvas({super.key, required this.filePath});
@override
State<ImageCanvas> createState() => _ImageCanvasState();
}
class _ImageCanvasState extends State<ImageCanvas> {
late TransformationController _controller;
double _currentScale = 1.0;
@override
void initState() {
super.initState();
_controller = TransformationController();
_controller.addListener(_onTransformChanged);
}
@override
void didUpdateWidget(ImageCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filePath != widget.filePath) {
_resetView();
}
}
void _resetView() {
_controller.value = Matrix4.identity();
_currentScale = 1.0;
}
void _onTransformChanged() {
_currentScale = _controller.value.getMaxScaleOnAxis();
}
void _handleScroll(PointerScrollEvent event) {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final localPosition = renderBox.globalToLocal(event.position);
final delta = event.scrollDelta.dy;
final scaleFactor = delta < 0 ? 1.1 : 1 / 1.1;
final newScale = (_currentScale * scaleFactor).clamp(0.1, 10.0);
if (newScale <= 0.1 || newScale >= 10.0) return;
final actualScale = newScale / _currentScale;
// Decompose current matrix: widget_point = scale * child_point + translation
// So: child_point = (widget_point - translation) / scale
final matrix = _controller.value;
final tx = matrix.getTranslation().x;
final ty = matrix.getTranslation().y;
final childX = (localPosition.dx - tx) / _currentScale;
final childY = (localPosition.dy - ty) / _currentScale;
// Build new matrix: zoom around child_point, then re-apply
final newMatrix = Matrix4.identity()
// ignore: deprecated_member_use
..translate(childX, childY)
// ignore: deprecated_member_use
..scale(actualScale)
// ignore: deprecated_member_use
..translate(-childX, -childY)
..multiply(_controller.value);
_controller.value = newMatrix;
}
@override
void dispose() {
_controller.removeListener(_onTransformChanged);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
_handleScroll(event);
}
},
child: InteractiveViewer(
transformationController: _controller,
boundaryMargin: const EdgeInsets.all(double.infinity),
minScale: 0.1,
maxScale: 10.0,
panEnabled: true,
scaleEnabled: false,
child: Center(
child: Image.file(
File(widget.filePath),
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image, size: 48, color: Colors.white38),
SizedBox(height: 8),
Text(
'Resim yüklenemedi',
style: TextStyle(color: Colors.white38),
),
],
),
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
/// Resize edge zones for frameless windows.
class WindowResizeZones extends StatelessWidget {
final Widget child;
const WindowResizeZones({super.key, required this.child});
static const _edgeSize = 4.0;
static const _cornerSize = 10.0;
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
// Top edge
_ResizeEdge(
edge: ResizeEdge.top,
top: 0, left: _cornerSize, right: _cornerSize, height: _edgeSize,
),
// Bottom edge
_ResizeEdge(
edge: ResizeEdge.bottom,
bottom: 0, left: _cornerSize, right: _cornerSize, height: _edgeSize,
),
// Left edge
_ResizeEdge(
edge: ResizeEdge.left,
left: 0, top: _cornerSize, bottom: _cornerSize, width: _edgeSize,
),
// Right edge
_ResizeEdge(
edge: ResizeEdge.right,
right: 0, top: _cornerSize, bottom: _cornerSize, width: _edgeSize,
),
// Corners
_ResizeCorner(edge: ResizeEdge.topLeft, top: 0, left: 0),
_ResizeCorner(edge: ResizeEdge.topRight, top: 0, right: 0),
_ResizeCorner(edge: ResizeEdge.bottomLeft, bottom: 0, left: 0),
_ResizeCorner(edge: ResizeEdge.bottomRight, bottom: 0, right: 0),
],
);
}
}
class _ResizeEdge extends StatelessWidget {
final ResizeEdge edge;
final double? top, bottom, left, right, width, height;
const _ResizeEdge({
required this.edge,
this.top, this.bottom, this.left, this.right,
this.width, this.height,
});
MouseCursor _cursor(ResizeEdge e) {
return switch (e) {
ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeRow,
ResizeEdge.left || ResizeEdge.right => SystemMouseCursors.resizeColumn,
_ => SystemMouseCursors.basic,
};
}
@override
Widget build(BuildContext context) {
return Positioned(
top: top, bottom: bottom, left: left, right: right,
width: width, height: height,
child: MouseRegion(
cursor: _cursor(edge),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (_) => windowManager.startResizing(edge),
),
),
);
}
}
class _ResizeCorner extends StatelessWidget {
final ResizeEdge edge;
final double? top, bottom, left, right;
const _ResizeCorner({
required this.edge,
this.top, this.bottom, this.left, this.right,
});
MouseCursor _cursor(ResizeEdge e) {
return switch (e) {
ResizeEdge.topLeft || ResizeEdge.bottomRight =>
SystemMouseCursors.resizeDownLeft,
ResizeEdge.topRight || ResizeEdge.bottomLeft =>
SystemMouseCursors.resizeDownRight,
_ => SystemMouseCursors.basic,
};
}
@override
Widget build(BuildContext context) {
return Positioned(
top: top, bottom: bottom, left: left, right: right,
width: WindowResizeZones._cornerSize,
height: WindowResizeZones._cornerSize,
child: MouseRegion(
cursor: _cursor(edge),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (_) => windowManager.startResizing(edge),
),
),
);
}
}