Files
imajviewer/lib/screens/viewer_screen.dart
T

733 lines
27 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 '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 []});
@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);
_updateTitle();
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) => _updateTitle());
}
}
@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);
_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 {
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
}
}
static const double _shortcutKeyColumnWidth = 126;
int _settingsTab = 0;
void _showSettings() {
showDialog(
context: context,
builder: (context) => StatefulBuilder(
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),
),
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)),
),
],
),
],
),
),
),
);
},
),
);
}
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;
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: 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),
),
),
),
);
}
}