Compare commits
No commits in common. "feaf7aee9f7312fdbc3df2de1fac39a8c483e454" and "26a0c93390a464f758903280cd773722f86461d7" have entirely different histories.
feaf7aee9f
...
26a0c93390
|
|
@ -61,17 +61,17 @@ void main() {
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
final vm = container.read(pdfViewModelProvider);
|
final vm = container.read(pdfViewModelProvider);
|
||||||
expect(vm.currentPage, 1);
|
expect(vm, 1);
|
||||||
|
|
||||||
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
|
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.pump(const Duration(milliseconds: 120));
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
expect(container.read(pdfViewModelProvider), 2);
|
||||||
|
|
||||||
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
|
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.pump(const Duration(milliseconds: 120));
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
expect(container.read(pdfViewModelProvider), 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('PDF View: zoom in/out', (tester) async {
|
testWidgets('PDF View: zoom in/out', (tester) async {
|
||||||
|
|
@ -166,7 +166,7 @@ void main() {
|
||||||
|
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
expect(container.read(pdfViewModelProvider), 1);
|
||||||
|
|
||||||
final pagesSidebar = find.byType(PagesSidebar);
|
final pagesSidebar = find.byType(PagesSidebar);
|
||||||
expect(pagesSidebar, findsOneWidget);
|
expect(pagesSidebar, findsOneWidget);
|
||||||
|
|
@ -180,7 +180,7 @@ void main() {
|
||||||
await tester.tap(page3Thumbnail);
|
await tester.tap(page3Thumbnail);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
expect(container.read(pdfViewModelProvider), 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
|
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
|
||||||
|
|
@ -221,7 +221,7 @@ void main() {
|
||||||
|
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
||||||
final container = ProviderScope.containerOf(ctx);
|
final container = ProviderScope.containerOf(ctx);
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
expect(container.read(pdfViewModelProvider), 1);
|
||||||
|
|
||||||
final pagesSidebar = find.byType(PagesSidebar);
|
final pagesSidebar = find.byType(PagesSidebar);
|
||||||
expect(pagesSidebar, findsOneWidget);
|
expect(pagesSidebar, findsOneWidget);
|
||||||
|
|
@ -229,85 +229,12 @@ void main() {
|
||||||
await tester.drag(pagesSidebar, const Offset(0, -200));
|
await tester.drag(pagesSidebar, const Offset(0, -200));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Page number '1' may appear in multiple text widgets (e.g., overlay/toolbar); restrict to sidebar.
|
expect(find.text('1'), findsOneWidget);
|
||||||
final page1InSidebar = find.descendant(
|
expect(container.read(pdfViewModelProvider), 1);
|
||||||
of: pagesSidebar,
|
|
||||||
matching: find.text('1'),
|
|
||||||
);
|
|
||||||
expect(page1InSidebar, findsOneWidget);
|
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
|
||||||
|
|
||||||
// Select page 2 thumbnail and verify page changes
|
// Select page 2 thumbnail and verify page changes
|
||||||
final page2InSidebar = find.descendant(
|
await tester.tap(find.text('2'));
|
||||||
of: pagesSidebar,
|
|
||||||
matching: find.text('2'),
|
|
||||||
);
|
|
||||||
await tester.tap(page2InSidebar);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 2);
|
expect(container.read(pdfViewModelProvider), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('PDF View: scroll thumbnails to reveal and select last page', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
final pdfBytes =
|
|
||||||
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
preferencesRepositoryProvider.overrideWith(
|
|
||||||
(ref) => PreferencesStateNotifier(prefs),
|
|
||||||
),
|
|
||||||
documentRepositoryProvider.overrideWith(
|
|
||||||
(ref) =>
|
|
||||||
DocumentStateNotifier()
|
|
||||||
..openPicked(pageCount: 3, bytes: pdfBytes),
|
|
||||||
),
|
|
||||||
pdfViewModelProvider.overrideWith(
|
|
||||||
(ref) => PdfViewModel(ref, useMockViewer: false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: MaterialApp(
|
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
locale: const Locale('en'),
|
|
||||||
home: PdfSignatureHomePage(
|
|
||||||
onPickPdf: () async {},
|
|
||||||
onClosePdf: () {},
|
|
||||||
currentFile: fs.XFile('test.pdf'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final ctx = tester.element(find.byType(PdfSignatureHomePage));
|
|
||||||
final container = ProviderScope.containerOf(ctx);
|
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 1);
|
|
||||||
|
|
||||||
final pagesSidebar = find.byType(PagesSidebar);
|
|
||||||
expect(pagesSidebar, findsOneWidget);
|
|
||||||
|
|
||||||
// Ensure page 3 not initially in view by trying to find it and allowing that it might be offstage.
|
|
||||||
// Perform a scroll/drag to bring page 3 into view.
|
|
||||||
await tester.drag(pagesSidebar, const Offset(0, -400));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final page3 = find.descendant(of: pagesSidebar, matching: find.text('3'));
|
|
||||||
expect(page3, findsOneWidget);
|
|
||||||
await tester.tap(page3);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
|
||||||
|
|
||||||
// Scroll back upward and verify selection persists.
|
|
||||||
await tester.drag(pagesSidebar, const Offset(0, 300));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(container.read(pdfViewModelProvider).currentPage, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
//TODO: Scroll Thumbs
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class GraphicAdjust {
|
||||||
|
|
||||||
const GraphicAdjust({
|
const GraphicAdjust({
|
||||||
this.contrast = 1.0,
|
this.contrast = 1.0,
|
||||||
this.brightness = 1.0,
|
this.brightness = 0.0,
|
||||||
this.bgRemoval = false,
|
this.bgRemoval = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,50 +5,126 @@ import 'package:go_router/go_router.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
|
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
|
||||||
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
|
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
|
||||||
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
||||||
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
// PdfManager removed: responsibilities moved into PdfSessionViewModel.
|
class PdfManager {
|
||||||
|
final DocumentStateNotifier _documentNotifier;
|
||||||
|
final SignatureCardStateNotifier _signatureCardNotifier;
|
||||||
|
final GoRouter _router;
|
||||||
|
|
||||||
|
fs.XFile _currentFile = fs.XFile('');
|
||||||
|
|
||||||
|
PdfManager({
|
||||||
|
required DocumentStateNotifier documentNotifier,
|
||||||
|
required SignatureCardStateNotifier signatureCardNotifier,
|
||||||
|
required GoRouter router,
|
||||||
|
}) : _documentNotifier = documentNotifier,
|
||||||
|
_signatureCardNotifier = signatureCardNotifier,
|
||||||
|
_router = router;
|
||||||
|
|
||||||
|
fs.XFile get currentFile => _currentFile;
|
||||||
|
|
||||||
|
Future<void> openPdf({String? path, Uint8List? bytes}) async {
|
||||||
|
int pageCount = 1; // default
|
||||||
|
if (bytes != null) {
|
||||||
|
try {
|
||||||
|
final doc = await PdfDocument.openData(bytes);
|
||||||
|
pageCount = doc.pages.length;
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file reference if path is provided
|
||||||
|
if (path != null) {
|
||||||
|
_currentFile = fs.XFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
_documentNotifier.openPicked(pageCount: pageCount, bytes: bytes);
|
||||||
|
_signatureCardNotifier.clearAll();
|
||||||
|
|
||||||
|
// Navigate to PDF screen after successfully opening PDF
|
||||||
|
_router.go('/pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
void closePdf() {
|
||||||
|
_documentNotifier.close();
|
||||||
|
_signatureCardNotifier.clearAll();
|
||||||
|
_currentFile = fs.XFile('');
|
||||||
|
|
||||||
|
// Navigate back to welcome screen when closing PDF
|
||||||
|
_router.go('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickAndOpenPdf() async {
|
||||||
|
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
||||||
|
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
||||||
|
if (file != null) {
|
||||||
|
Uint8List? bytes;
|
||||||
|
try {
|
||||||
|
bytes = await file.readAsBytes();
|
||||||
|
} catch (_) {
|
||||||
|
bytes = null;
|
||||||
|
}
|
||||||
|
await openPdf(path: file.path, bytes: bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
// Determine initial location based on current document state.
|
// Create PdfManager instance with dependencies
|
||||||
// Access the state via the provider (not via the notifier's protected .state).
|
final documentNotifier = ref.read(documentRepositoryProvider.notifier);
|
||||||
final docState = ref.read(documentRepositoryProvider);
|
final signatureCardNotifier = ref.read(
|
||||||
final initialLocation = docState.loaded ? '/pdf' : '/';
|
signatureCardRepositoryProvider.notifier,
|
||||||
// Session view model will be obtained inside each route builder; no shared
|
);
|
||||||
// late variable (avoids LateInitializationError on rebuilds).
|
|
||||||
|
|
||||||
|
// Create a navigator key for the router
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
late final GoRouter router; // declare before use in builders
|
|
||||||
|
// Create a late variable for the router
|
||||||
|
late final GoRouter router;
|
||||||
|
|
||||||
|
// Create PdfManager with router dependency (will be set after router creation)
|
||||||
|
late final PdfManager pdfManager;
|
||||||
|
|
||||||
|
// If tests pre-load a document, start at /pdf so sidebars and controls
|
||||||
|
// are present immediately.
|
||||||
|
final initialLocation = documentNotifier.debugState.loaded ? '/pdf' : '/';
|
||||||
|
|
||||||
router = GoRouter(
|
router = GoRouter(
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
builder: (context, state) {
|
builder:
|
||||||
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
|
(context, state) => WelcomeScreen(
|
||||||
return WelcomeScreen(
|
onPickPdf: () => pdfManager.pickAndOpenPdf(),
|
||||||
onPickPdf: () => sessionVm.pickAndOpenPdf(),
|
onOpenPdf:
|
||||||
onOpenPdf:
|
({String? path, Uint8List? bytes, String? fileName}) =>
|
||||||
({String? path, Uint8List? bytes, String? fileName}) =>
|
pdfManager.openPdf(path: path, bytes: bytes),
|
||||||
sessionVm.openPdf(path: path, bytes: bytes),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/pdf',
|
path: '/pdf',
|
||||||
builder: (context, state) {
|
builder:
|
||||||
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
|
(context, state) => PdfSignatureHomePage(
|
||||||
return PdfSignatureHomePage(
|
onPickPdf: () => pdfManager.pickAndOpenPdf(),
|
||||||
onPickPdf: () => sessionVm.pickAndOpenPdf(),
|
onClosePdf: () => pdfManager.closePdf(),
|
||||||
onClosePdf: () => sessionVm.closePdf(),
|
currentFile: pdfManager.currentFile,
|
||||||
currentFile: sessionVm.currentFile,
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Now create PdfManager with the router
|
||||||
|
pdfManager = PdfManager(
|
||||||
|
documentNotifier: documentNotifier,
|
||||||
|
signatureCardNotifier: signatureCardNotifier,
|
||||||
|
router: router,
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
|
||||||
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
|
||||||
import 'package:pdf_signature/domain/models/model.dart';
|
import 'package:pdf_signature/domain/models/model.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:file_selector/file_selector.dart' as fs;
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
class PdfViewModel extends ChangeNotifier {
|
class PdfViewModel extends ChangeNotifier {
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
@ -64,8 +62,28 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> openPdf({required String path, Uint8List? bytes}) async {
|
||||||
|
int pageCount = 1;
|
||||||
|
if (bytes != null) {
|
||||||
|
try {
|
||||||
|
final doc = await PdfDocument.openData(bytes);
|
||||||
|
pageCount = doc.pages.length;
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref
|
||||||
|
.read(documentRepositoryProvider.notifier)
|
||||||
|
.openPicked(pageCount: pageCount, bytes: bytes);
|
||||||
|
clearAllSignatureCards();
|
||||||
|
|
||||||
|
currentPage = 1; // Reset current page to 1
|
||||||
|
}
|
||||||
|
|
||||||
// Document repository methods
|
// Document repository methods
|
||||||
// Lifecycle (open/close) removed: handled exclusively by PdfSessionViewModel.
|
void closeDocument() {
|
||||||
|
ref.read(documentRepositoryProvider.notifier).close();
|
||||||
|
}
|
||||||
|
|
||||||
void setPageCount(int count) {
|
void setPageCount(int count) {
|
||||||
ref.read(documentRepositoryProvider.notifier).setPageCount(count);
|
ref.read(documentRepositoryProvider.notifier).setPageCount(count);
|
||||||
|
|
@ -179,63 +197,3 @@ class PdfViewModel extends ChangeNotifier {
|
||||||
final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
|
final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
|
||||||
return PdfViewModel(ref);
|
return PdfViewModel(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 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;
|
|
||||||
fs.XFile _currentFile = fs.XFile('');
|
|
||||||
|
|
||||||
PdfSessionViewModel({required this.ref, required this.router});
|
|
||||||
|
|
||||||
fs.XFile get currentFile => _currentFile;
|
|
||||||
|
|
||||||
Future<void> pickAndOpenPdf() async {
|
|
||||||
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
|
|
||||||
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
|
|
||||||
if (file != null) {
|
|
||||||
Uint8List? bytes;
|
|
||||||
try {
|
|
||||||
bytes = await file.readAsBytes();
|
|
||||||
} catch (_) {
|
|
||||||
bytes = null;
|
|
||||||
}
|
|
||||||
await openPdf(path: file.path, bytes: bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> openPdf({String? path, Uint8List? bytes}) async {
|
|
||||||
int pageCount = 1; // default
|
|
||||||
if (bytes != null) {
|
|
||||||
try {
|
|
||||||
final doc = await PdfDocument.openData(bytes);
|
|
||||||
pageCount = doc.pages.length;
|
|
||||||
} catch (_) {
|
|
||||||
// ignore invalid bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (path != null) {
|
|
||||||
_currentFile = fs.XFile(path);
|
|
||||||
}
|
|
||||||
ref
|
|
||||||
.read(documentRepositoryProvider.notifier)
|
|
||||||
.openPicked(pageCount: pageCount, bytes: bytes);
|
|
||||||
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
|
|
||||||
router.go('/pdf');
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void closePdf() {
|
|
||||||
ref.read(documentRepositoryProvider.notifier).close();
|
|
||||||
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
|
|
||||||
_currentFile = fs.XFile('');
|
|
||||||
router.go('/');
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final pdfSessionViewModelProvider =
|
|
||||||
ChangeNotifierProvider.family<PdfSessionViewModel, GoRouter>((ref, router) {
|
|
||||||
return PdfSessionViewModel(ref: ref, router: router);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,13 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: const Key('btn_canvas_confirm'),
|
key: const Key('btn_canvas_confirm'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// Export signature to PNG bytes first
|
// If requested, close the sheet immediately without waiting
|
||||||
|
// for the potentially heavy export.
|
||||||
|
if (widget.closeOnConfirmImmediately &&
|
||||||
|
Navigator.canPop(context)) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
// Export signature to PNG bytes
|
||||||
final byteData = await _control.toImage(
|
final byteData = await _control.toImage(
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 512,
|
height: 512,
|
||||||
|
|
@ -62,15 +68,12 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
);
|
);
|
||||||
final bytes = byteData?.buffer.asUint8List();
|
final bytes = byteData?.buffer.asUint8List();
|
||||||
widget.debugBytesSink?.value = bytes;
|
widget.debugBytesSink?.value = bytes;
|
||||||
|
|
||||||
// Handle callbacks and navigation
|
|
||||||
if (widget.onConfirm != null) {
|
if (widget.onConfirm != null) {
|
||||||
widget.onConfirm!(bytes);
|
widget.onConfirm!(bytes);
|
||||||
}
|
} else if (!widget.closeOnConfirmImmediately) {
|
||||||
|
if (context.mounted) {
|
||||||
// Close the canvas
|
Navigator.of(context).pop(bytes);
|
||||||
if (mounted && Navigator.canPop(context)) {
|
}
|
||||||
Navigator.of(context).pop(bytes);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(l.confirm),
|
child: Text(l.confirm),
|
||||||
|
|
@ -92,10 +95,7 @@ class _DrawCanvasState extends State<DrawCanvas> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
key: const Key('draw_canvas'),
|
key: const Key('draw_canvas'),
|
||||||
height: math.min(
|
height: math.max(MediaQuery.of(context).size.height * 0.6, 350),
|
||||||
math.max(MediaQuery.of(context).size.height * 0.6, 350),
|
|
||||||
MediaQuery.of(context).size.height * 0.8,
|
|
||||||
),
|
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 10 / 3,
|
aspectRatio: 10 / 3,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:file_selector/file_selector.dart' as fs;
|
import 'package:file_selector/file_selector.dart' as fs;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
|
||||||
|
|
@ -123,7 +124,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
enableDrag: false,
|
enableDrag: false,
|
||||||
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false),
|
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: true),
|
||||||
);
|
);
|
||||||
if (result != null && result.isNotEmpty) {
|
if (result != null && result.isNotEmpty) {
|
||||||
// In simplified UI, adding to library isn't implemented
|
// In simplified UI, adding to library isn't implemented
|
||||||
|
|
@ -196,37 +197,11 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
return name;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Build areas once with builders; keep these instances stable.
|
// Build areas once with builders; keep these instances stable.
|
||||||
_viewModel = ref.read(pdfViewModelProvider.notifier);
|
_viewModel = ref.read(pdfViewModelProvider.notifier);
|
||||||
|
|
||||||
// Add listener to update zoom level when controller zoom changes
|
|
||||||
_viewModel.controller.addListener(_onControllerChanged);
|
|
||||||
|
|
||||||
_areas = [
|
_areas = [
|
||||||
Area(
|
Area(
|
||||||
size: _lastPagesWidth,
|
size: _lastPagesWidth,
|
||||||
|
|
@ -295,7 +270,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_viewModel.controller.removeListener(_onControllerChanged);
|
|
||||||
_splitController.dispose();
|
_splitController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -349,36 +323,14 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
onClosePdf: _closePdf,
|
onClosePdf: _closePdf,
|
||||||
onJumpToPage: _jumpToPage,
|
onJumpToPage: _jumpToPage,
|
||||||
onZoomOut: () {
|
onZoomOut: () {
|
||||||
if (_viewModel.controller.isReady) {
|
setState(() {
|
||||||
_viewModel.controller.zoomDown();
|
_zoomLevel = (_zoomLevel - 10).clamp(10, 800);
|
||||||
// Update display zoom level after controller zoom
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_zoomLevel = (_viewModel.controller.currentZoom *
|
|
||||||
100)
|
|
||||||
.round()
|
|
||||||
.clamp(10, 800);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onZoomIn: () {
|
onZoomIn: () {
|
||||||
if (_viewModel.controller.isReady) {
|
setState(() {
|
||||||
_viewModel.controller.zoomUp();
|
_zoomLevel = (_zoomLevel + 10).clamp(10, 800);
|
||||||
// Update display zoom level after controller zoom
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_zoomLevel = (_viewModel.controller.currentZoom *
|
|
||||||
100)
|
|
||||||
.round()
|
|
||||||
.clamp(10, 800);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
zoomLevel: _zoomLevel,
|
zoomLevel: _zoomLevel,
|
||||||
filePath: widget.currentFile.path,
|
filePath: widget.currentFile.path,
|
||||||
|
|
|
||||||
|
|
@ -126,44 +126,6 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
|
||||||
onClearActiveOverlay: widget.onClearActiveOverlay,
|
onClearActiveOverlay: widget.onClearActiveOverlay,
|
||||||
onSelectPlaced: widget.onSelectPlaced,
|
onSelectPlaced: widget.onSelectPlaced,
|
||||||
),
|
),
|
||||||
// Vertical scroll thumb on the right
|
|
||||||
PdfViewerScrollThumb(
|
|
||||||
controller: widget.controller,
|
|
||||||
orientation: ScrollbarOrientation.right,
|
|
||||||
thumbSize: const Size(40, 25),
|
|
||||||
thumbBuilder:
|
|
||||||
(context, thumbSize, pageNumber, controller) => Container(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
pageNumber.toString(),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Horizontal scroll thumb on the bottom
|
|
||||||
PdfViewerScrollThumb(
|
|
||||||
controller: widget.controller,
|
|
||||||
orientation: ScrollbarOrientation.bottom,
|
|
||||||
thumbSize: const Size(40, 25),
|
|
||||||
thumbBuilder:
|
|
||||||
(context, thumbSize, pageNumber, controller) => Container(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
pageNumber.toString(),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
import '../../pdf/widgets/adjustments_panel.dart';
|
import '../../pdf/widgets/adjustments_panel.dart';
|
||||||
import '../../../../domain/models/model.dart' as domain;
|
import '../../../../domain/models/model.dart' as domain;
|
||||||
|
import '../view_model/signature_view_model.dart';
|
||||||
import 'rotated_signature_image.dart';
|
import 'rotated_signature_image.dart';
|
||||||
|
import '../../../../data/services/signature_image_processing_service.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
class ImageEditorResult {
|
class ImageEditorResult {
|
||||||
final double rotation;
|
final double rotation;
|
||||||
|
|
@ -16,7 +19,7 @@ class ImageEditorResult {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageEditorDialog extends StatefulWidget {
|
class ImageEditorDialog extends ConsumerStatefulWidget {
|
||||||
const ImageEditorDialog({
|
const ImageEditorDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
|
@ -29,16 +32,20 @@ class ImageEditorDialog extends StatefulWidget {
|
||||||
final domain.GraphicAdjust initialGraphicAdjust;
|
final domain.GraphicAdjust initialGraphicAdjust;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ImageEditorDialog> createState() => _ImageEditorDialogState();
|
ConsumerState<ImageEditorDialog> createState() => _ImageEditorDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
class _ImageEditorDialogState extends ConsumerState<ImageEditorDialog> {
|
||||||
late bool _aspectLocked;
|
late bool _aspectLocked;
|
||||||
late bool _bgRemoval;
|
late bool _bgRemoval;
|
||||||
late double _contrast;
|
late double _contrast;
|
||||||
late double _brightness;
|
late double _brightness;
|
||||||
late double _rotation;
|
late double _rotation;
|
||||||
late Uint8List _processedBytes;
|
late Uint8List _processedBytes;
|
||||||
|
img.Image? _decodedSource; // Reused decoded source for fast previews
|
||||||
|
bool _previewScheduled = false;
|
||||||
|
bool _previewDirty = false;
|
||||||
|
late final SignatureImageProcessingService _svc;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -46,65 +53,49 @@ class _ImageEditorDialogState extends State<ImageEditorDialog> {
|
||||||
_aspectLocked = false; // Not persisted in GraphicAdjust
|
_aspectLocked = false; // Not persisted in GraphicAdjust
|
||||||
_bgRemoval = widget.initialGraphicAdjust.bgRemoval;
|
_bgRemoval = widget.initialGraphicAdjust.bgRemoval;
|
||||||
_contrast = widget.initialGraphicAdjust.contrast;
|
_contrast = widget.initialGraphicAdjust.contrast;
|
||||||
_brightness = widget.initialGraphicAdjust.brightness;
|
_brightness = 1.0; // Changed from 0.0 to 1.0
|
||||||
_rotation = widget.initialRotation;
|
_rotation = widget.initialRotation;
|
||||||
_processedBytes = widget.asset.bytes; // Initialize with original bytes
|
_processedBytes = widget.asset.bytes; // initial preview
|
||||||
_updateProcessedBytes(); // Apply initial adjustments to preview
|
_svc = SignatureImageProcessingService();
|
||||||
|
// Decode once for preview reuse
|
||||||
|
// Note: package:image lives in service; expose decode via service
|
||||||
|
_decodedSource = _svc.decode(widget.asset.bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update processed image bytes when processing parameters change
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Frame callbacks are tied to mounting; nothing to cancel explicitly
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update processed image bytes when processing parameters change.
|
||||||
|
/// Coalesce rapid changes once per frame to keep UI responsive and tests stable.
|
||||||
void _updateProcessedBytes() {
|
void _updateProcessedBytes() {
|
||||||
try {
|
_previewDirty = true;
|
||||||
final decoded = img.decodeImage(widget.asset.bytes);
|
if (_previewScheduled) return;
|
||||||
|
_previewScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_previewScheduled = false;
|
||||||
|
if (!mounted || !_previewDirty) return;
|
||||||
|
_previewDirty = false;
|
||||||
|
final adjust = domain.GraphicAdjust(
|
||||||
|
contrast: _contrast,
|
||||||
|
brightness: _brightness,
|
||||||
|
bgRemoval: _bgRemoval,
|
||||||
|
);
|
||||||
|
// Fast preview path: reuse decoded, downscale, low-compression encode
|
||||||
|
final decoded = _decodedSource;
|
||||||
if (decoded != null) {
|
if (decoded != null) {
|
||||||
img.Image processed = decoded;
|
final preview = _svc.processPreviewFromDecoded(decoded, adjust);
|
||||||
|
if (mounted) setState(() => _processedBytes = preview);
|
||||||
// Apply contrast and brightness first
|
} else {
|
||||||
if (_contrast != 1.0 || _brightness != 1.0) {
|
// Fallback to repository path if decode failed
|
||||||
processed = img.adjustColor(
|
final bytes = ref
|
||||||
processed,
|
.read(signatureViewModelProvider)
|
||||||
contrast: _contrast,
|
.getProcessedBytes(widget.asset, adjust);
|
||||||
brightness: _brightness,
|
if (mounted) setState(() => _processedBytes = bytes);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply background removal after color adjustments
|
|
||||||
if (_bgRemoval) {
|
|
||||||
processed = _removeBackground(processed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode back to PNG to preserve transparency
|
|
||||||
_processedBytes = Uint8List.fromList(img.encodePng(processed));
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
});
|
||||||
// If processing fails, keep original bytes
|
|
||||||
_processedBytes = widget.asset.bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove near-white background using simple threshold approach for maximum speed
|
|
||||||
/// TODO: remove double loops with SIMD matrix operations for better performance
|
|
||||||
img.Image _removeBackground(img.Image image) {
|
|
||||||
final result =
|
|
||||||
image.hasAlpha ? img.Image.from(image) : image.convert(numChannels: 4);
|
|
||||||
|
|
||||||
// Simple and fast: single pass through all pixels
|
|
||||||
for (int y = 0; y < result.height; y++) {
|
|
||||||
for (int x = 0; x < result.width; x++) {
|
|
||||||
final pixel = result.getPixel(x, y);
|
|
||||||
final r = pixel.r;
|
|
||||||
final g = pixel.g;
|
|
||||||
final b = pixel.b;
|
|
||||||
|
|
||||||
// Simple threshold: if pixel is close to white, make it transparent
|
|
||||||
const int threshold = 240; // Very close to white
|
|
||||||
if (r >= threshold && g >= threshold && b >= threshold) {
|
|
||||||
result.setPixelRgba(x, y, r, g, b, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:pdf_signature/routing/router.dart';
|
|
||||||
|
|
||||||
class WelcomeViewModel {
|
class WelcomeViewModel {
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
final GoRouter router;
|
|
||||||
|
|
||||||
WelcomeViewModel(this.ref, this.router);
|
WelcomeViewModel(this.ref);
|
||||||
|
|
||||||
Future<void> openPdf({required String path, Uint8List? bytes}) async {
|
Future<void> openPdf({required String path, Uint8List? bytes}) async {
|
||||||
// Use PdfSessionViewModel to open and navigate.
|
await ref
|
||||||
final session = ref.read(pdfSessionViewModelProvider(router));
|
.read(pdfViewModelProvider.notifier)
|
||||||
await session.openPdf(path: path, bytes: bytes);
|
.openPdf(path: path, bytes: bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final welcomeViewModelProvider = Provider<WelcomeViewModel>((ref) {
|
final welcomeViewModelProvider = Provider<WelcomeViewModel>((ref) {
|
||||||
final router = ref.read(routerProvider);
|
return WelcomeViewModel(ref);
|
||||||
return WelcomeViewModel(ref, router);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,15 @@ Future<void> theUserDrawsStrokesAndConfirms(WidgetTester tester) async {
|
||||||
await tester.drag(canvas, const Offset(100, 100));
|
await tester.drag(canvas, const Offset(100, 100));
|
||||||
await tester.drag(canvas, const Offset(150, 150));
|
await tester.drag(canvas, const Offset(150, 150));
|
||||||
|
|
||||||
|
// Check confirm button is there
|
||||||
|
expect(find.byKey(const Key('btn_canvas_confirm')), findsOneWidget);
|
||||||
|
|
||||||
// Tap confirm
|
// Tap confirm
|
||||||
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
|
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Dialog should be closed - but skip this check for now as it may not work in test environment
|
// Dialog should be closed
|
||||||
// expect(find.byKey(const Key('draw_canvas')), findsNothing);
|
expect(find.byKey(const Key('draw_canvas')), findsNothing);
|
||||||
|
|
||||||
// Inject a dummy asset into repository (app does not auto-add drawn bytes yet)
|
// Inject a dummy asset into repository (app does not auto-add drawn bytes yet)
|
||||||
final container = TestWorld.container;
|
final container = TestWorld.container;
|
||||||
|
|
|
||||||
|
|
@ -62,61 +62,4 @@ void main() {
|
||||||
expect(exported, isNotNull);
|
expect(exported, isNotNull);
|
||||||
expect(exported!.isNotEmpty, isTrue);
|
expect(exported!.isNotEmpty, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('DrawCanvas calls onConfirm with bytes when confirm is pressed', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
Uint8List? confirmedBytes;
|
|
||||||
final sink = ValueNotifier<Uint8List?>(null);
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
home: Scaffold(
|
|
||||||
body: DrawCanvas(
|
|
||||||
debugBytesSink: sink,
|
|
||||||
onConfirm: (bytes) {
|
|
||||||
confirmedBytes = bytes;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Draw a simple stroke inside the pad
|
|
||||||
final pad = find.byKey(const Key('hand_signature_pad'));
|
|
||||||
expect(pad, findsOneWidget);
|
|
||||||
final rect = tester.getRect(pad);
|
|
||||||
final g = await tester.startGesture(
|
|
||||||
Offset(rect.left + 20, rect.center.dy),
|
|
||||||
kind: PointerDeviceKind.touch,
|
|
||||||
);
|
|
||||||
for (int i = 0; i < 10; i++) {
|
|
||||||
await g.moveBy(
|
|
||||||
const Offset(12, 0),
|
|
||||||
timeStamp: Duration(milliseconds: 16 * (i + 1)),
|
|
||||||
);
|
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
|
||||||
}
|
|
||||||
await g.up();
|
|
||||||
await tester.pump(const Duration(milliseconds: 50));
|
|
||||||
|
|
||||||
// Confirm export
|
|
||||||
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
|
|
||||||
// Wait until bytes are available
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.runAsync(() async {
|
|
||||||
final end = DateTime.now().add(const Duration(seconds: 2));
|
|
||||||
while ((confirmedBytes == null && sink.value == null) &&
|
|
||||||
DateTime.now().isBefore(end)) {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
confirmedBytes ??= sink.value;
|
|
||||||
|
|
||||||
// Verify that onConfirm was called with non-empty bytes
|
|
||||||
expect(confirmedBytes, isNotNull);
|
|
||||||
expect(confirmedBytes!.isNotEmpty, isTrue);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue