chore: migrate to riverpod3
feat: update code to riverpod3
This commit is contained in:
parent
f1b1141da8
commit
0587e50360
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -8,12 +8,14 @@ import 'package:pdfrx/pdfrx.dart';
|
|||
|
||||
import '../../domain/models/model.dart';
|
||||
|
||||
class DocumentStateNotifier extends StateNotifier<Document> {
|
||||
DocumentStateNotifier({ExportService? service})
|
||||
: _service = service ?? ExportService(),
|
||||
super(Document.initial());
|
||||
class DocumentStateNotifier extends Notifier<Document> {
|
||||
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<Document> {
|
|||
}
|
||||
|
||||
final documentRepositoryProvider =
|
||||
StateNotifierProvider<DocumentStateNotifier, Document>(
|
||||
(ref) => DocumentStateNotifier(),
|
||||
NotifierProvider<DocumentStateNotifier, Document>(
|
||||
DocumentStateNotifier.new,
|
||||
);
|
||||
|
||||
/// --- Isolate helpers of DocumentRepository ---
|
||||
|
|
|
|||
|
|
@ -89,9 +89,39 @@ String _normalizeLanguageTag(String tag) {
|
|||
return tags.contains('en') ? 'en' : tags.first;
|
||||
}
|
||||
|
||||
class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
|
||||
class PreferencesStateNotifier extends Notifier<PreferencesState> {
|
||||
late final SharedPreferences _prefs;
|
||||
final Completer<void> _ready = Completer<void>();
|
||||
|
||||
@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<PreferencesState> {
|
|||
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<void> _init(SharedPreferences? injected) async {
|
||||
_prefs = injected ?? await SharedPreferences.getInstance();
|
||||
Future<void> _load() async {
|
||||
// Load persisted values (with sane defaults)
|
||||
final loaded = PreferencesState(
|
||||
theme: _prefs.getString(_kTheme) ?? 'system',
|
||||
|
|
@ -291,9 +306,8 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
|
|||
}
|
||||
|
||||
final preferencesRepositoryProvider =
|
||||
StateNotifierProvider<PreferencesStateNotifier, PreferencesState>((ref) {
|
||||
// Construct with lazy SharedPreferences initialization.
|
||||
return PreferencesStateNotifier();
|
||||
});
|
||||
NotifierProvider<PreferencesStateNotifier, PreferencesState>(
|
||||
PreferencesStateNotifier.new,
|
||||
);
|
||||
|
||||
// pageViewModeProvider removed; the app always runs in continuous mode.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:pdf_signature/domain/models/model.dart';
|
||||
|
||||
///
|
||||
class SignatureAssetRepository extends StateNotifier<List<SignatureAsset>> {
|
||||
SignatureAssetRepository() : super(const []);
|
||||
class SignatureAssetRepository extends Notifier<List<SignatureAsset>> {
|
||||
@override
|
||||
List<SignatureAsset> 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<List<SignatureAsset>> {
|
|||
}
|
||||
|
||||
final signatureAssetRepositoryProvider =
|
||||
StateNotifierProvider<SignatureAssetRepository, List<SignatureAsset>>(
|
||||
(ref) => SignatureAssetRepository(),
|
||||
NotifierProvider<SignatureAssetRepository, List<SignatureAsset>>(
|
||||
SignatureAssetRepository.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,8 +54,9 @@ class CachedSignatureCard {
|
|||
);
|
||||
}
|
||||
|
||||
class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
|
||||
SignatureCardStateNotifier() : super(const []);
|
||||
class SignatureCardStateNotifier extends Notifier<List<SignatureCard>> {
|
||||
@override
|
||||
List<SignatureCard> build() => const [];
|
||||
|
||||
// Internal storage with cache
|
||||
final List<CachedSignatureCard> _cards = <CachedSignatureCard>[];
|
||||
|
|
@ -207,6 +208,6 @@ class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
|
|||
}
|
||||
|
||||
final signatureCardRepositoryProvider =
|
||||
StateNotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
|
||||
(ref) => SignatureCardStateNotifier(),
|
||||
NotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
|
||||
SignatureCardStateNotifier.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,15 +26,16 @@ final routerProvider = Provider<GoRouter>((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<GoRouter>((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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PdfExportState> {
|
||||
// Dependencies (injectable via constructor for tests)
|
||||
// Zero-arg picker retained for backward compatibility with tests.
|
||||
final Future<String?> Function() _savePathPicker;
|
||||
late final Future<String?> Function() _savePathPicker;
|
||||
// Preferred picker that accepts a suggested filename.
|
||||
final Future<String?> Function(String suggestedName)
|
||||
late final Future<String?> Function(String suggestedName)
|
||||
_savePathPickerWithSuggestedName;
|
||||
|
||||
PdfExportViewModel(
|
||||
this.ref, {
|
||||
Future<String?> Function()? savePathPicker,
|
||||
Future<String?> 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<PdfExportViewModel>((
|
||||
ref,
|
||||
) {
|
||||
return PdfExportViewModel(ref);
|
||||
});
|
||||
final pdfExportViewModelProvider =
|
||||
NotifierProvider<PdfExportViewModel, PdfExportState>(
|
||||
PdfExportViewModel.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PdfViewState> {
|
||||
@override
|
||||
PdfViewState build() {
|
||||
return PdfViewState.initial();
|
||||
}
|
||||
|
||||
// Locked placements: Set of (page, index) tuples
|
||||
final Set<String> _lockedPlacements = {};
|
||||
Set<String> 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<String> 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<String>.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<String>.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<String>.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<PdfViewModel>((ref) {
|
||||
return PdfViewModel(ref);
|
||||
});
|
||||
final pdfViewModelProvider = NotifierProvider<PdfViewModel, PdfViewState>(
|
||||
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/<UUID>, 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_<original>.pdf".
|
||||
String _displayFileName = '';
|
||||
class PdfSessionViewModel extends Notifier<PdfSessionState> {
|
||||
@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<void> pickAndOpenPdf() async {
|
||||
Future<void> 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<PdfSessionViewModel, GoRouter>((ref, router) {
|
||||
return PdfSessionViewModel(ref: ref, router: router);
|
||||
});
|
||||
NotifierProvider<PdfSessionViewModel, PdfSessionState>(
|
||||
PdfSessionViewModel.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String> 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<String>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -112,11 +112,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
|||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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 <SignaturePlacement>[];
|
||||
final activeRect = pdfViewModel.activeRect;
|
||||
final activeRect = pdfViewState.activeRect;
|
||||
final widgets = <Widget>[];
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
|||
|
||||
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<PdfSignatureHomePage> {
|
|||
// 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<PdfSignatureHomePage> {
|
|||
}(),
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
|||
|
||||
@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);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
|||
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<PdfViewerWidget> {
|
|||
|
||||
@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<PdfViewerWidget> {
|
|||
}
|
||||
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}');
|
||||
|
|
|
|||
|
|
@ -42,9 +42,10 @@ class SignatureOverlay extends ConsumerWidget {
|
|||
|
||||
Future<void> _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<String>(
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<bool>(
|
||||
(ref) => false,
|
||||
);
|
||||
class IsDraggingSignatureNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
void setDragging(bool value) => state = value;
|
||||
}
|
||||
|
||||
final isDraggingSignatureViewModelProvider =
|
||||
NotifierProvider<IsDraggingSignatureNotifier, bool>(
|
||||
IsDraggingSignatureNotifier.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -158,10 +158,14 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
|
|||
),
|
||||
),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ class _DropReadableFromDesktop implements DropReadable {
|
|||
Future<Uint8List> readAsBytes() => inner.readAsBytes();
|
||||
}
|
||||
|
||||
// Allow injecting Riverpod's read function from either WidgetRef or ProviderContainer
|
||||
typedef Reader = T Function<T>(ProviderListenable<T> provider);
|
||||
|
||||
// Select first .pdf file (case-insensitive) or fall back to first entry.
|
||||
Future<void> handleDroppedFiles(
|
||||
Future<void> Function({String? path, Uint8List? bytes, String? fileName})
|
||||
|
|
|
|||
21
pubspec.yaml
21
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue