Files
imajviewer/lib/screens/viewer_screen.dart
T
Alhan c34a3bfd95
Build & Release / build-linux (push) Has been cancelled
Build & Release / build-windows (push) Has been cancelled
Build & Release / create-release (push) Has been cancelled
fix: crop save always JPEG q93 + save rotated view 1:1 (v1.4.1)
- Output extension is always .jpg regardless of source format (was: PNG in -> PNG out)
- Rotation now saved correctly: viewport grid resampled bilinearly in source
  space (was: unrotated bounding box copy)
- Quality 90 -> 93; l10n strings updated (JPEG 93)
- New: tool/crop_test.dart — pure-Dart end-to-end test (dart run tool/crop_test.dart)
2026-10-11 22:16:25 +03:00

377 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_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 '../shortcuts/app_shortcuts.dart';
import '../services/window_persistence.dart';
import '../l10n/app_localizations.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;
bool _controlsVisible = false;
Timer? _saveDebounce;
Rect _lastBounds = Rect.zero;
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();
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),
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)),
),
],
),
),
);
}
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<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,
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,
)
: 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(),
),
),
],
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),
),
),
);
}
}