- 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)
498 lines
18 KiB
Dart
498 lines
18 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
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 '../widgets/custom_title_bar.dart';
|
||
import '../widgets/image_canvas.dart';
|
||
import '../widgets/window_resize_zones.dart';
|
||
import '../widgets/window_size_selector.dart';
|
||
import '../shortcuts/app_shortcuts.dart';
|
||
import '../services/window_persistence.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
|
||
const appVersion = '1.8.0';
|
||
|
||
class ViewerScreen extends StatefulWidget {
|
||
final List<String> initialFiles;
|
||
const ViewerScreen({super.key, this.initialFiles = const []});
|
||
@override
|
||
State<ViewerScreen> createState() => _ViewerScreenState();
|
||
}
|
||
|
||
class _ViewerScreenState extends State<ViewerScreen> with WindowListener {
|
||
bool _hasImages = false;
|
||
bool _isDragging = false;
|
||
bool _controlsVisible = false;
|
||
Timer? _saveDebounce;
|
||
Rect _lastBounds = Rect.zero;
|
||
final ValueNotifier<double> _zoomPercent = ValueNotifier<double>(100);
|
||
final GlobalKey<ImageCanvasState> _canvasKey = GlobalKey<ImageCanvasState>();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
windowManager.addListener(this);
|
||
ImageManager.instance.addListener(_onImageManagerChanged);
|
||
|
||
if (widget.initialFiles.isNotEmpty) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
FileHandler.loadImagesFromPaths(widget.initialFiles);
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
windowManager.removeListener(this);
|
||
ImageManager.instance.removeListener(_onImageManagerChanged);
|
||
_saveDebounce?.cancel();
|
||
_zoomPercent.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// Universal event handler
|
||
@override
|
||
void onWindowEvent(String eventName) {
|
||
switch (eventName) {
|
||
case 'close':
|
||
// Senkron kayıt — async çağrı kapanışta tamamlanmayabilir
|
||
if (_lastBounds != Rect.zero) {
|
||
WindowPersistence.saveSync(
|
||
position: _lastBounds.topLeft,
|
||
size: _lastBounds.size,
|
||
);
|
||
}
|
||
break;
|
||
case 'resize':
|
||
case 'move':
|
||
// Son bilinen konumu hemen güncelle (kapanış senkron kaydı için)
|
||
windowManager.getBounds().then((b) => _lastBounds = b).catchError((_) => _lastBounds);
|
||
_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
|
||
}
|
||
}
|
||
|
||
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(() {});
|
||
},
|
||
),
|
||
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(() {});
|
||
},
|
||
),
|
||
const Divider(color: Colors.white24, height: 24),
|
||
WindowSizeSelector(
|
||
workArea: _workArea,
|
||
onPick: _applyWindowSize,
|
||
),
|
||
const Divider(color: Colors.white24, height: 24),
|
||
if (_hasImages) ...[
|
||
Text(AppLocalizations.of(context)!.fileProperties,
|
||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||
const SizedBox(height: 4),
|
||
FutureBuilder<List<(String, String)>>(
|
||
future: _fileProperties(ImageManager.instance.currentImagePath),
|
||
builder: (context, snap) {
|
||
if (!snap.hasData) return const SizedBox(height: 20);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: snap.data!.map((r) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 2),
|
||
child: Text.rich(
|
||
TextSpan(children: [
|
||
TextSpan(text: '${r.$1}: ', style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||
TextSpan(text: r.$2, style: const TextStyle(color: Colors.white, fontSize: 13)),
|
||
]),
|
||
),
|
||
)).toList(),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
Text('imajViewer $appVersion',
|
||
style: const TextStyle(color: Colors.white, fontSize: 15)),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
AppLocalizations.of(context)!.appDescription,
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _printImage() async {
|
||
final path = ImageManager.instance.currentImagePath;
|
||
if (path.isEmpty) return;
|
||
final bytes = await File(path).readAsBytes();
|
||
final image = pw.MemoryImage(bytes);
|
||
await Printing.layoutPdf(
|
||
onLayout: (format) async {
|
||
final doc = pw.Document();
|
||
doc.addPage(
|
||
pw.Page(
|
||
pageFormat: PdfPageFormat.a4,
|
||
build: (context) => pw.Center(
|
||
child: pw.Image(image, fit: pw.BoxFit.contain),
|
||
),
|
||
),
|
||
);
|
||
return doc.save();
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<List<(String, String)>> _fileProperties(String path) async {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
final file = File(path);
|
||
final rows = <(String, String)>[];
|
||
final Size? size = pixelSizeOfFile(path);
|
||
rows.add((l10n.propResolution, size == null ? '—' : '${size.width.toInt()} × ${size.height.toInt()}'));
|
||
try {
|
||
rows.add((l10n.propFileSize, _fmtSize(file.statSync().size)));
|
||
} catch (_) {
|
||
rows.add((l10n.propFileSize, '—'));
|
||
}
|
||
rows.add((l10n.propFormat, path.toLowerCase().split('.').last.toUpperCase()));
|
||
try {
|
||
rows.add((l10n.propModified, _fmtDate(file.statSync().modified)));
|
||
} catch (_) {
|
||
rows.add((l10n.propModified, '—'));
|
||
}
|
||
rows.add((l10n.propPath, path));
|
||
return rows;
|
||
}
|
||
|
||
String _fmtSize(int bytes) {
|
||
if (bytes < 1024) return '$bytes B';
|
||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||
double v = bytes / 1024;
|
||
var i = 0;
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024;
|
||
i++;
|
||
}
|
||
return '${v.toStringAsFixed(v >= 100 ? 0 : 1)} ${units[i]}';
|
||
}
|
||
|
||
String _fmtDate(DateTime t) {
|
||
String two(int n) => n.toString().padLeft(2, '0');
|
||
return '${t.year}-${two(t.month)}-${two(t.day)} ${two(t.hour)}:${two(t.minute)}';
|
||
}
|
||
|
||
void _flipHorizontal() => _canvasKey.currentState?.toggleFlipH();
|
||
|
||
Future<Size?> _workArea() async {
|
||
final pos = await windowManager.getPosition();
|
||
return WindowPersistence.workAreaSize(windowPosition: pos);
|
||
}
|
||
|
||
Future<void> _applyWindowSize(int rows, int cols, Size work) async {
|
||
try {
|
||
await windowManager.setSize(Size(work.width / cols, work.height / rows));
|
||
} catch (_) {
|
||
// Boyutlandırma başarısız olursa sessizce geç (buton saf eylem).
|
||
}
|
||
}
|
||
|
||
Future<void> _cropSave() async {
|
||
final path = ImageManager.instance.currentImagePath;
|
||
final state = _canvasKey.currentState;
|
||
if (path.isEmpty || state == null) return;
|
||
final l10n = AppLocalizations.of(context)!;
|
||
String? message;
|
||
try {
|
||
final source = await CropSaveService.load(path);
|
||
final vp = state.cropViewParams(source.width, source.height);
|
||
if (vp == null) {
|
||
message = l10n.cropFailed;
|
||
} else {
|
||
final outPath = await CropSaveService.saveCrop(
|
||
srcPath: path,
|
||
source: source,
|
||
vp: vp,
|
||
matrix: state.activeColorMatrix,
|
||
);
|
||
message = l10n.cropSaved(outPath);
|
||
}
|
||
} on CropSaveError catch (e) {
|
||
switch (e.code) {
|
||
case 'file_exists':
|
||
message = l10n.cropFileExists;
|
||
case 'decode':
|
||
message = l10n.cropUnsupported;
|
||
default:
|
||
message = l10n.cropFailed;
|
||
}
|
||
} catch (_) {
|
||
message = l10n.cropFailed;
|
||
}
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context)
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(SnackBar(
|
||
content: Text(message, style: const TextStyle(fontSize: 13)),
|
||
duration: const Duration(seconds: 2),
|
||
behavior: SnackBarBehavior.floating,
|
||
width: 520,
|
||
));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AppShortcuts(
|
||
onCropSave: _hasImages ? _cropSave : null,
|
||
onFlipH: _hasImages ? _flipHorizontal : null,
|
||
child: Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: WindowResizeZones(
|
||
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: MouseRegion(
|
||
onEnter: (_) => setState(() => _controlsVisible = true),
|
||
onExit: (_) => setState(() => _controlsVisible = false),
|
||
child: Stack(
|
||
children: [
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Container(color: const Color(0xFF1a1a1a)),
|
||
),
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Container(
|
||
color: Colors.transparent,
|
||
child: _hasImages
|
||
? ImageCanvas(
|
||
key: _canvasKey,
|
||
filePath: ImageManager.instance.currentImagePath,
|
||
zoomPercent: _zoomPercent,
|
||
)
|
||
: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.image, size: 64, color: Colors.white24),
|
||
const SizedBox(height: 16),
|
||
Text(AppLocalizations.of(context)!.dragDropHint,
|
||
key: const Key('dragDropHint'),
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: Colors.white38, fontSize: 16)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
CustomTitleBar(
|
||
onSettings: _showSettings,
|
||
onSaveCrop: _hasImages ? _cropSave : null,
|
||
visible: _controlsVisible,
|
||
),
|
||
if (_hasImages && _controlsVisible) ...[
|
||
Positioned(
|
||
left: 8,
|
||
bottom: 8,
|
||
child: _NavButton(
|
||
icon: Icons.chevron_left,
|
||
onTap: () => ImageManager.instance.previousImage(),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: 8,
|
||
bottom: 8,
|
||
child: _NavButton(
|
||
icon: Icons.chevron_right,
|
||
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(
|
||
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: Text(AppLocalizations.of(context)!.dropHere,
|
||
style: const TextStyle(color: Colors.white, fontSize: 24)),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavButton extends StatefulWidget {
|
||
final IconData icon;
|
||
final VoidCallback onTap;
|
||
|
||
const _NavButton({required this.icon, required this.onTap});
|
||
|
||
@override
|
||
State<_NavButton> createState() => _NavButtonState();
|
||
}
|
||
|
||
class _NavButtonState extends State<_NavButton> {
|
||
bool _hovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: widget.onTap,
|
||
child: MouseRegion(
|
||
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: Icon(widget.icon, color: Colors.white, size: 22),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|