diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..801aae8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + # Dart/Flutter dependencies (pubspec.yaml) + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "cron" + cronjob: "0 0 * * 2,5" + timezone: "Etc/UTC" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "dart" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "cron" + cronjob: "0 0 * * 2,5" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" diff --git a/.github/workflows/pr-unit-tests.yml b/.github/workflows/pr-unit-tests.yml new file mode 100644 index 0000000..4e7fcef --- /dev/null +++ b/.github/workflows/pr-unit-tests.yml @@ -0,0 +1,65 @@ +name: Run unit tests on Pull Request + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened] + +permissions: + contents: read + checks: write + pull-requests: write + +concurrency: + group: "pr-unit-tests" + cancel-in-progress: false + +jobs: + unit-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - name: Checkout repository + uses: actions/checkout@v4 + continue-on-error: true + id: checkout_https + + - name: Checkout repository via SSH (fallback) + if: steps.checkout_https.outcome == 'failure' + uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} + ssh-strict: false + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run build_runner (if needed) + run: flutter pub run build_runner build --delete-conflicting-outputs + + - name: Run static analysis (for logs) + run: flutter analyze || true + + - name: Setup reviewdog + uses: reviewdog/action-setup@v1 + with: + reviewdog_version: latest + + - name: Annotate analyzer results with reviewdog + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # run analyzer and pipe into reviewdog so PRs get inline annotations + flutter analyze 2>&1 | reviewdog -efm="%f:%l:%c: %m" -name="flutter analyze" -reporter=github-pr-check -filter-mode=added -level=warning || true + + # - name: Run tests + # run: flutter test \ No newline at end of file diff --git a/lib/data/repositories/document_repository.dart b/lib/data/repositories/document_repository.dart index ae69d39..d60d7b9 100644 --- a/lib/data/repositories/document_repository.dart +++ b/lib/data/repositories/document_repository.dart @@ -8,12 +8,14 @@ import 'package:pdfrx/pdfrx.dart'; import '../../domain/models/model.dart'; -class DocumentStateNotifier extends StateNotifier { - DocumentStateNotifier({ExportService? service}) - : _service = service ?? ExportService(), - super(Document.initial()); +class DocumentStateNotifier extends Notifier { + late final ExportService _service; - final ExportService _service; + @override + Document build() { + _service = ExportService(); + return Document.initial(); + } @visibleForTesting void openSample() { @@ -278,8 +280,8 @@ class DocumentStateNotifier extends StateNotifier { } final documentRepositoryProvider = - StateNotifierProvider( - (ref) => DocumentStateNotifier(), + NotifierProvider( + DocumentStateNotifier.new, ); /// --- Isolate helpers of DocumentRepository --- diff --git a/lib/data/repositories/preferences_repository.dart b/lib/data/repositories/preferences_repository.dart index 2330d95..6cf4212 100644 --- a/lib/data/repositories/preferences_repository.dart +++ b/lib/data/repositories/preferences_repository.dart @@ -89,9 +89,39 @@ String _normalizeLanguageTag(String tag) { return tags.contains('en') ? 'en' : tags.first; } -class PreferencesStateNotifier extends StateNotifier { +class PreferencesStateNotifier extends Notifier { late final SharedPreferences _prefs; final Completer _ready = Completer(); + + @override + PreferencesState build() { + // Initialize with defaults + final defaultState = PreferencesState( + theme: 'system', + language: _normalizeLanguageTag( + WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(), + ), + exportDpi: 144.0, + theme_color: '#FF2196F3', // blue + ); + // Start async initialization + _initAsync(); + return defaultState; + } + + void _initAsync() async { + _prefs = await SharedPreferences.getInstance(); + await _load(); + _ready.complete(); + } + + // For testing - can be called with mock SharedPreferences + void initWithPrefs(SharedPreferences prefs) { + _prefs = prefs; + _load(); + _ready.complete(); + } + static Color? _tryParseColor(String? s) { if (s == null || s.isEmpty) return null; final v = s.trim(); @@ -162,22 +192,7 @@ class PreferencesStateNotifier extends StateNotifier { return '#$a$r$g$b'; } - PreferencesStateNotifier([SharedPreferences? prefs]) - : super( - PreferencesState( - theme: 'system', - language: _normalizeLanguageTag( - WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(), - ), - exportDpi: 144.0, - theme_color: '#FF2196F3', // blue - ), - ) { - _init(prefs); - } - - Future _init(SharedPreferences? injected) async { - _prefs = injected ?? await SharedPreferences.getInstance(); + Future _load() async { // Load persisted values (with sane defaults) final loaded = PreferencesState( theme: _prefs.getString(_kTheme) ?? 'system', @@ -291,9 +306,8 @@ class PreferencesStateNotifier extends StateNotifier { } final preferencesRepositoryProvider = - StateNotifierProvider((ref) { - // Construct with lazy SharedPreferences initialization. - return PreferencesStateNotifier(); - }); + NotifierProvider( + PreferencesStateNotifier.new, + ); // pageViewModeProvider removed; the app always runs in continuous mode. diff --git a/lib/data/repositories/signature_asset_repository.dart b/lib/data/repositories/signature_asset_repository.dart index d57037c..cf4bd39 100644 --- a/lib/data/repositories/signature_asset_repository.dart +++ b/lib/data/repositories/signature_asset_repository.dart @@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/domain/models/model.dart'; /// -class SignatureAssetRepository extends StateNotifier> { - SignatureAssetRepository() : super(const []); +class SignatureAssetRepository extends Notifier> { + @override + List build() => const []; /// Preferred API: add from an already decoded image to avoid re-decodes. void addImage(img.Image image, {String? name}) { @@ -17,6 +18,6 @@ class SignatureAssetRepository extends StateNotifier> { } final signatureAssetRepositoryProvider = - StateNotifierProvider>( - (ref) => SignatureAssetRepository(), + NotifierProvider>( + SignatureAssetRepository.new, ); diff --git a/lib/data/repositories/signature_card_repository.dart b/lib/data/repositories/signature_card_repository.dart index 6820235..e31507b 100644 --- a/lib/data/repositories/signature_card_repository.dart +++ b/lib/data/repositories/signature_card_repository.dart @@ -54,8 +54,9 @@ class CachedSignatureCard { ); } -class SignatureCardStateNotifier extends StateNotifier> { - SignatureCardStateNotifier() : super(const []); +class SignatureCardStateNotifier extends Notifier> { + @override + List build() => const []; // Internal storage with cache final List _cards = []; @@ -207,6 +208,6 @@ class SignatureCardStateNotifier extends StateNotifier> { } final signatureCardRepositoryProvider = - StateNotifierProvider>( - (ref) => SignatureCardStateNotifier(), + NotifierProvider>( + SignatureCardStateNotifier.new, ); diff --git a/lib/routing/router.dart b/lib/routing/router.dart index 8c489b6..cbf4528 100644 --- a/lib/routing/router.dart +++ b/lib/routing/router.dart @@ -26,15 +26,16 @@ final routerProvider = Provider((ref) { GoRoute( path: '/', builder: (context, state) { - final sessionVm = ref.read(pdfSessionViewModelProvider(router)); + final sessionVm = ref.read(pdfSessionViewModelProvider.notifier); return WelcomeScreen( - onPickPdf: () => sessionVm.pickAndOpenPdf(), + onPickPdf: () => sessionVm.pickAndOpenPdf(router), onOpenPdf: ({String? path, Uint8List? bytes, String? fileName}) => sessionVm.openPdf( path: path, bytes: bytes ?? Uint8List(0), fileName: fileName, + router: router, ), ); }, @@ -42,12 +43,13 @@ final routerProvider = Provider((ref) { GoRoute( path: '/pdf', builder: (context, state) { - final sessionVm = ref.read(pdfSessionViewModelProvider(router)); + final sessionVm = ref.read(pdfSessionViewModelProvider.notifier); + final sessionState = ref.read(pdfSessionViewModelProvider); return PdfSignatureHomePage( - onPickPdf: () => sessionVm.pickAndOpenPdf(), - onClosePdf: () => sessionVm.closePdf(), - currentFile: sessionVm.currentFile, - currentFileName: sessionVm.displayFileName, + onPickPdf: () => sessionVm.pickAndOpenPdf(router), + onClosePdf: () => sessionVm.closePdf(router), + currentFile: sessionState.currentFile, + currentFileName: sessionState.displayFileName, ); }, ), diff --git a/lib/ui/features/pdf/view_model/pdf_export_state.dart b/lib/ui/features/pdf/view_model/pdf_export_state.dart new file mode 100644 index 0000000..09eaa94 --- /dev/null +++ b/lib/ui/features/pdf/view_model/pdf_export_state.dart @@ -0,0 +1,14 @@ +/// Immutable state for PdfExportViewModel +class PdfExportState { + final bool exporting; + + const PdfExportState({required this.exporting}); + + factory PdfExportState.initial() { + return const PdfExportState(exporting: false); + } + + PdfExportState copyWith({bool? exporting}) { + return PdfExportState(exporting: exporting ?? this.exporting); + } +} diff --git a/lib/ui/features/pdf/view_model/pdf_export_view_model.dart b/lib/ui/features/pdf/view_model/pdf_export_view_model.dart index dda891c..13e5f7e 100644 --- a/lib/ui/features/pdf/view_model/pdf_export_view_model.dart +++ b/lib/ui/features/pdf/view_model/pdf_export_view_model.dart @@ -5,39 +5,30 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_state.dart'; /// ViewModel for export-related UI state and helpers. -class PdfExportViewModel extends ChangeNotifier { - final Ref ref; - bool _exporting = false; - +class PdfExportViewModel extends Notifier { // Dependencies (injectable via constructor for tests) // Zero-arg picker retained for backward compatibility with tests. - final Future Function() _savePathPicker; + late final Future Function() _savePathPicker; // Preferred picker that accepts a suggested filename. - final Future Function(String suggestedName) + late final Future Function(String suggestedName) _savePathPickerWithSuggestedName; - PdfExportViewModel( - this.ref, { - Future Function()? savePathPicker, - Future Function(String suggestedName)? - savePathPickerWithSuggestedName, - }) : _savePathPicker = savePathPicker ?? _defaultSavePathPicker, - // Prefer provided suggested-name picker; otherwise, if only zero-arg - // picker is given (tests), wrap it; else use default that honors name. - _savePathPickerWithSuggestedName = - savePathPickerWithSuggestedName ?? - (savePathPicker != null - ? ((_) => savePathPicker()) - : _defaultSavePathPickerWithSuggestedName); + @override + PdfExportState build() { + // Initialize with default pickers + _savePathPicker = _defaultSavePathPicker; + _savePathPickerWithSuggestedName = _defaultSavePathPickerWithSuggestedName; + return PdfExportState.initial(); + } - bool get exporting => _exporting; + bool get exporting => state.exporting; void setExporting(bool value) { - if (_exporting == value) return; - _exporting = value; - notifyListeners(); + if (state.exporting == value) return; + state = state.copyWith(exporting: value); } /// Perform export via document repository. Returns true on success. @@ -122,8 +113,7 @@ class PdfExportViewModel extends ChangeNotifier { } } -final pdfExportViewModelProvider = ChangeNotifierProvider(( - ref, -) { - return PdfExportViewModel(ref); -}); +final pdfExportViewModelProvider = + NotifierProvider( + PdfExportViewModel.new, + ); diff --git a/lib/ui/features/pdf/view_model/pdf_session_state.dart b/lib/ui/features/pdf/view_model/pdf_session_state.dart new file mode 100644 index 0000000..f5dd9d8 --- /dev/null +++ b/lib/ui/features/pdf/view_model/pdf_session_state.dart @@ -0,0 +1,23 @@ +import 'package:cross_file/cross_file.dart'; + +/// Immutable state for PdfSessionViewModel +class PdfSessionState { + final XFile currentFile; + final String displayFileName; + + const PdfSessionState({ + required this.currentFile, + required this.displayFileName, + }); + + factory PdfSessionState.initial() { + return PdfSessionState(currentFile: XFile(''), displayFileName: ''); + } + + PdfSessionState copyWith({XFile? currentFile, String? displayFileName}) { + return PdfSessionState( + currentFile: currentFile ?? this.currentFile, + displayFileName: displayFileName ?? this.displayFileName, + ); + } +} diff --git a/lib/ui/features/pdf/view_model/pdf_view_model.dart b/lib/ui/features/pdf/view_model/pdf_view_model.dart index c0c58fc..f993204 100644 --- a/lib/ui/features/pdf/view_model/pdf_view_model.dart +++ b/lib/ui/features/pdf/view_model/pdf_view_model.dart @@ -7,103 +7,70 @@ import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/data/repositories/signature_card_repository.dart'; import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/document_version.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_state.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/pdf_session_state.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:file_picker/file_picker.dart' as fp; import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter/foundation.dart'; -class PdfViewModel extends ChangeNotifier { - final Ref ref; - PdfViewerController _controller = PdfViewerController(); - PdfViewerController get controller => _controller; - int _currentPage = 1; - late final bool _useMockViewer; - bool _isDisposed = false; - - // Active rect for signature placement overlay - Rect? _activeRect; - Rect? get activeRect => _activeRect; - set activeRect(Rect? value) { - _activeRect = value; - if (!_isDisposed) { - notifyListeners(); - } +class PdfViewModel extends Notifier { + @override + PdfViewState build() { + return PdfViewState.initial(); } - // Locked placements: Set of (page, index) tuples - final Set _lockedPlacements = {}; - Set get lockedPlacements => Set.unmodifiable(_lockedPlacements); + PdfViewerController get controller => state.controller; + int get currentPage => state.currentPage; + bool get useMockViewer => state.useMockViewer; + Rect? get activeRect => state.activeRect; + Set get lockedPlacements => state.lockedPlacements; - // Document version tracking for UI consistency - DocumentVersion _documentVersion = DocumentVersion.initial(); + set activeRect(Rect? value) { + state = state.copyWith(activeRect: value, clearActiveRect: value == null); + } + + set currentPage(int value) { + final doc = ref.read(documentRepositoryProvider); + final clamped = value.clamp(1, doc.pageCount); + debugPrint('PdfViewModel.currentPage set to $clamped'); + state = state.copyWith(currentPage: clamped); + } // Get current document source name for PdfDocumentRefData String get documentSourceName { // Ensure document version is up to date, but only update if really needed _updateDocumentVersionIfNeeded(); - return _documentVersion.sourceName; + return state.documentVersion.sourceName; } void _updateDocumentVersionIfNeeded() { final document = ref.read(documentRepositoryProvider); - if (!identical(_documentVersion.lastBytes, document.pickedPdfBytes)) { - _documentVersion = DocumentVersion( - version: _documentVersion.version + 1, - lastBytes: document.pickedPdfBytes, + if (!identical(state.documentVersion.lastBytes, document.pickedPdfBytes)) { + state = state.copyWith( + documentVersion: DocumentVersion( + version: state.documentVersion.version + 1, + lastBytes: document.pickedPdfBytes, + ), ); } } - // const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false); - PdfViewModel(this.ref, {bool? useMockViewer}) - : _useMockViewer = - useMockViewer ?? - const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false); - - bool get useMockViewer => _useMockViewer; - - int get currentPage => _currentPage; - - set currentPage(int value) { - _currentPage = value.clamp(1, document.pageCount); - // ignore: avoid_print - debugPrint('PdfViewModel.currentPage set to $_currentPage'); - if (!_isDisposed) { - notifyListeners(); - } - } - // Do not watch the document repository here; watching would cause this - // ChangeNotifier to be disposed/recreated on every document change, which + // notifier to be disposed/recreated on every document change, which // resets transient UI state like locked placements. Read instead. Document get document => ref.read(documentRepositoryProvider); void jumpToPage(int page) { - // ignore: avoid_print - debugPrint('PdfViewModel.jumpToPage ' + page.toString()); + debugPrint('PdfViewModel.jumpToPage $page'); currentPage = page; } - // Make this view model "int-like" for tests that compare it directly to an - // integer or use it as a Map key for page lookups. - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is int) { - return other == currentPage; - } - return false; - } - - @override - int get hashCode => currentPage.hashCode; - // Allow repositories to request a UI refresh without mutating provider state void notifyPlacementsChanged() { - if (!_isDisposed) { - notifyListeners(); - } + // Force a rebuild by updating state + state = state.copyWith(); } // Document repository methods @@ -146,10 +113,9 @@ class PdfViewModel extends ChangeNotifier { .read(documentRepositoryProvider.notifier) .removePlacement(page: page, index: index); // Also remove from locked placements if it was locked - _lockedPlacements.remove(_placementKey(page, index)); - if (!_isDisposed) { - notifyListeners(); - } + final newLocked = Set.from(state.lockedPlacements) + ..remove(_placementKey(page, index)); + state = state.copyWith(lockedPlacements: newLocked); } void updatePlacementRect({ @@ -177,23 +143,21 @@ class PdfViewModel extends ChangeNotifier { // Check if a placement is locked bool isPlacementLocked({required int page, required int index}) { - return _lockedPlacements.contains(_placementKey(page, index)); + return state.lockedPlacements.contains(_placementKey(page, index)); } // Lock a placement void lockPlacement({required int page, required int index}) { - _lockedPlacements.add(_placementKey(page, index)); - if (!_isDisposed) { - notifyListeners(); - } + final newLocked = Set.from(state.lockedPlacements) + ..add(_placementKey(page, index)); + state = state.copyWith(lockedPlacements: newLocked); } // Unlock a placement void unlockPlacement({required int page, required int index}) { - _lockedPlacements.remove(_placementKey(page, index)); - if (!_isDisposed) { - notifyListeners(); - } + final newLocked = Set.from(state.lockedPlacements) + ..remove(_placementKey(page, index)); + state = state.copyWith(lockedPlacements: newLocked); } // Toggle lock state of a placement @@ -250,37 +214,24 @@ class PdfViewModel extends ChangeNotifier { void clearAllSignatureCards() { ref.read(signatureCardRepositoryProvider.notifier).clearAll(); } - - @override - void dispose() { - _isDisposed = true; - super.dispose(); - } } -final pdfViewModelProvider = ChangeNotifierProvider((ref) { - return PdfViewModel(ref); -}); +final pdfViewModelProvider = NotifierProvider( + PdfViewModel.new, +); /// ViewModel managing PDF session lifecycle (file picking/open/close) and /// navigation. Replaces the previous PdfManager helper. -class PdfSessionViewModel extends ChangeNotifier { - final Ref ref; - final GoRouter router; - XFile _currentFile = XFile(''); - // Keep a human display name in addition to XFile, because on Linux via - // xdg-desktop-portal the path can look like /run/user/.../doc/, and - // XFile.name derives from that basename, yielding a random UUID instead of - // the actual filename the user selected. We preserve the picker/drop name - // here to offer a sensible default like "signed_.pdf". - String _displayFileName = ''; +class PdfSessionViewModel extends Notifier { + @override + PdfSessionState build() { + return PdfSessionState.initial(); + } - PdfSessionViewModel({required this.ref, required this.router}); + XFile get currentFile => state.currentFile; + String get displayFileName => state.displayFileName; - XFile get currentFile => _currentFile; - String get displayFileName => _displayFileName; - - Future pickAndOpenPdf() async { + Future pickAndOpenPdf(GoRouter router) async { final result = await fp.FilePicker.platform.pickFiles( type: fp.FileType.custom, allowedExtensions: const ['pdf'], @@ -304,7 +255,12 @@ class PdfSessionViewModel extends ChangeNotifier { } } if (effectiveBytes != null) { - await openPdf(path: path, bytes: effectiveBytes, fileName: name); + await openPdf( + path: path, + bytes: effectiveBytes, + fileName: name, + router: router, + ); } else { debugPrint( '[PdfSessionViewModel] No PDF data available to open (path=$path, name=$name)', @@ -316,6 +272,7 @@ class PdfSessionViewModel extends ChangeNotifier { String? path, required Uint8List bytes, String? fileName, + required GoRouter router, }) async { int pageCount = 1; // default try { @@ -355,36 +312,44 @@ class PdfSessionViewModel extends ChangeNotifier { ); debugPrint(st.toString()); } + XFile newFile; if (path != null && path.isNotEmpty) { - _currentFile = XFile(path); + newFile = XFile(path); } else if ((fileName != null && fileName.isNotEmpty)) { // Keep in-memory XFile so .name is available for suggestion try { - _currentFile = XFile.fromData( + newFile = XFile.fromData( bytes, name: fileName, mimeType: 'application/pdf', ); } catch (e, st) { - _currentFile = XFile(fileName); + newFile = XFile(fileName); debugPrint( '[PdfSessionViewModel] Failed to create XFile.fromData name=$fileName error=$e', ); debugPrint(st.toString()); } } else { - _currentFile = XFile(''); + newFile = XFile(''); } // Update display name: prefer explicit fileName (from picker/drop), // fall back to basename of path, otherwise empty. + String newDisplayFileName; if (fileName != null && fileName.isNotEmpty) { - _displayFileName = fileName; + newDisplayFileName = fileName; } else if (path != null && path.isNotEmpty) { - _displayFileName = path.split('/').last.split('\\').last; + newDisplayFileName = path.split('/').last.split('\\').last; } else { - _displayFileName = ''; + newDisplayFileName = ''; } + + state = state.copyWith( + currentFile: newFile, + displayFileName: newDisplayFileName, + ); + // If fast path failed to set repository (e.g., exception earlier), fallback to async derive. if (ref.read(documentRepositoryProvider).pickedPdfBytes != bytes) { debugPrint( @@ -397,21 +362,18 @@ class PdfSessionViewModel extends ChangeNotifier { // relies on this behavior. Placements are reset by openPicked() above. debugPrint('[PdfSessionViewModel] Navigating to /pdf'); router.go('/pdf'); - debugPrint('[PdfSessionViewModel] Notifying listeners after open'); - notifyListeners(); + debugPrint('[PdfSessionViewModel] State updated after open'); } - void closePdf() { + void closePdf(GoRouter router) { ref.read(documentRepositoryProvider.notifier).close(); ref.read(signatureCardRepositoryProvider.notifier).clearAll(); - _currentFile = XFile(''); - _displayFileName = ''; + state = state.copyWith(currentFile: XFile(''), displayFileName: ''); router.go('/'); - notifyListeners(); } } final pdfSessionViewModelProvider = - ChangeNotifierProvider.family((ref, router) { - return PdfSessionViewModel(ref: ref, router: router); - }); + NotifierProvider( + PdfSessionViewModel.new, + ); diff --git a/lib/ui/features/pdf/view_model/pdf_view_state.dart b/lib/ui/features/pdf/view_model/pdf_view_state.dart new file mode 100644 index 0000000..a7abe1c --- /dev/null +++ b/lib/ui/features/pdf/view_model/pdf_view_state.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/document_version.dart'; +import 'package:pdfrx/pdfrx.dart'; + +/// Immutable state for PdfViewModel +class PdfViewState { + final PdfViewerController controller; + final int currentPage; + final bool useMockViewer; + final Rect? activeRect; + final Set lockedPlacements; + final DocumentVersion documentVersion; + + const PdfViewState({ + required this.controller, + required this.currentPage, + required this.useMockViewer, + this.activeRect, + required this.lockedPlacements, + required this.documentVersion, + }); + + factory PdfViewState.initial({bool? useMockViewer}) { + return PdfViewState( + controller: PdfViewerController(), + currentPage: 1, + useMockViewer: + useMockViewer ?? + const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false), + activeRect: null, + lockedPlacements: const {}, + documentVersion: DocumentVersion.initial(), + ); + } + + PdfViewState copyWith({ + PdfViewerController? controller, + int? currentPage, + bool? useMockViewer, + Rect? activeRect, + bool clearActiveRect = false, + Set? lockedPlacements, + DocumentVersion? documentVersion, + }) { + return PdfViewState( + controller: controller ?? this.controller, + currentPage: currentPage ?? this.currentPage, + useMockViewer: useMockViewer ?? this.useMockViewer, + activeRect: clearActiveRect ? null : (activeRect ?? this.activeRect), + lockedPlacements: lockedPlacements ?? this.lockedPlacements, + documentVersion: documentVersion ?? this.documentVersion, + ); + } +} diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart index 1579dcf..626f6b2 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_area.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart @@ -112,11 +112,12 @@ class _PdfPageAreaState extends ConsumerState { @override Widget build(BuildContext context) { - final pdfViewModel = ref.watch(pdfViewModelProvider); + final pdfViewState = ref.watch(pdfViewModelProvider); + final pdfViewModel = ref.read(pdfViewModelProvider.notifier); final pdf = pdfViewModel.document; const pageViewMode = 'continuous'; - // React to PdfViewModel currentPage changes. With ChangeNotifierProvider, - // prev/next are the same instance, so compare to a local cache. + // React to PdfViewModel currentPage changes. With NotifierProvider, + // prev/next are now different state objects, so we can compare them directly. ref.listen(pdfViewModelProvider, (prev, next) { // Only perform manual scrolling in mock viewer mode. In real viewer mode, // PdfViewerController + onPageChanged keep things in sync, and attempting diff --git a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart index c41bbf1..cbb53e3 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_overlays.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_overlays.dart @@ -36,12 +36,12 @@ class PdfPageOverlays extends ConsumerWidget { if (!overlaysEnabled) { return const SizedBox.shrink(); } - final pdfViewModel = ref.watch(pdfViewModelProvider); + final pdfViewState = ref.watch(pdfViewModelProvider); // Subscribe to document changes to rebuild overlays final pdf = ref.watch(documentRepositoryProvider); final placed = pdf.placementsByPage[pageNumber] ?? const []; - final activeRect = pdfViewModel.activeRect; + final activeRect = pdfViewState.activeRect; final widgets = []; // Base DragTarget filling the whole page to accept drops from signature cards. @@ -116,10 +116,10 @@ class PdfPageOverlays extends ConsumerWidget { // TODO:Add active overlay if present and not using mock (mock has its own) - final useMock = pdfViewModel.useMockViewer; + final useMock = pdfViewState.useMockViewer; if (!useMock && activeRect != null && - pageNumber == pdfViewModel.currentPage) { + pageNumber == pdfViewState.currentPage) { widgets.add( LayoutBuilder( builder: (context, constraints) { diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index 2baa017..5b2e55d 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -195,7 +195,7 @@ class _PdfSignatureHomePageState extends ConsumerState { if (!kIsWeb) { final path = await ref - .read(pdfExportViewModelProvider) + .read(pdfExportViewModelProvider.notifier) .pickSavePathWithSuggestedName(suggested); if (path == null || path.trim().isEmpty) return; final fullPath = _ensurePdfExtension(path.trim()); @@ -203,7 +203,7 @@ class _PdfSignatureHomePageState extends ConsumerState { // ignore: avoid_print debugPrint('_saveSignedPdf: picked save path ' + fullPath); ok = await ref - .read(pdfExportViewModelProvider) + .read(pdfExportViewModelProvider.notifier) .exportToPath( outputPath: fullPath, uiPageSize: _pageSize, @@ -329,7 +329,8 @@ class _PdfSignatureHomePageState extends ConsumerState { }(), child: Consumer( builder: (context, ref, child) { - final pdfViewModel = ref.watch(pdfViewModelProvider); + final pdfViewState = ref.watch(pdfViewModelProvider); + final pdfViewModel = ref.read(pdfViewModelProvider.notifier); final pdf = pdfViewModel.document; final documentRef = diff --git a/lib/ui/features/pdf/widgets/pdf_toolbar.dart b/lib/ui/features/pdf/widgets/pdf_toolbar.dart index 362d4d5..0975a4b 100644 --- a/lib/ui/features/pdf/widgets/pdf_toolbar.dart +++ b/lib/ui/features/pdf/widgets/pdf_toolbar.dart @@ -59,11 +59,11 @@ class _PdfToolbarState extends ConsumerState { @override Widget build(BuildContext context) { - final pdfViewModel = ref.watch(pdfViewModelProvider); + final pdfViewState = ref.watch(pdfViewModelProvider); final pdf = ref.watch( documentRepositoryProvider, ); // Watch document directly for updates - final currentPage = pdfViewModel.currentPage; + final currentPage = pdfViewState.currentPage; final l = AppLocalizations.of(context); final pageInfo = l.pageInfo(currentPage, pdf.pageCount); diff --git a/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart b/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart index dadf2d6..9298181 100644 --- a/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart +++ b/lib/ui/features/pdf/widgets/pdf_viewer_widget.dart @@ -51,7 +51,7 @@ class _PdfViewerWidgetState extends ConsumerState { final bytes = doc.pickedPdfBytes!; if (!identical(bytes, _lastBytes)) { _lastBytes = bytes; - final viewModel = ref.read(pdfViewModelProvider); + final viewModel = ref.read(pdfViewModelProvider.notifier); debugPrint( '[PdfViewerWidget] New PDF bytes detected -> ${viewModel.documentSourceName}', ); @@ -84,9 +84,9 @@ class _PdfViewerWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - final pdfViewModel = ref.watch(pdfViewModelProvider); + final pdfViewState = ref.watch(pdfViewModelProvider); final document = ref.watch(documentRepositoryProvider); - final useMock = pdfViewModel.useMockViewer; + final useMock = pdfViewState.useMockViewer; _updateDocRef(document); if (useMock) { @@ -113,7 +113,7 @@ class _PdfViewerWidgetState extends ConsumerState { } return Center(child: Text(text)); } - final pdfViewModel = ref.read(pdfViewModelProvider); + final pdfViewModel = ref.read(pdfViewModelProvider.notifier); final viewerKey = widget.innerViewerKey ?? Key('pdf_viewer_${pdfViewModel.documentSourceName}'); diff --git a/lib/ui/features/pdf/widgets/signature_overlay.dart b/lib/ui/features/pdf/widgets/signature_overlay.dart index 905812e..2ad7f31 100644 --- a/lib/ui/features/pdf/widgets/signature_overlay.dart +++ b/lib/ui/features/pdf/widgets/signature_overlay.dart @@ -42,9 +42,10 @@ class SignatureOverlay extends ConsumerWidget { Future _showContextMenu(Offset position) async { final pdfViewModel = ref.read(pdfViewModelProvider.notifier); - final isLocked = ref - .watch(pdfViewModelProvider) - .isPlacementLocked(page: pageNumber, index: placedIndex); + final isLocked = pdfViewModel.isPlacementLocked( + page: pageNumber, + index: placedIndex, + ); final selected = await showMenu( context: context, position: RelativeRect.fromLTRB( @@ -92,7 +93,7 @@ class SignatureOverlay extends ConsumerWidget { allowContentFlipping: false, onChanged: ref - .watch(pdfViewModelProvider) + .read(pdfViewModelProvider.notifier) .isPlacementLocked( page: pageNumber, index: placedIndex, @@ -118,7 +119,7 @@ class SignatureOverlay extends ConsumerWidget { // Keep default handles; you can customize later if needed contentBuilder: (context, boxRect, flip) { final isLocked = ref - .watch(pdfViewModelProvider) + .read(pdfViewModelProvider.notifier) .isPlacementLocked(page: pageNumber, index: placedIndex); return DecoratedBox( decoration: BoxDecoration( diff --git a/lib/ui/features/signature/view_model/dragging_signature_view_model.dart b/lib/ui/features/signature/view_model/dragging_signature_view_model.dart index dd13ebe..498470c 100644 --- a/lib/ui/features/signature/view_model/dragging_signature_view_model.dart +++ b/lib/ui/features/signature/view_model/dragging_signature_view_model.dart @@ -1,6 +1,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Global flag indicating whether a signature card is currently being dragged. -final isDraggingSignatureViewModelProvider = StateProvider( - (ref) => false, -); +class IsDraggingSignatureNotifier extends Notifier { + @override + bool build() => false; + + void setDragging(bool value) => state = value; +} + +final isDraggingSignatureViewModelProvider = + NotifierProvider( + IsDraggingSignatureNotifier.new, + ); diff --git a/lib/ui/features/signature/widgets/signature_card_view.dart b/lib/ui/features/signature/widgets/signature_card_view.dart index ffd9c63..19c752d 100644 --- a/lib/ui/features/signature/widgets/signature_card_view.dart +++ b/lib/ui/features/signature/widgets/signature_card_view.dart @@ -158,10 +158,14 @@ class _SignatureCardViewState extends ConsumerState { ), ), onDragStarted: () { - ref.read(isDraggingSignatureViewModelProvider.notifier).state = true; + ref + .read(isDraggingSignatureViewModelProvider.notifier) + .setDragging(true); }, onDragEnd: (_) { - ref.read(isDraggingSignatureViewModelProvider.notifier).state = false; + ref + .read(isDraggingSignatureViewModelProvider.notifier) + .setDragging(false); }, feedback: Opacity( opacity: 0.9, diff --git a/lib/ui/features/welcome/view_model/welcome_view_model.dart b/lib/ui/features/welcome/view_model/welcome_view_model.dart index 8a17753..e170807 100644 --- a/lib/ui/features/welcome/view_model/welcome_view_model.dart +++ b/lib/ui/features/welcome/view_model/welcome_view_model.dart @@ -20,8 +20,8 @@ class WelcomeViewModel { } // Use PdfSessionViewModel to open and navigate. - final session = ref.read(pdfSessionViewModelProvider(router)); - await session.openPdf(path: path, bytes: bytes); + final session = ref.read(pdfSessionViewModelProvider.notifier); + await session.openPdf(path: path, bytes: bytes, router: router); } } diff --git a/lib/ui/features/welcome/widgets/welcome_screen.dart b/lib/ui/features/welcome/widgets/welcome_screen.dart index 6aab832..681ef13 100644 --- a/lib/ui/features/welcome/widgets/welcome_screen.dart +++ b/lib/ui/features/welcome/widgets/welcome_screen.dart @@ -25,9 +25,6 @@ class _DropReadableFromDesktop implements DropReadable { Future readAsBytes() => inner.readAsBytes(); } -// Allow injecting Riverpod's read function from either WidgetRef or ProviderContainer -typedef Reader = T Function(ProviderListenable provider); - // Select first .pdf file (case-insensitive) or fall back to first entry. Future handleDroppedFiles( Future Function({String? path, Uint8List? bytes, String? fileName}) diff --git a/pubspec.yaml b/pubspec.yaml index 3805788..d949a58 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: pdf_signature description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -42,28 +42,28 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - flutter_riverpod: ^2.6.1 + flutter_riverpod: ^3.0.3 shared_preferences: ^2.5.3 flutter_dotenv: ^6.0.0 path_provider: ^2.1.5 - pdfrx: ^2.1.9 + pdfrx: ^2.2.16 pdf: ^3.10.8 # printing: ^5.14.2 # extension of pdf pkg hand_signature: ^3.1.0+2 image: ^4.2.0 result_dart: ^2.1.1 - go_router: ^16.2.0 + go_router: ^17.0.0 flutter_localizations: sdk: flutter intl: any flutter_localized_locales: ^2.0.5 - desktop_drop: ^0.6.1 + desktop_drop: ^0.7.0 multi_split_view: ^3.6.1 freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 share_plus: ^12.0.0 logging: ^1.3.0 - riverpod_annotation: ^2.6.1 + riverpod_annotation: ^3.0.3 colorfilter_generator: ^0.0.8 flutter_box_transform: ^0.4.7 file_picker: ^10.3.3 @@ -78,12 +78,12 @@ dev_dependencies: integration_test: sdk: flutter build_runner: ^2.4.12 - build: ^3.0.2 - bdd_widget_test: ^2.0.1 + build: ^4.0.3 + bdd_widget_test: ^2.1.3 mocktail: ^1.0.4 freezed: ^3.0.0 - custom_lint: ^0.7.6 - riverpod_lint: ^2.6.5 + # custom_lint: ^0.7.6 + # riverpod_lint: ^2.6.5 go_router_builder: ^4.0.1 # The "flutter_lints" package below contains a set of recommended lints to @@ -141,7 +141,6 @@ flutter: # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package - flutter_launcher_icons: image_path: "assets/icon/pdf_signature-icon.png" android: true