import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; 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'; const appVersion = '1.7.0'; 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 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(); 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)>[]; Size? size; try { final stat = file.statSync(); const maxHead = 512 * 1024; final raf = file.openSync(); final header = raf.readSync(stat.size < maxHead ? stat.size : maxHead); raf.closeSync(); size = _headerPixelSize(header); } catch (_) {} 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; } /// İlk 512 KB'dan çözünürlük (piksel decode yok). /// PNG/GIF/BMP/WebP: boyut ilk 33 baytta → elle parse. /// JPEG: SOF büyük APP/EXIF segmentlerinin ardından gelebilir → package:image startDecode. Size? _headerPixelSize(Uint8List b) { try { if (b.length >= 24 && b[0] == 0x89 && b[1] == 0x50) { // PNG final w = (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]; final h = (b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]; return Size(w.toDouble(), h.toDouble()); } if (b.length >= 10 && b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46) { // GIF (WBMP da GIF tabanlı olabilir) final w = b[6] | (b[7] << 8); final h = b[8] | (b[9] << 8); return Size(w.toDouble(), h.toDouble()); } if (b.length >= 26 && b[0] == 0x42 && b[1] == 0x4D) { // BMP final w = b[18] | (b[19] << 8) | (b[20] << 16) | (b[21] << 24); final h = b[22] | (b[23] << 8) | (b[24] << 16) | (b[25] << 24); return Size(w.toDouble(), h.abs().toDouble()); } if (b.length >= 30 && b[0] == 0x52 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x46 && b[8] == 0x57 && b[9] == 0x45 && b[10] == 0x42 && b[11] == 0x50) { // WebP if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x58) { // VP8X final w = b[24] | (b[25] << 8) | (b[26] << 16); final h = b[27] | (b[28] << 8) | (b[29] << 16); return Size((w + 1).toDouble(), (h + 1).toDouble()); } if (b.length >= 25 && b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x4C && b[20] == 0x2F) { // VP8L final w = (b[21] & 0x3F) | ((b[22] & 0x0F) << 6); final h = (b[23] & 0x3F) | ((b[24] & 0x0F) << 6); return Size((w + 1).toDouble(), (h + 1).toDouble()); } if (b[12] == 0x56 && b[13] == 0x50 && b[15] == 0x20) { // VP8 (lossy) final w = (b[26] | (b[27] << 8)) & 0x3FFF; final h = (b[28] | (b[29] << 8)) & 0x3FFF; return Size(w.toDouble(), h.toDouble()); } } } catch (_) {} try { // JPEG final info = img.JpegDecoder().startDecode(b); if (info != null) return Size(info.width.toDouble(), info.height.toDouble()); } catch (_) {} return null; } 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, ) : 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), ), ), ); } }