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 '../shortcuts/app_shortcuts.dart'; import '../services/window_persistence.dart'; import '../l10n/app_localizations.dart'; const appVersion = '1.7.1'; class ViewerScreen extends StatefulWidget { final List initialFiles; const ViewerScreen({super.key, this.initialFiles = const []}); @override State createState() => _ViewerScreenState(); } class _ViewerScreenState extends State with WindowListener { bool _hasImages = false; bool _isDragging = false; bool _controlsVisible = false; Timer? _saveDebounce; Rect _lastBounds = Rect.zero; final ValueNotifier _zoomPercent = ValueNotifier(100); final GlobalKey _canvasKey = GlobalKey(); @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 _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( 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( 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), if (_hasImages) ...[ Text(AppLocalizations.of(context)!.fileProperties, style: const TextStyle(color: Colors.white70, fontSize: 13)), const SizedBox(height: 4), FutureBuilder>( future: _fileProperties(ImageManager.instance.currentImagePath), builder: (context, snap) { if (!snap.hasData) return const SizedBox(height: 20); return Column( 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 _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> _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 _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( 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), ), ), ); } }