import 'package:file_selector/file_selector.dart' as fs; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/preferences_repository.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:multi_split_view/multi_split_view.dart'; import 'package:pdfrx/pdfrx.dart'; import 'draw_canvas.dart'; import 'pdf_toolbar.dart'; import 'pdf_page_area.dart'; import 'pages_sidebar.dart'; import 'signatures_sidebar.dart'; import 'ui_services.dart'; import 'package:pdf_signature/utils/download.dart'; import '../view_model/pdf_view_model.dart'; class PdfSignatureHomePage extends ConsumerStatefulWidget { final Future Function() onPickPdf; final VoidCallback onClosePdf; final fs.XFile currentFile; const PdfSignatureHomePage({ super.key, required this.onPickPdf, required this.onClosePdf, required this.currentFile, }); @override ConsumerState createState() => _PdfSignatureHomePageState(); } class _PdfSignatureHomePageState extends ConsumerState { static const Size _pageSize = Size(676, 960 / 1.4142); bool _showPagesSidebar = true; bool _showSignaturesSidebar = true; int _zoomLevel = 100; // percentage for display only // Split view controller to manage resizable sidebars without remounting the center area. late final MultiSplitViewController _splitController; late final List _areas; double _lastPagesWidth = 160; double _lastSignaturesWidth = 220; // Configurable sidebar constraints final double _pagesMin = 100; final double _pagesMax = 250; final double _signaturesMin = 140; final double _signaturesMax = 250; late PdfViewModel _viewModel; // Exposed for tests to trigger the invalid-file SnackBar without UI. @visibleForTesting void debugShowInvalidSignatureSnackBar() { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).invalidOrUnsupportedFile), ), ); } Future _pickPdf() async { await widget.onPickPdf(); } void _closePdf() { widget.onClosePdf(); } void _jumpToPage(int page) { final controller = _viewModel.controller; final current = _viewModel.currentPage; final pdf = _viewModel.document; int target; if (page == -1) { target = (current - 1).clamp(1, pdf.pageCount); } else { target = page.clamp(1, pdf.pageCount); } // Update reactive page providers so UI/tests reflect navigation even if controller is a stub if (current != target) { // Also notify view model (if used elsewhere) via its public API try { _viewModel.jumpToPage(target); } catch (_) { // ignore if provider not available } } if (controller.isReady) controller.goToPage(pageNumber: target); } Future _loadSignatureFromFile() async { final typeGroup = fs.XTypeGroup( label: Localizations.of(context, AppLocalizations)?.image, extensions: ['png', 'jpg', 'jpeg', 'webp'], ); final file = await fs.openFile(acceptedTypeGroups: [typeGroup]); if (file == null) return null; final bytes = await file.readAsBytes(); return bytes; } void _confirmSignature() { // In simplified UI, confirmation is a no-op } void _onDragSignature(Offset delta) { // In simplified UI, interactive overlay disabled } void _onResizeSignature(Offset delta) { // In simplified UI, interactive overlay disabled } void _onSelectPlaced(int? index) { // In simplified UI, selection is a no-op for tests } Future _openDrawCanvas() async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, enableDrag: false, builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false), ); if (result != null && result.isNotEmpty) { // In simplified UI, adding to library isn't implemented } return result; } Future _saveSignedPdf() async { ref.read(exportingProvider.notifier).state = true; try { final pdf = _viewModel.document; final messenger = ScaffoldMessenger.of(context); if (!pdf.loaded) { messenger.showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).nothingToSaveYet), ), ); return; } final exporter = ref.read(exportServiceProvider); // get DPI from preferences final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi; bool ok = false; String? savedPath; if (!kIsWeb) { final pick = ref.read(savePathPickerProvider); final path = await pick(); if (path == null || path.trim().isEmpty) return; final fullPath = _ensurePdfExtension(path.trim()); savedPath = fullPath; final src = pdf.pickedPdfBytes ?? Uint8List(0); final out = await exporter.exportSignedPdfFromBytes( srcBytes: src, uiPageSize: _pageSize, signatureImageBytes: null, placementsByPage: pdf.placementsByPage, targetDpi: targetDpi, ); if (out != null) { ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath); } } else { // Web: export and trigger browser download final src = pdf.pickedPdfBytes ?? Uint8List(0); final out = await exporter.exportSignedPdfFromBytes( srcBytes: src, uiPageSize: _pageSize, signatureImageBytes: null, placementsByPage: pdf.placementsByPage, targetDpi: targetDpi, ); if (out != null) { // Use a sensible default filename (cannot prompt path on web) ok = await downloadBytes(out, filename: 'signed.pdf'); savedPath = 'signed.pdf'; } } if (!kIsWeb) { if (ok) { messenger.showSnackBar( SnackBar( content: Text( AppLocalizations.of(context).savedWithPath(savedPath ?? ''), ), ), ); } else { messenger.showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).failedToSavePdf), ), ); } } else { // Web: show a toast-like confirmation messenger.showSnackBar( SnackBar( content: Text( ok ? AppLocalizations.of( context, ).savedWithPath(savedPath ?? 'signed.pdf') : AppLocalizations.of(context).failedToSavePdf, ), ), ); } } finally { ref.read(exportingProvider.notifier).state = false; } } String _ensurePdfExtension(String name) { if (!name.toLowerCase().endsWith('.pdf')) return '$name.pdf'; return name; } void _onControllerChanged() { if (mounted) { if (_viewModel.controller.isReady) { final newZoomLevel = (_viewModel.controller.currentZoom * 100) .round() .clamp(10, 800); if (newZoomLevel != _zoomLevel) { setState(() { _zoomLevel = newZoomLevel; }); } } else { // Reset to default zoom level when controller is not ready if (_zoomLevel != 100) { setState(() { _zoomLevel = 100; }); } } } } @override void initState() { super.initState(); // Build areas once with builders; keep these instances stable. _viewModel = ref.read(pdfViewModelProvider.notifier); // Add listener to update zoom level when controller zoom changes _viewModel.controller.addListener(_onControllerChanged); _areas = [ Area( size: _lastPagesWidth, min: _pagesMin, max: _pagesMax, builder: (context, area) => Offstage( offstage: !_showPagesSidebar, child: Consumer( builder: (context, ref, child) { final pdfViewModel = ref.watch(pdfViewModelProvider); final pdf = pdfViewModel.document; final documentRef = pdf.loaded && pdf.pickedPdfBytes != null ? PdfDocumentRefData( pdf.pickedPdfBytes!, sourceName: 'document.pdf', ) : null; return PagesSidebar( documentRef: documentRef, controller: _viewModel.controller, currentPage: _viewModel.currentPage, ); }, ), ), ), Area( flex: 1, builder: (context, area) => RepaintBoundary( child: PdfPageArea( controller: _viewModel.controller, key: const ValueKey('pdf_page_area'), pageSize: _pageSize, onDragSignature: _onDragSignature, onResizeSignature: _onResizeSignature, onConfirmSignature: _confirmSignature, onClearActiveOverlay: () {}, onSelectPlaced: _onSelectPlaced, ), ), ), Area( size: _lastSignaturesWidth, min: _signaturesMin, max: _signaturesMax, builder: (context, area) => Offstage( offstage: !_showSignaturesSidebar, child: SignaturesSidebar( onLoadSignatureFromFile: _loadSignatureFromFile, onOpenDrawCanvas: _openDrawCanvas, onSave: _saveSignedPdf, ), ), ), ]; _splitController = MultiSplitViewController(areas: _areas); // Apply initial collapse if needed _applySidebarVisibility(); } @override void dispose() { _viewModel.controller.removeListener(_onControllerChanged); _splitController.dispose(); super.dispose(); } void _applySidebarVisibility() { // Left pages sidebar final left = _splitController.areas[0]; if (_showPagesSidebar) { left.max = _pagesMax; left.min = _pagesMin; left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax); } else { _lastPagesWidth = left.size ?? _lastPagesWidth; left.min = 0; left.max = 1; left.size = 1; // effectively hidden } // Right signatures sidebar final right = _splitController.areas[2]; if (_showSignaturesSidebar) { right.max = _signaturesMax; right.min = _signaturesMin; right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax); } else { _lastSignaturesWidth = right.size ?? _lastSignaturesWidth; right.min = 0; right.max = 1; right.size = 1; } } @override Widget build(BuildContext context) { return _buildScaffold(context); } Widget _buildScaffold(BuildContext context) { final isExporting = ref.watch(exportingProvider); final l = AppLocalizations.of(context); return Scaffold( body: Padding( padding: const EdgeInsets.all(12), child: Stack( children: [ Column( children: [ // Full-width toolbar row PdfToolbar( disabled: isExporting, onPickPdf: _pickPdf, onClosePdf: _closePdf, onJumpToPage: _jumpToPage, onZoomOut: () { if (_viewModel.controller.isReady) { _viewModel.controller.zoomDown(); // Update display zoom level after controller zoom WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _zoomLevel = (_viewModel.controller.currentZoom * 100) .round() .clamp(10, 800); }); } }); } }, onZoomIn: () { if (_viewModel.controller.isReady) { _viewModel.controller.zoomUp(); // Update display zoom level after controller zoom WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _zoomLevel = (_viewModel.controller.currentZoom * 100) .round() .clamp(10, 800); }); } }); } }, zoomLevel: _zoomLevel, filePath: widget.currentFile.path, showPagesSidebar: _showPagesSidebar, showSignaturesSidebar: _showSignaturesSidebar, onTogglePagesSidebar: () => setState(() { _showPagesSidebar = !_showPagesSidebar; _applySidebarVisibility(); }), onToggleSignaturesSidebar: () => setState(() { _showSignaturesSidebar = !_showSignaturesSidebar; _applySidebarVisibility(); }), ), // Expose a compact signature drawer trigger area for tests when sidebar hidden if (!_showSignaturesSidebar) Align( alignment: Alignment.centerLeft, child: SizedBox( height: 0, // zero-height container exposing buttons offstage width: 0, child: Offstage( offstage: true, child: SignaturesSidebar( onLoadSignatureFromFile: _loadSignatureFromFile, onOpenDrawCanvas: _openDrawCanvas, onSave: _saveSignedPdf, ), ), ), ), const SizedBox(height: 8), Expanded( child: MultiSplitView( controller: _splitController, axis: Axis.horizontal, ), ), ], ), if (isExporting) Positioned.fill( child: Container( color: Colors.black45, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 12), Text( l.exportingPleaseWait, style: const TextStyle(color: Colors.white), ), ], ), ), ), ), ], ), ), ); } }