From df1bf2755390fd3856c57f8b2de350f7a1fd9d3e Mon Sep 17 00:00:00 2001 From: insleker Date: Tue, 2 Sep 2025 10:59:52 +0800 Subject: [PATCH 1/4] refactor: remove single-page mode and enforce continuous view across preferences and settings --- .../features/pdf/widgets/pdf_page_area.dart | 204 +++++------------- lib/ui/features/preferences/providers.dart | 22 +- .../preferences/widgets/settings_screen.dart | 32 +-- test/features/pdf_browser.feature | 7 +- .../only_page_is_rendered_in_the_canvas.dart | 14 -- .../the_page_view_mode_is_set_to_single.dart | 7 - .../the_preference_language_is_saved_as.dart | 23 -- .../the_preference_theme_is_saved_as.dart | 23 -- test/widget/helpers.dart | 4 + test/widget/pdf_navigation_widget_test.dart | 14 +- 10 files changed, 78 insertions(+), 272 deletions(-) delete mode 100644 test/features/step/only_page_is_rendered_in_the_canvas.dart delete mode 100644 test/features/step/the_page_view_mode_is_set_to_single.dart delete mode 100644 test/features/step/the_preference_language_is_saved_as.dart delete mode 100644 test/features/step/the_preference_theme_is_saved_as.dart diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart index 1db2c09..69e5bb8 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_area.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; @@ -64,6 +65,7 @@ class _PdfPageAreaState extends ConsumerState { void _scrollToPage(int page) { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final pdf = ref.read(pdfProvider); final isContinuous = ref.read(pageViewModeProvider) == 'continuous'; @@ -77,9 +79,10 @@ class _PdfPageAreaState extends ConsumerState { anchor: PdfPageAnchor.top, ); // Fallback: if no onPageChanged arrives (e.g., same page), don't block future jumps + // Use post-frame callbacks to avoid scheduling timers in tests. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - Future.delayed(const Duration(milliseconds: 120), () { + WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_programmaticTargetPage == page) { _programmaticTargetPage = null; @@ -129,7 +132,7 @@ class _PdfPageAreaState extends ConsumerState { Scrollable.ensureVisible( ctx, alignment: 0.1, - duration: const Duration(milliseconds: 1), + duration: Duration.zero, curve: Curves.linear, ); return; @@ -192,51 +195,6 @@ class _PdfPageAreaState extends ConsumerState { final useMock = ref.watch(useMockViewerProvider); final isContinuous = pageViewMode == 'continuous'; - // Mock single-page - if (useMock && !isContinuous) { - return Center( - child: AspectRatio( - aspectRatio: widget.pageSize.width / widget.pageSize.height, - child: InteractiveViewer( - minScale: 0.5, - maxScale: 4.0, - panEnabled: false, - transformationController: widget.controller, - child: Stack( - key: const Key('page_stack'), - children: [ - Container( - key: ValueKey('pdf_page_view_${pdf.currentPage}'), - color: Colors.grey.shade200, - child: Center( - child: Text( - AppLocalizations.of( - context, - ).pageInfo(pdf.currentPage, pdf.pageCount), - style: const TextStyle( - fontSize: 24, - color: Colors.black54, - ), - ), - ), - ), - Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - final visible = ref.watch(signatureVisibilityProvider); - return visible - ? _buildPageOverlays(context, ref, sig, pdf.currentPage) - : const SizedBox.shrink(); - }, - ), - _ZoomControls(controller: widget.controller), - ], - ), - ), - ), - ); - } - // Mock continuous: ListView with prebuilt children, no controller if (useMock && isContinuous) { final count = pdf.pageCount > 0 ? pdf.pageCount : 1; @@ -250,7 +208,8 @@ class _PdfPageAreaState extends ConsumerState { if (p != null) { _pendingPage = null; _scrollRetryCount = 0; - Future.delayed(const Duration(milliseconds: 1), () { + // Schedule via microtask to avoid test timers remaining pending + scheduleMicrotask(() { if (!mounted) return; _scrollToPage(p); }); @@ -315,66 +274,51 @@ class _PdfPageAreaState extends ConsumerState { ); } - // Real single-page mode - if (pdf.pickedPdfPath != null && !isContinuous) { - return PdfDocumentViewBuilder.file( - pdf.pickedPdfPath!, - builder: (context, document) { - if (document == null) { - return const Center(child: CircularProgressIndicator()); - } - final pages = document.pages; - final pageNum = pdf.currentPage.clamp(1, pages.length); - final page = pages[pageNum - 1]; - final aspect = page.width / page.height; - if (pdf.pageCount != pages.length) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(pdfProvider.notifier).setPageCount(pages.length); - }); - } - return Center( - child: AspectRatio( - aspectRatio: aspect, - child: InteractiveViewer( - minScale: 0.5, - maxScale: 4.0, - panEnabled: false, - transformationController: widget.controller, - child: Stack( - key: const Key('page_stack'), - children: [ - PdfPageView( - key: ValueKey('pdf_page_view_$pageNum'), - document: document, - pageNumber: pageNum, - alignment: Alignment.center, - ), - Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - final visible = ref.watch(signatureVisibilityProvider); - return visible - ? _buildPageOverlays(context, ref, sig, pageNum) - : const SizedBox.shrink(); - }, - ), - _ZoomControls(controller: widget.controller), - ], - ), - ), - ), - ); - }, - ); - } - // Real continuous mode (pdfrx): copy example patterns + // https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart if (pdf.pickedPdfPath != null && isContinuous) { return PdfViewer.file( pdf.pickedPdfPath!, controller: _viewerController, params: PdfViewerParams( pageAnchor: PdfPageAnchor.top, + keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true), + maxScale: 8, + // scrollByMouseWheel: 0.6, + // Add overlay scroll thumbs (vertical on right, horizontal on bottom) + viewerOverlayBuilder: + (context, size, handleLinkTap) => [ + PdfViewerScrollThumb( + controller: _viewerController, + orientation: ScrollbarOrientation.right, + thumbSize: const Size(40, 24), + thumbBuilder: + (context, thumbSize, pageNumber, controller) => Container( + color: Colors.black.withOpacity(0.7), + child: Center( + child: Text( + pageNumber.toString(), + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + PdfViewerScrollThumb( + controller: _viewerController, + orientation: ScrollbarOrientation.bottom, + thumbSize: const Size(40, 24), + thumbBuilder: + (context, thumbSize, pageNumber, controller) => Container( + color: Colors.black.withOpacity(0.7), + child: Center( + child: Text( + pageNumber.toString(), + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ], onViewerReady: (doc, controller) { if (pdf.pageCount != doc.pages.length) { ref.read(pdfProvider.notifier).setPageCount(doc.pages.length); @@ -388,7 +332,7 @@ class _PdfPageAreaState extends ConsumerState { // ensure we don't keep blocking provider-driven jumps. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - Future.delayed(const Duration(milliseconds: 120), () { + WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_programmaticTargetPage == target) { _programmaticTargetPage = null; @@ -496,9 +440,12 @@ class _PdfPageAreaState extends ConsumerState { ), ); } + // Only show the active (interactive) signature overlay on the current page + // in continuous mode, so tests can reliably find a single overlay. if (sig.rect != null && sig.editingEnabled && - (pdf.signedPage == null || pdf.signedPage == pageNumber)) { + (pdf.signedPage == null || pdf.signedPage == pageNumber) && + pdf.currentPage == pageNumber) { widgets.add( _buildSignatureOverlay( context, @@ -712,53 +659,4 @@ class _PdfPageAreaState extends ConsumerState { } } -class _ZoomControls extends StatelessWidget { - const _ZoomControls({this.controller}); - final TransformationController? controller; - - @override - Widget build(BuildContext context) { - if (controller == null) return const SizedBox.shrink(); - void setScale(double scale) { - final m = controller!.value.clone(); - // Reset translation but keep center - m.setEntry(0, 0, scale); - m.setEntry(1, 1, scale); - controller!.value = m; - } - - return Positioned( - right: 8, - bottom: 8, - child: Card( - elevation: 2, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Zoom out', - icon: const Icon(Icons.remove), - onPressed: () { - final current = controller!.value.getMaxScaleOnAxis(); - setScale((current - 0.1).clamp(0.5, 4.0)); - }, - ), - IconButton( - tooltip: 'Reset', - icon: const Icon(Icons.refresh), - onPressed: () => controller!.value = Matrix4.identity(), - ), - IconButton( - tooltip: 'Zoom in', - icon: const Icon(Icons.add), - onPressed: () { - final current = controller!.value.getMaxScaleOnAxis(); - setScale((current + 0.1).clamp(0.5, 4.0)); - }, - ), - ], - ), - ), - ); - } -} +// Zoom controls removed with single-page mode; continuous viewer manages zoom. diff --git a/lib/ui/features/preferences/providers.dart b/lib/ui/features/preferences/providers.dart index cc40154..e269530 100644 --- a/lib/ui/features/preferences/providers.dart +++ b/lib/ui/features/preferences/providers.dart @@ -28,7 +28,7 @@ Set _supportedTags() { // Keys const _kTheme = 'theme'; // 'light'|'dark'|'system' const _kLanguage = 'language'; // BCP-47 tag like 'en', 'zh-TW', 'es' -const _kPageView = 'page_view'; // 'single' | 'continuous' +const _kPageView = 'page_view'; // now only 'continuous' String _normalizeLanguageTag(String tag) { final tags = _supportedTags(); @@ -65,7 +65,7 @@ String _normalizeLanguageTag(String tag) { class PreferencesState { final String theme; // 'light' | 'dark' | 'system' final String language; // 'en' | 'zh-TW' | 'es' - final String pageView; // 'single' | 'continuous' + final String pageView; // only 'continuous' const PreferencesState({ required this.theme, required this.language, @@ -94,7 +94,7 @@ class PreferencesNotifier extends StateNotifier { WidgetsBinding.instance.platformDispatcher.locale .toLanguageTag(), ), - pageView: prefs.getString(_kPageView) ?? 'single', + pageView: prefs.getString(_kPageView) ?? 'continuous', ), ) { // normalize language to supported/fallback @@ -112,10 +112,10 @@ class PreferencesNotifier extends StateNotifier { state = state.copyWith(language: normalized); prefs.setString(_kLanguage, normalized); } - final pageViewValid = {'single', 'continuous'}; + final pageViewValid = {'continuous'}; if (!pageViewValid.contains(state.pageView)) { - state = state.copyWith(pageView: 'single'); - prefs.setString(_kPageView, 'single'); + state = state.copyWith(pageView: 'continuous'); + prefs.setString(_kPageView, 'continuous'); } } @@ -139,15 +139,15 @@ class PreferencesNotifier extends StateNotifier { state = PreferencesState( theme: 'system', language: normalized, - pageView: 'single', + pageView: 'continuous', ); await prefs.setString(_kTheme, 'system'); await prefs.setString(_kLanguage, normalized); - await prefs.setString(_kPageView, 'single'); + await prefs.setString(_kPageView, 'continuous'); } Future setPageView(String pageView) async { - final valid = {'single', 'continuous'}; + final valid = {'continuous'}; if (!valid.contains(pageView)) return; state = state.copyWith(pageView: pageView); await prefs.setString(_kPageView, pageView); @@ -173,13 +173,13 @@ final preferencesProvider = return PreferencesNotifier(prefs); }); -/// Safe accessor for page view mode that falls back to 'single' until +/// Safe accessor for page view mode that falls back to 'continuous' until /// SharedPreferences is available (useful for lightweight widget tests). final pageViewModeProvider = Provider((ref) { final sp = ref.watch(sharedPreferencesProvider); return sp.maybeWhen( data: (_) => ref.watch(preferencesProvider).pageView, - orElse: () => 'single', + orElse: () => 'continuous', ); }); diff --git a/lib/ui/features/preferences/widgets/settings_screen.dart b/lib/ui/features/preferences/widgets/settings_screen.dart index f2a0d45..9a06358 100644 --- a/lib/ui/features/preferences/widgets/settings_screen.dart +++ b/lib/ui/features/preferences/widgets/settings_screen.dart @@ -13,7 +13,7 @@ class SettingsDialog extends ConsumerStatefulWidget { class _SettingsDialogState extends ConsumerState { String? _theme; String? _language; - String? _pageView; // 'single' | 'continuous' + // Page view removed; continuous-only @override void initState() { @@ -21,7 +21,7 @@ class _SettingsDialogState extends ConsumerState { final prefs = ref.read(preferencesProvider); _theme = prefs.theme; _language = prefs.language; - _pageView = prefs.pageView; + // pageView no longer configurable (continuous-only) } @override @@ -149,31 +149,7 @@ class _SettingsDialogState extends ConsumerState { ), ], ), - const SizedBox(height: 12), - Row( - children: [ - SizedBox(width: 140, child: Text('${l.pageView}:')), - const SizedBox(width: 8), - Expanded( - child: DropdownButton( - key: const Key('ddl_page_view'), - isExpanded: true, - value: _pageView, - items: [ - DropdownMenuItem( - value: 'single', - child: Text(l.pageViewSingle), - ), - DropdownMenuItem( - value: 'continuous', - child: Text(l.pageViewContinuous), - ), - ], - onChanged: (v) => setState(() => _pageView = v), - ), - ), - ], - ), + // Page view setting removed (continuous-only) const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -188,7 +164,7 @@ class _SettingsDialogState extends ConsumerState { final n = ref.read(preferencesProvider.notifier); if (_theme != null) await n.setTheme(_theme!); if (_language != null) await n.setLanguage(_language!); - if (_pageView != null) await n.setPageView(_pageView!); + // pageView not configurable anymore if (mounted) Navigator.of(context).pop(true); }, child: Text(l.save), diff --git a/test/features/pdf_browser.feature b/test/features/pdf_browser.feature index 375fc45..8b5b2ad 100644 --- a/test/features/pdf_browser.feature +++ b/test/features/pdf_browser.feature @@ -36,12 +36,7 @@ Feature: PDF browser Then page {5} becomes visible in the scroll area And the left pages overview highlights page {5} - Scenario: Single-page mode renders only the selected page - Given the document is open - And the Page view mode is set to Single - When the user jumps to page {2} - Then only page {2} is rendered in the canvas - And the page label shows "Page {2} of {5}" + Scenario: Go to clamps out-of-range inputs to valid bounds Given the document is open diff --git a/test/features/step/only_page_is_rendered_in_the_canvas.dart b/test/features/step/only_page_is_rendered_in_the_canvas.dart deleted file mode 100644 index 2938351..0000000 --- a/test/features/step/only_page_is_rendered_in_the_canvas.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; -import '_world.dart'; - -/// Usage: only page {2} is rendered in the canvas -Future onlyPageIsRenderedInTheCanvas( - WidgetTester tester, - num param1, -) async { - final page = param1.toInt(); - final c = TestWorld.container ?? ProviderContainer(); - expect(c.read(pdfProvider).currentPage, page); -} diff --git a/test/features/step/the_page_view_mode_is_set_to_single.dart b/test/features/step/the_page_view_mode_is_set_to_single.dart deleted file mode 100644 index 4e21cbf..0000000 --- a/test/features/step/the_page_view_mode_is_set_to_single.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import '_world.dart'; - -/// Usage: the Page view mode is set to Single -Future thePageViewModeIsSetToSingle(WidgetTester tester) async { - TestWorld.prefs['page_view'] = 'single'; -} diff --git a/test/features/step/the_preference_language_is_saved_as.dart b/test/features/step/the_preference_language_is_saved_as.dart deleted file mode 100644 index 0cfdd6d..0000000 --- a/test/features/step/the_preference_language_is_saved_as.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import '_world.dart'; - -/// Usage: the preference 'language' is saved as {""} -Future thePreferenceLanguageIsSavedAs( - WidgetTester tester, [ - dynamic valueWrapped, -]) async { - String unwrap(String s) { - var out = s.trim(); - if (out.startsWith('{') && out.endsWith('}')) { - out = out.substring(1, out.length - 1); - } - if ((out.startsWith("'") && out.endsWith("'")) || - (out.startsWith('"') && out.endsWith('"'))) { - out = out.substring(1, out.length - 1); - } - return out; - } - - final expected = unwrap((valueWrapped ?? '').toString()); - expect(TestWorld.prefs['language'], expected); -} diff --git a/test/features/step/the_preference_theme_is_saved_as.dart b/test/features/step/the_preference_theme_is_saved_as.dart deleted file mode 100644 index 3f98047..0000000 --- a/test/features/step/the_preference_theme_is_saved_as.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import '_world.dart'; - -/// Usage: the preference 'theme' is saved as {""} -Future thePreferenceThemeIsSavedAs( - WidgetTester tester, [ - dynamic valueWrapped, -]) async { - String unwrap(String s) { - var out = s.trim(); - if (out.startsWith('{') && out.endsWith('}')) { - out = out.substring(1, out.length - 1); - } - if ((out.startsWith("'") && out.endsWith("'")) || - (out.startsWith('"') && out.endsWith('"'))) { - out = out.substring(1, out.length - 1); - } - return out; - } - - final expected = unwrap((valueWrapped ?? '').toString()); - expect(TestWorld.prefs['theme'], expected); -} diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index d733335..8a001f7 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -6,6 +6,7 @@ import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import 'package:pdf_signature/data/services/providers.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; +import 'package:pdf_signature/ui/features/preferences/providers.dart'; Future pumpWithOpenPdf(WidgetTester tester) async { await tester.pumpWidget( @@ -15,6 +16,8 @@ Future pumpWithOpenPdf(WidgetTester tester) async { (ref) => PdfController()..openPicked(path: 'test.pdf'), ), useMockViewerProvider.overrideWith((ref) => true), + // Force continuous mode regardless of prefs + pageViewModeProvider.overrideWithValue('continuous'), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -37,6 +40,7 @@ Future pumpWithOpenPdfAndSig(WidgetTester tester) async { (ref) => SignatureController()..placeDefaultRect(), ), useMockViewerProvider.overrideWith((ref) => true), + pageViewModeProvider.overrideWithValue('continuous'), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/widget/pdf_navigation_widget_test.dart b/test/widget/pdf_navigation_widget_test.dart index cead7bf..c37ac2c 100644 --- a/test/widget/pdf_navigation_widget_test.dart +++ b/test/widget/pdf_navigation_widget_test.dart @@ -38,23 +38,23 @@ void main() { ), ); - // Initial label and page view key + // Initial label and page list exists (continuous mock) expect(find.byKey(const Key('lbl_page_info')), findsOneWidget); Text label() => tester.widget(find.byKey(const Key('lbl_page_info'))); expect(label().data, equals('Page 1/5')); - expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget); + expect(find.byKey(const Key('pdf_continuous_mock_list')), findsOneWidget); // Next await tester.tap(find.byKey(const Key('btn_next'))); await tester.pumpAndSettle(); expect(label().data, equals('Page 2/5')); - expect(find.byKey(const ValueKey('pdf_page_view_2')), findsOneWidget); + expect(find.byKey(const Key('pdf_continuous_mock_list')), findsOneWidget); // Prev await tester.tap(find.byKey(const Key('btn_prev'))); await tester.pumpAndSettle(); expect(label().data, equals('Page 1/5')); - expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget); + expect(find.byKey(const Key('pdf_continuous_mock_list')), findsOneWidget); // Goto specific page await tester.tap(find.byKey(const Key('txt_goto'))); @@ -63,7 +63,7 @@ void main() { await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(label().data, equals('Page 4/5')); - expect(find.byKey(const ValueKey('pdf_page_view_4')), findsOneWidget); + expect(find.byKey(const Key('pdf_continuous_mock_list')), findsOneWidget); // Goto beyond upper bound -> clamp to 5 await tester.tap(find.byKey(const Key('txt_goto'))); @@ -72,7 +72,7 @@ void main() { await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(label().data, equals('Page 5/5')); - expect(find.byKey(const ValueKey('pdf_page_view_5')), findsOneWidget); + expect(find.byKey(const Key('pdf_continuous_mock_list')), findsOneWidget); // Goto below 1 -> clamp to 1 await tester.tap(find.byKey(const Key('txt_goto'))); @@ -81,6 +81,6 @@ void main() { await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(label().data, equals('Page 1/5')); - expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget); + expect(find.byKey(const Key('pdf_continuous_mock_list')), findsOneWidget); }); } From d3df15d6956a5962c957650041d302c0a2ef85a3 Mon Sep 17 00:00:00 2001 From: insleker Date: Tue, 2 Sep 2025 12:36:30 +0800 Subject: [PATCH 2/4] docs: update wireframe, introduce signature cards abstraction --- .vscode/extensions.json | 3 +- docs/wireframe.assets/first_screen.excalidraw | 430 ----- .../with_configure_screen.excalidraw | 159 -- .../with_pdf_opened.excalidraw | 1541 ++++++++++++----- docs/wireframe.md | 31 +- 5 files changed, 1108 insertions(+), 1056 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 62d5411..04cdaf5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,8 @@ "dart-code.flutter", "lsaudon.l10nization", // quick translation gen "oke331.flutter-l10n-helper", // show arb string - "gabbygreat.flutter-l10n-checker", // detect hard-coded strings + "gabbygreat.flutter-l10n-checker", + "pomdtr.excalidraw-editor", // detect hard-coded strings // "joaopinacio.translate-me" ] } \ No newline at end of file diff --git a/docs/wireframe.assets/first_screen.excalidraw b/docs/wireframe.assets/first_screen.excalidraw index 7db0436..46e8c87 100644 --- a/docs/wireframe.assets/first_screen.excalidraw +++ b/docs/wireframe.assets/first_screen.excalidraw @@ -607,436 +607,6 @@ "originalText": "search", "autoResize": true, "lineHeight": 1.35 - }, - { - "id": "xdjwEB-znvBqkgNWqei0e", - "type": "rectangle", - "x": 829.345613801518, - "y": 73.27856093258742, - "width": 109.23454710748254, - "height": 36.33306860750372, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "#ffffff", - "fillStyle": "hachure", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "a_nIXU9JKF6NQxqDn6778" - ], - "frameId": null, - "index": "aK", - "roundness": null, - "seed": 1079291190, - "version": 165, - "versionNonce": 1774775286, - "isDeleted": false, - "boundElements": [], - "updated": 1756646729695, - "link": null, - "locked": false - }, - { - "type": "rectangle", - "version": 769, - "versionNonce": 754187574, - "isDeleted": false, - "id": "7kiaSLfvSEdtaNYQBYwzh", - "fillStyle": "hachure", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 841.4747836141609, - "y": 84.61996518660985, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 22.637490885793227, - "height": 13.582494531475936, - "seed": 1458935414, - "groupIds": [ - "s9zt_E4Q8I28ITXmLtTku", - "a_nIXU9JKF6NQxqDn6778" - ], - "boundElements": [], - "updated": 1756646729695, - "link": null, - "locked": false, - "index": "aL", - "frameId": null, - "roundness": null - }, - { - "type": "line", - "version": 786, - "versionNonce": 321578614, - "isDeleted": false, - "id": "CbsiEXCcAqMJ4HF8vzcR3", - "fillStyle": "hachure", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 855.0572781456366, - "y": 84.61996518660985, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 13.582494531475936, - "height": 4.5274981771586456, - "seed": 1411864502, - "groupIds": [ - "s9zt_E4Q8I28ITXmLtTku", - "a_nIXU9JKF6NQxqDn6778" - ], - "boundElements": [], - "updated": 1756646729695, - "link": null, - "locked": false, - "startBinding": null, - "endBinding": null, - "lastCommittedPoint": null, - "startArrowhead": null, - "endArrowhead": null, - "points": [ - [ - 0, - 0 - ], - [ - -4.5274981771586456, - -4.5274981771586456 - ], - [ - -13.582494531475934, - -4.527498177158644 - ], - [ - -13.582494531475936, - -8.881784197001252e-16 - ], - [ - 0, - 0 - ] - ], - "index": "aM", - "frameId": null, - "roundness": null, - "polygon": false - }, - { - "id": "qiIJiYioLPMG7pTYwhrg5", - "type": "text", - "x": 877.3865259161674, - "y": 77.10300187674156, - "width": 49.27995300292969, - "height": 27, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "#ffffff", - "fillStyle": "hachure", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "a_nIXU9JKF6NQxqDn6778" - ], - "frameId": null, - "index": "aN", - "roundness": null, - "seed": 1611591926, - "version": 76, - "versionNonce": 2100471734, - "isDeleted": false, - "boundElements": [], - "updated": 1756646729695, - "link": null, - "locked": false, - "text": "Open", - "fontSize": 20, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "Open", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "type": "rectangle", - "version": 447, - "versionNonce": 1428930594, - "isDeleted": false, - "id": "CvUvdJBdFi_9gk7DmBP3h", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 1276.5699538308854, - "y": -105.41567571464518, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 41.296072889060916, - "height": 9.620822332911771, - "seed": 324223266, - "groupIds": [ - "FhaLGab-lElwTXWsop0jD", - "ALJlvV1mOFRZHOCbgoRYA" - ], - "index": "aO", - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1756647328403, - "link": null, - "locked": false - }, - { - "type": "rectangle", - "version": 467, - "versionNonce": 734227426, - "isDeleted": false, - "id": "JST3yuxNBZUHRvRS5svAi", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 1276.5699538308854, - "y": -120.86895124654592, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 41.0018486675741, - "height": 9.620822332911771, - "seed": 458411234, - "groupIds": [ - "FhaLGab-lElwTXWsop0jD", - "ALJlvV1mOFRZHOCbgoRYA" - ], - "index": "aP", - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1756647328404, - "link": null, - "locked": false - }, - { - "type": "rectangle", - "version": 546, - "versionNonce": 1911529378, - "isDeleted": false, - "id": "BdE7Lsbj5rN-2Fbc_g8TY", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 1276.3519181972783, - "y": -136.32222677844666, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 40.89858173493463, - "height": 8.789494679651982, - "seed": 1525342370, - "groupIds": [ - "FhaLGab-lElwTXWsop0jD", - "ALJlvV1mOFRZHOCbgoRYA" - ], - "index": "aQ", - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1756647328404, - "link": null, - "locked": false - }, - { - "type": "text", - "version": 306, - "versionNonce": 1407146850, - "isDeleted": false, - "id": "aMLLNSOvXTWAD4qesWqN6", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 1285.8386804713773, - "y": -86.87954484494927, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 24.33822760138865, - "height": 12.945865745419493, - "seed": 1573551202, - "groupIds": [ - "ALJlvV1mOFRZHOCbgoRYA" - ], - "fontSize": 10.356692596335595, - "fontFamily": 1, - "text": "Menu", - "baseline": 18, - "textAlign": "left", - "verticalAlign": "top", - "index": "aR", - "frameId": null, - "roundness": { - "type": 2 - }, - "boundElements": [ - { - "id": "Zq3EuupF1HmOWACV2oefy", - "type": "arrow" - } - ], - "updated": 1756647328404, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Menu", - "autoResize": true, - "lineHeight": 1.25 - }, - { - "id": "lOTebkqDHtT4BBQALESig", - "type": "rectangle", - "x": 790.384031749907, - "y": 36.693096342540855, - "width": 335.5208042689734, - "height": 109.54448154994424, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "index": "aS", - "roundness": { - "type": 3 - }, - "seed": 1783740578, - "version": 55, - "versionNonce": 1405945982, - "isDeleted": false, - "boundElements": [ - { - "id": "Zq3EuupF1HmOWACV2oefy", - "type": "arrow" - } - ], - "updated": 1756647293005, - "link": null, - "locked": false - }, - { - "id": "Zq3EuupF1HmOWACV2oefy", - "type": "arrow", - "x": 1128.0299808310733, - "y": 66.13666497128354, - "width": 153.447595240714, - "height": 132.2939715962288, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "index": "aT", - "roundness": { - "type": 2 - }, - "seed": 581324706, - "version": 170, - "versionNonce": 410803618, - "isDeleted": false, - "boundElements": [ - { - "type": "text", - "id": "f9MY_jyhZB6ng-Fp--Exn" - } - ], - "updated": 1756647332367, - "link": null, - "locked": false, - "points": [ - [ - 0, - 0 - ], - [ - 153.447595240714, - -132.2939715962288 - ] - ], - "lastCommittedPoint": null, - "startBinding": { - "elementId": "lOTebkqDHtT4BBQALESig", - "focus": 0.6098248915581349, - "gap": 2.241472516741169 - }, - "endBinding": { - "elementId": "aMLLNSOvXTWAD4qesWqN6", - "focus": 0, - "gap": 12.733143243255915 - }, - "startArrowhead": null, - "endArrowhead": "arrow", - "elbowed": false - }, - { - "id": "f9MY_jyhZB6ng-Fp--Exn", - "type": "text", - "x": 1107.8638629851216, - "y": -25.010320826830863, - "width": 193.7798309326172, - "height": 50, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "index": "aU", - "roundness": null, - "seed": 1827590818, - "version": 61, - "versionNonce": 1563426338, - "isDeleted": false, - "boundElements": null, - "updated": 1756647330897, - "link": null, - "locked": false, - "text": "group to 1 symbol if\nscreen is thin", - "fontSize": 20, - "fontFamily": 5, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "Zq3EuupF1HmOWACV2oefy", - "originalText": "group to 1 symbol if screen is thin", - "autoResize": true, - "lineHeight": 1.25 } ], "appState": { diff --git a/docs/wireframe.assets/with_configure_screen.excalidraw b/docs/wireframe.assets/with_configure_screen.excalidraw index ca412ea..f9c67f0 100644 --- a/docs/wireframe.assets/with_configure_screen.excalidraw +++ b/docs/wireframe.assets/with_configure_screen.excalidraw @@ -810,165 +810,6 @@ "originalText": "Configure", "autoResize": true, "lineHeight": 1.35 - }, - { - "id": "iIDobnzWCl-gygCOsA73n", - "type": "rectangle", - "x": 576.109131998714, - "y": -56.43111661983278, - "width": 109.23454710748254, - "height": 36.33306860750372, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "#ffffff", - "fillStyle": "hachure", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "2xO--DSh2411Pyp1YG0B4" - ], - "frameId": null, - "index": "ac", - "roundness": null, - "seed": 1897278824, - "version": 221, - "versionNonce": 536065304, - "isDeleted": false, - "boundElements": [], - "updated": 1756647186276, - "link": null, - "locked": false - }, - { - "type": "rectangle", - "version": 825, - "versionNonce": 921522712, - "isDeleted": false, - "id": "rumws8Xb5KM1-COUn-SjA", - "fillStyle": "hachure", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 588.2383018113569, - "y": -45.08971236581036, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 22.637490885793227, - "height": 13.582494531475936, - "seed": 331104360, - "groupIds": [ - "1FZGUtYp_0lg0mZX7lxmQ", - "2xO--DSh2411Pyp1YG0B4" - ], - "boundElements": [], - "updated": 1756647186276, - "link": null, - "locked": false, - "index": "ad", - "frameId": null, - "roundness": null - }, - { - "type": "line", - "version": 842, - "versionNonce": 1454067480, - "isDeleted": false, - "id": "l8Hqi6JegDC-MxE036F3N", - "fillStyle": "hachure", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "angle": 0, - "x": 601.8207963428326, - "y": -45.08971236581036, - "strokeColor": "#000000", - "backgroundColor": "#868e96", - "width": 13.582494531475936, - "height": 4.5274981771586456, - "seed": 1042805608, - "groupIds": [ - "1FZGUtYp_0lg0mZX7lxmQ", - "2xO--DSh2411Pyp1YG0B4" - ], - "boundElements": [], - "updated": 1756647186276, - "link": null, - "locked": false, - "startBinding": null, - "endBinding": null, - "lastCommittedPoint": null, - "startArrowhead": null, - "endArrowhead": null, - "points": [ - [ - 0, - 0 - ], - [ - -4.5274981771586456, - -4.5274981771586456 - ], - [ - -13.582494531475934, - -4.527498177158644 - ], - [ - -13.582494531475936, - -8.881784197001252e-16 - ], - [ - 0, - 0 - ] - ], - "index": "ae", - "frameId": null, - "roundness": null, - "polygon": false - }, - { - "id": "C8V0VrPmqft0_wEEXIh2G", - "type": "text", - "x": 624.1500441133635, - "y": -52.606675675678645, - "width": 49.27995300292969, - "height": 27, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "#ffffff", - "fillStyle": "hachure", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "2xO--DSh2411Pyp1YG0B4" - ], - "frameId": null, - "index": "af", - "roundness": null, - "seed": 648365672, - "version": 132, - "versionNonce": 664906776, - "isDeleted": false, - "boundElements": [], - "updated": 1756647186276, - "link": null, - "locked": false, - "text": "Open", - "fontSize": 20, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "Open", - "autoResize": true, - "lineHeight": 1.35 } ], "appState": { diff --git a/docs/wireframe.assets/with_pdf_opened.excalidraw b/docs/wireframe.assets/with_pdf_opened.excalidraw index 3c2347e..2d300b2 100644 --- a/docs/wireframe.assets/with_pdf_opened.excalidraw +++ b/docs/wireframe.assets/with_pdf_opened.excalidraw @@ -35,9 +35,9 @@ "id": "thumbs", "type": "rectangle", "x": 102.48263041178382, - "y": 134.76353963216147, - "width": 158.04682413736987, - "height": 589.4899749755859, + "y": 176.8952360607328, + "width": 160.1859755743119, + "height": 547.3582785470146, "angle": 0, "strokeColor": "#1f2937", "backgroundColor": "transparent", @@ -51,11 +51,16 @@ "index": "a3", "roundness": null, "seed": 1003, - "version": 112, - "versionNonce": 1259126921, + "version": 157, + "versionNonce": 1712101276, "isDeleted": false, - "boundElements": [], - "updated": 1756625049829, + "boundElements": [ + { + "id": "JeHos26tW2fq01Rk4WLuz", + "type": "arrow" + } + ], + "updated": 1756782722215, "link": null, "locked": false }, @@ -132,9 +137,9 @@ { "id": "thumb-2", "type": "rectangle", - "x": 139.97398376464844, + "x": 129.6354217529297, "y": 335.77476501464844, - "width": 97.08328247070314, + "width": 107.42184448242189, "height": 134.98046874999997, "angle": 0, "strokeColor": "#6b7280", @@ -149,8 +154,8 @@ "index": "a5", "roundness": null, "seed": 1005, - "version": 90, - "versionNonce": 387456489, + "version": 98, + "versionNonce": 1579773340, "isDeleted": false, "boundElements": [ { @@ -158,16 +163,16 @@ "id": "Gs_DUiE5dIevh4J_3VybF" } ], - "updated": 1756624676182, + "updated": 1756782821238, "link": null, "locked": false }, { "id": "Gs_DUiE5dIevh4J_3VybF", "type": "text", - "x": 160.07565307617188, + "x": 154.90636444091797, "y": 389.76499938964844, - "width": 56.87994384765625, + "width": 56.87995910644531, "height": 27, "angle": 0, "strokeColor": "#1e1e1e", @@ -182,11 +187,11 @@ "index": "a5V", "roundness": null, "seed": 1852090887, - "version": 9, - "versionNonce": 321877033, + "version": 14, + "versionNonce": 845240860, "isDeleted": false, "boundElements": [], - "updated": 1756626498564, + "updated": 1756782821238, "link": null, "locked": false, "text": "page2", @@ -545,312 +550,13 @@ "autoResize": true, "lineHeight": 1.35 }, - { - "id": "nfrsieMiuqFplgD4IGhD9", - "type": "rectangle", - "x": 108.59586079915357, - "y": 131.71744791666669, - "width": 691.0762911945586, - "height": 40, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "index": "aX", - "roundness": null, - "seed": 396258633, - "version": 178, - "versionNonce": 1022335177, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498564, - "link": null, - "locked": false - }, - { - "id": "zoom-", - "type": "text", - "x": 630.0716857910156, - "y": 141.29579994895244, - "width": 8.18438720703125, - "height": 18.430332599986684, - "angle": 0, - "strokeColor": "#374151", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "r8UQCQ2BudsD9_j2c_B2W" - ], - "frameId": null, - "index": "aZ", - "roundness": null, - "seed": 1016, - "version": 83, - "versionNonce": 921933737, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498564, - "link": null, - "locked": false, - "text": "−", - "fontSize": 13.652098222212357, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "−", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "id": "zoom-value", - "type": "text", - "x": 645.240683815696, - "y": 141.29579994895244, - "width": 37.279876708984375, - "height": 18.430332599986684, - "angle": 0, - "strokeColor": "#374151", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "r8UQCQ2BudsD9_j2c_B2W" - ], - "frameId": null, - "index": "aa", - "roundness": null, - "seed": 1017, - "version": 83, - "versionNonce": 359781223, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498564, - "link": null, - "locked": false, - "text": "100%", - "fontSize": 13.652098222212357, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "100%", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "id": "zoom+", - "type": "text", - "x": 681.646279074929, - "y": 141.29579994895244, - "width": 8.18438720703125, - "height": 18.430332599986684, - "angle": 0, - "strokeColor": "#374151", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "r8UQCQ2BudsD9_j2c_B2W" - ], - "frameId": null, - "index": "ab", - "roundness": null, - "seed": 1018, - "version": 83, - "versionNonce": 299154057, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498564, - "link": null, - "locked": false, - "text": "+", - "fontSize": 13.652098222212357, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "+", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "id": "-9UWBd4AturQx2OL5HZ4u", - "type": "text", - "x": 345.0416303362167, - "y": 138.75596346552493, - "width": 165.37982177734375, - "height": 27, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "index": "ac", - "roundness": null, - "seed": 1254868007, - "version": 60, - "versionNonce": 129406249, - "isDeleted": false, - "boundElements": [], - "updated": 1756646374710, - "link": null, - "locked": false, - "text": "file:agreement.pdf", - "fontSize": 20, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "file:agreement.pdf", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "id": "nav-prev", - "type": "text", - "x": 131.8096923828125, - "y": 141.26295471191406, - "width": 13.8427734375, - "height": 24.3, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "4h3rvJ5DuPOaXNi2cTIGn" - ], - "frameId": null, - "index": "ad", - "roundness": null, - "seed": 1013, - "version": 147, - "versionNonce": 2119074153, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498565, - "link": null, - "locked": false, - "text": "◀", - "fontSize": 18, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "◀", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "id": "nav-page", - "type": "text", - "x": 161.8096923828125, - "y": 139.26295471191406, - "width": 92.57337951660156, - "height": 24.3, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "4h3rvJ5DuPOaXNi2cTIGn" - ], - "frameId": null, - "index": "ae", - "roundness": null, - "seed": 1014, - "version": 150, - "versionNonce": 887317927, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498565, - "link": null, - "locked": false, - "text": "Page 2 / 10", - "fontSize": 18, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "Page 2 / 10", - "autoResize": true, - "lineHeight": 1.35 - }, - { - "id": "nav-next", - "type": "text", - "x": 271.35278875177556, - "y": 140.0437497225675, - "width": 13.8427734375, - "height": 24.3, - "angle": 0, - "strokeColor": "#1e1e1e", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [ - "4h3rvJ5DuPOaXNi2cTIGn" - ], - "frameId": null, - "index": "af", - "roundness": null, - "seed": 1015, - "version": 179, - "versionNonce": 162294857, - "isDeleted": false, - "boundElements": [], - "updated": 1756626498565, - "link": null, - "locked": false, - "text": "▶", - "fontSize": 18, - "fontFamily": 6, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "▶", - "autoResize": true, - "lineHeight": 1.35 - }, { "id": "iBaRmcgeuIwk3OfI02kuL", "type": "rectangle", - "x": 810.3571470338211, - "y": 141.27303387929163, - "width": 253.3040877100313, - "height": 554.9518986672008, + "x": 843.3519590455398, + "y": 175.25740887929163, + "width": 258.30402667487505, + "height": 538.2852726906383, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -864,11 +570,16 @@ "index": "ag", "roundness": null, "seed": 1975872585, - "version": 182, - "versionNonce": 1498742761, + "version": 252, + "versionNonce": 1239337116, "isDeleted": false, - "boundElements": [], - "updated": 1756625664404, + "boundElements": [ + { + "id": "F0fDJsXLlR0chlTMrN_Zn", + "type": "arrow" + } + ], + "updated": 1756783028691, "link": null, "locked": false }, @@ -912,8 +623,8 @@ { "id": "XixBzXhYxxZLeqjg8z0ZO", "type": "rectangle", - "x": 827.0336106324435, - "y": 162.60581025133592, + "x": 861.0336106324435, + "y": 191.60581025133592, "width": 211.53344390600182, "height": 87.10747268498352, "angle": 0, @@ -929,8 +640,8 @@ "index": "ai", "roundness": null, "seed": 891929161, - "version": 71, - "versionNonce": 306274281, + "version": 138, + "versionNonce": 957178020, "isDeleted": false, "boundElements": [ { @@ -942,15 +653,15 @@ "type": "arrow" } ], - "updated": 1756626270315, + "updated": 1756782988122, "link": null, "locked": false }, { "id": "whP9P0oU10wxfki5Tzc62", "type": "text", - "x": 843.3004241381788, - "y": 192.65954659382768, + "x": 877.3004241381788, + "y": 221.65954659382768, "width": 178.99981689453125, "height": 27, "angle": 0, @@ -966,11 +677,11 @@ "index": "aiV", "roundness": null, "seed": 455291975, - "version": 32, - "versionNonce": 197786409, + "version": 99, + "versionNonce": 829516700, "isDeleted": false, "boundElements": [], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false, "text": "signature 1 preview", @@ -986,8 +697,8 @@ { "id": "Z55O9bqO71SGHYMaUBPhR", "type": "rectangle", - "x": 830.9167767144809, - "y": 371.74081562846106, + "x": 864.9167767144809, + "y": 400.74081562846106, "width": 211.53344390600182, "height": 106.79561266731257, "angle": 0, @@ -1005,8 +716,8 @@ "index": "amG", "roundness": null, "seed": 1049648681, - "version": 161, - "versionNonce": 1622262311, + "version": 228, + "versionNonce": 1768008740, "isDeleted": false, "boundElements": [ { @@ -1014,15 +725,15 @@ "id": "XeRDzIu_O7UfMwzj2jVSr" } ], - "updated": 1756626080687, + "updated": 1756782988122, "link": null, "locked": false }, { "id": "XeRDzIu_O7UfMwzj2jVSr", "type": "text", - "x": 869.7335627543958, - "y": 376.74081562846106, + "x": 903.7335627543958, + "y": 405.74081562846106, "width": 133.89987182617188, "height": 27, "angle": 0, @@ -1040,11 +751,11 @@ "index": "amV", "roundness": null, "seed": 931906631, - "version": 71, - "versionNonce": 1027461895, + "version": 138, + "versionNonce": 402233372, "isDeleted": false, "boundElements": [], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false, "text": " new signature", @@ -1060,8 +771,8 @@ { "id": "IyLBn_6s8mdEe3m4j4aVa", "type": "rectangle", - "x": 847.9390554128533, - "y": 403.00447723591344, + "x": 881.9390554128533, + "y": 432.00447723591344, "width": 78.54616510032311, "height": 64, "angle": 0, @@ -1079,8 +790,8 @@ "index": "aml", "roundness": null, "seed": 92751719, - "version": 149, - "versionNonce": 478282985, + "version": 216, + "versionNonce": 807438244, "isDeleted": false, "boundElements": [ { @@ -1092,15 +803,15 @@ "type": "arrow" } ], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false }, { "id": "DF_YBy8mO-3zg5Qml1Ob8", "type": "text", - "x": 866.0221584097923, - "y": 408.00447723591344, + "x": 900.0221584097923, + "y": 437.00447723591344, "width": 42.37995910644531, "height": 54, "angle": 0, @@ -1118,11 +829,11 @@ "index": "an", "roundness": null, "seed": 446978921, - "version": 84, - "versionNonce": 1748752935, + "version": 151, + "versionNonce": 1170748572, "isDeleted": false, "boundElements": [], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false, "text": "from\nfile", @@ -1138,8 +849,8 @@ { "id": "jv-o_lsyoFbRU2v247F0e", "type": "rectangle", - "x": 953.5213155306128, - "y": 407.1068678489766, + "x": 987.5213155306128, + "y": 436.1068678489766, "width": 59.8151745391508, "height": 60, "angle": 0, @@ -1157,8 +868,8 @@ "index": "anV", "roundness": null, "seed": 1577206471, - "version": 87, - "versionNonce": 1169684135, + "version": 154, + "versionNonce": 24638244, "isDeleted": false, "boundElements": [ { @@ -1166,15 +877,15 @@ "id": "S7pHJH_ya1LUkqOu0N0EC" } ], - "updated": 1756626080687, + "updated": 1756782988122, "link": null, "locked": false }, { "id": "S7pHJH_ya1LUkqOu0N0EC", "type": "text", - "x": 960.2389308763601, - "y": 423.6068678489766, + "x": 994.2389308763601, + "y": 452.6068678489766, "width": 46.37994384765625, "height": 27, "angle": 0, @@ -1192,11 +903,11 @@ "index": "ao", "roundness": null, "seed": 509366759, - "version": 74, - "versionNonce": 765689801, + "version": 141, + "versionNonce": 1754218780, "isDeleted": false, "boundElements": [], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false, "text": "draw", @@ -1212,7 +923,7 @@ { "id": "pLSlN_M61Uiudwuy70z_h", "type": "rectangle", - "x": 852.0588495823167, + "x": 886.0588495823167, "y": 621.478834804223, "width": 164.7117303241327, "height": 54.0073862032541, @@ -1231,8 +942,8 @@ "type": 3 }, "seed": 1346415783, - "version": 132, - "versionNonce": 1472685033, + "version": 170, + "versionNonce": 467005092, "isDeleted": false, "boundElements": [ { @@ -1240,14 +951,14 @@ "id": "NyLAd21ZYYngMMBcBhfZ_" } ], - "updated": 1756626091145, + "updated": 1756782988122, "link": null, "locked": false }, { "id": "NyLAd21ZYYngMMBcBhfZ_", "type": "text", - "x": 904.844745567137, + "x": 938.844745567137, "y": 634.98252790585, "width": 59.13993835449219, "height": 27, @@ -1264,11 +975,11 @@ "index": "as", "roundness": null, "seed": 1815080073, - "version": 23, - "versionNonce": 271318343, + "version": 61, + "versionNonce": 666942876, "isDeleted": false, "boundElements": [], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false, "text": "export", @@ -1284,8 +995,8 @@ { "id": "8Mrx4xKoYQb9pYl6Nz7lJ", "type": "rectangle", - "x": 829.6765765874698, - "y": 267.092786204764, + "x": 863.6765765874698, + "y": 296.092786204764, "width": 211.53344390600182, "height": 87.10747268498352, "angle": 0, @@ -1301,8 +1012,8 @@ "index": "at", "roundness": null, "seed": 233656615, - "version": 95, - "versionNonce": 1352197063, + "version": 163, + "versionNonce": 1072200228, "isDeleted": false, "boundElements": [ { @@ -1312,17 +1023,21 @@ { "id": "oBLDDEiNeM4ftDjo0B4oS", "type": "arrow" + }, + { + "id": "IcoXGklBcxgzbrgxgcIkQ", + "type": "arrow" } ], - "updated": 1756626122191, + "updated": 1756782988122, "link": null, "locked": false }, { "id": "g5CeVohCoK1BBB9N-B-V8", "type": "text", - "x": 845.943390093205, - "y": 297.1465225472558, + "x": 879.943390093205, + "y": 326.1465225472558, "width": 178.99981689453125, "height": 27, "angle": 0, @@ -1338,11 +1053,11 @@ "index": "au", "roundness": null, "seed": 244749383, - "version": 60, - "versionNonce": 1452449449, + "version": 127, + "versionNonce": 612881180, "isDeleted": false, "boundElements": [], - "updated": 1756626498565, + "updated": 1756782988122, "link": null, "locked": false, "text": "signature 2 preview", @@ -1358,10 +1073,10 @@ { "id": "oBLDDEiNeM4ftDjo0B4oS", "type": "arrow", - "x": 824.0531494801987, - "y": 315.7097111409047, - "width": 125.23480537103046, - "height": 263.9451586265342, + "x": 858.0531494801987, + "y": 331.55426023468453, + "width": 157.58909128142238, + "height": 276.96342284655645, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -1377,8 +1092,8 @@ "type": 2 }, "seed": 1498168745, - "version": 153, - "versionNonce": 113571591, + "version": 252, + "versionNonce": 592612252, "isDeleted": false, "boundElements": [ { @@ -1386,7 +1101,7 @@ "id": "4xE9aa2ywOSlZgWwTQhwK" } ], - "updated": 1756626625057, + "updated": 1756783366019, "link": null, "locked": false, "points": [ @@ -1395,21 +1110,25 @@ 0 ], [ - -101.6065507296687, - 140.74088224558102 + -135.6065507296687, + 124.89633315180117 ], [ - -125.23480537103046, - 263.9451586265342 + -157.58909128142238, + 276.96342284655645 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "8Mrx4xKoYQb9pYl6Nz7lJ", - "focus": 0.785182438526405, + "focus": 0.7851824385264009, "gap": 5.623427107271027 }, - "endBinding": null, + "endBinding": { + "elementId": "XERWDNpIQMuKdXS0vdMkz", + "focus": 0.21161433945578534, + "gap": 7.684403366504625 + }, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false @@ -1419,7 +1138,7 @@ "type": "text", "x": 658.6866576494558, "y": 442.9505933864857, - "width": 127.51988220214844, + "width": 127.51985168457031, "height": 27, "angle": 0, "strokeColor": "#1e1e1e", @@ -1434,11 +1153,11 @@ "index": "avV", "roundness": null, "seed": 1357516649, - "version": 15, - "versionNonce": 34477865, + "version": 16, + "versionNonce": 316809124, "isDeleted": false, "boundElements": [], - "updated": 1756626623767, + "updated": 1756782907413, "link": null, "locked": false, "text": "drag and drop", @@ -1454,10 +1173,10 @@ { "id": "IcoXGklBcxgzbrgxgcIkQ", "type": "arrow", - "x": 1041.1110308632972, - "y": 200.65400390285498, - "width": 224.12520759073232, - "height": 38.158499568758714, + "x": 1081.1581769347615, + "y": 379.1123247786131, + "width": 120.64636825007551, + "height": 61.84099524331043, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -1473,11 +1192,11 @@ "type": 2 }, "seed": 1142794121, - "version": 41, - "versionNonce": 18952775, + "version": 404, + "versionNonce": 1967020444, "isDeleted": false, "boundElements": [], - "updated": 1756626271478, + "updated": 1756783298253, "link": null, "locked": false, "points": [ @@ -1486,17 +1205,21 @@ 0 ], [ - 224.12520759073232, - -38.158499568758714 + 120.64636825007551, + 61.84099524331043 ] ], "lastCommittedPoint": null, "startBinding": { - "elementId": "XixBzXhYxxZLeqjg8z0ZO", - "focus": 0.21011531284522128, - "gap": 2.543976324851883 + "elementId": "8Mrx4xKoYQb9pYl6Nz7lJ", + "focus": -0.18627185477610247, + "gap": 5.948156441289711 + }, + "endBinding": { + "elementId": "g4Uh6FAsGkMRFYRgcKkcM", + "focus": -0.3588501149170561, + "gap": 12.863567149268192 }, - "endBinding": null, "startArrowhead": null, "endArrowhead": "arrow", "elbowed": false @@ -1504,10 +1227,10 @@ { "id": "g4Uh6FAsGkMRFYRgcKkcM", "type": "text", - "x": 1267.801901396605, - "y": 91.83448600449418, - "width": 340.91685327733757, - "height": 126.06201358869963, + "x": 1214.6681123341052, + "y": 427.44649568873893, + "width": 223.01190409218867, + "height": 82.4638894025017, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -1521,15 +1244,20 @@ "index": "az", "roundness": null, "seed": 815013001, - "version": 320, - "versionNonce": 2131041543, + "version": 508, + "versionNonce": 479223068, "isDeleted": false, - "boundElements": [], - "updated": 1756626484398, + "boundElements": [ + { + "id": "IcoXGklBcxgzbrgxgcIkQ", + "type": "arrow" + } + ], + "updated": 1756783298253, "link": null, "locked": false, "text": "long press or right\nclick will show menu\nwith delete option", - "fontSize": 33.616536956986565, + "fontSize": 21.990370507333783, "fontFamily": 5, "textAlign": "left", "verticalAlign": "top", @@ -1538,13 +1266,732 @@ "autoResize": false, "lineHeight": 1.25 }, + { + "id": "JeHos26tW2fq01Rk4WLuz", + "type": "arrow", + "x": 117.45638384137827, + "y": 162.71419735106227, + "width": 224.2596871512277, + "height": 213.49697807256328, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dotted", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0F", + "roundness": { + "type": 2 + }, + "seed": 1040972700, + "version": 626, + "versionNonce": 784144676, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "uuo49wkpxAPVQAL4pCPH6" + } + ], + "updated": 1756783728888, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -224.2596871512277, + 124.36382955218116 + ], + [ + -3.342613377427483, + 213.49697807256328 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "7n3D1UiHStvKJRF4e_P9P", + "focus": -0.8708888535728319, + "gap": 14.257377125596634 + }, + "endBinding": { + "elementId": "thumbs", + "focus": 0.15275121438122682, + "gap": 11.63114005216697 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false, + "fixedSegments": [ + { + "index": 3, + "start": [ + -220.92637416294644, + 40 + ], + "end": [ + -220.92637416294644, + 173.49697807256328 + ] + } + ], + "startIsSpecial": false, + "endIsSpecial": false + }, + { + "id": "uuo49wkpxAPVQAL4pCPH6", + "type": "text", + "x": -212.2532239641463, + "y": 249.57802690324343, + "width": 210.89984130859375, + "height": 75, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0FV", + "roundness": null, + "seed": 1845681692, + "version": 54, + "versionNonce": 704067612, + "isDeleted": false, + "boundElements": null, + "updated": 1756783074885, + "link": null, + "locked": false, + "text": "determine page\noverview sidebar open\nor not", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "JeHos26tW2fq01Rk4WLuz", + "originalText": "determine page overview sidebar open or not", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "nfrsieMiuqFplgD4IGhD9", + "type": "rectangle", + "x": 97.63974507649743, + "y": 121.46820649646577, + "width": 1003.3605319172146, + "height": 50.249241420200924, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0K", + "roundness": null, + "seed": 396258633, + "version": 308, + "versionNonce": 1717370020, + "isDeleted": false, + "boundElements": [], + "updated": 1756782935549, + "link": null, + "locked": false + }, + { + "id": "-9UWBd4AturQx2OL5HZ4u", + "type": "text", + "x": 194.4774111793157, + "y": 133.74294263219156, + "width": 165.37982177734375, + "height": 27, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0L", + "roundness": null, + "seed": 1254868007, + "version": 118, + "versionNonce": 33667492, + "isDeleted": false, + "boundElements": [], + "updated": 1756783209753, + "link": null, + "locked": false, + "text": "file:agreement.pdf", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "file:agreement.pdf", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "type": "rectangle", + "version": 492, + "versionNonce": 1835178396, + "isDeleted": false, + "id": "UH5C0HZF_BQTThYHMND7e", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 111.8271495825251, + "y": 153.36571868816873, + "strokeColor": "#000000", + "backgroundColor": "#868e96", + "width": 34.756940485339044, + "height": 8.097388585673801, + "seed": 1402192164, + "groupIds": [ + "EvYozfdyMhomPImwubtdV" + ], + "index": "b0M", + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1756782872854, + "link": null, + "locked": false + }, + { + "type": "rectangle", + "version": 515, + "versionNonce": 48389668, + "isDeleted": false, + "id": "7n3D1UiHStvKJRF4e_P9P", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 111.8271495825251, + "y": 140.35943163979184, + "strokeColor": "#000000", + "backgroundColor": "#868e96", + "width": 34.509305951589674, + "height": 8.097388585673801, + "seed": 357039268, + "groupIds": [ + "EvYozfdyMhomPImwubtdV" + ], + "index": "b0N", + "frameId": null, + "roundness": null, + "boundElements": [ + { + "id": "JeHos26tW2fq01Rk4WLuz", + "type": "arrow" + } + ], + "updated": 1756782872854, + "link": null, + "locked": false + }, + { + "type": "rectangle", + "version": 591, + "versionNonce": 1502938652, + "isDeleted": false, + "id": "YJ_lVa0OyMnY81sRaL9Z5", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 111.6436393596203, + "y": 127.35314459141497, + "strokeColor": "#000000", + "backgroundColor": "#868e96", + "width": 34.42239108582276, + "height": 7.397699638354526, + "seed": 468240420, + "groupIds": [ + "EvYozfdyMhomPImwubtdV" + ], + "index": "b0O", + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1756782872854, + "link": null, + "locked": false + }, + { + "id": "zoom-", + "type": "text", + "x": 770.0455423990886, + "y": 135.132597027403, + "width": 11.027289708489763, + "height": 24.83223384510387, + "angle": 0, + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [ + "r8UQCQ2BudsD9_j2c_B2W" + ], + "frameId": null, + "index": "b0P", + "roundness": null, + "seed": 1016, + "version": 158, + "versionNonce": 294631844, + "isDeleted": false, + "boundElements": [], + "updated": 1756783273470, + "link": null, + "locked": false, + "text": "−", + "fontSize": 18.394247292669533, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "−", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "id": "zoom-value", + "type": "text", + "x": 790.4835949464991, + "y": 135.132597027403, + "width": 50.22929516501564, + "height": 24.83223384510387, + "angle": 0, + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [ + "r8UQCQ2BudsD9_j2c_B2W" + ], + "frameId": null, + "index": "b0Q", + "roundness": null, + "seed": 1017, + "version": 158, + "versionNonce": 305194276, + "isDeleted": false, + "boundElements": [], + "updated": 1756783273470, + "link": null, + "locked": false, + "text": "100%", + "fontSize": 18.394247292669533, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "100%", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "id": "zoom+", + "type": "text", + "x": 839.5349210602847, + "y": 135.132597027403, + "width": 11.027289708489763, + "height": 24.83223384510387, + "angle": 0, + "strokeColor": "#374151", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [ + "r8UQCQ2BudsD9_j2c_B2W" + ], + "frameId": null, + "index": "b0R", + "roundness": null, + "seed": 1018, + "version": 158, + "versionNonce": 1285595300, + "isDeleted": false, + "boundElements": [], + "updated": 1756783273470, + "link": null, + "locked": false, + "text": "+", + "fontSize": 18.394247292669533, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "+", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "id": "nav-prev", + "type": "text", + "x": 606.1832551502046, + "y": 137.48827471051894, + "width": 13.8427734375, + "height": 24.3, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [ + "H6_unFfiihfDsT0Xhc3dA" + ], + "frameId": null, + "index": "b0RV", + "roundness": null, + "seed": 1013, + "version": 313, + "versionNonce": 1688974748, + "isDeleted": false, + "boundElements": [], + "updated": 1756783666200, + "link": null, + "locked": false, + "text": "◀", + "fontSize": 18, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "◀", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "id": "nav-page", + "type": "text", + "x": 629.1832551502046, + "y": 136.48827471051894, + "width": 92.57337951660156, + "height": 24.3, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [ + "H6_unFfiihfDsT0Xhc3dA" + ], + "frameId": null, + "index": "b0S", + "roundness": null, + "seed": 1014, + "version": 308, + "versionNonce": 1430848036, + "isDeleted": false, + "boundElements": [], + "updated": 1756783666200, + "link": null, + "locked": false, + "text": "Page 2 / 10", + "fontSize": 18, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Page 2 / 10", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "id": "nav-next", + "type": "text", + "x": 720.7263515191676, + "y": 137.26906972117237, + "width": 13.8427734375, + "height": 24.3, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [ + "H6_unFfiihfDsT0Xhc3dA" + ], + "frameId": null, + "index": "b0T", + "roundness": null, + "seed": 1015, + "version": 355, + "versionNonce": 1096458780, + "isDeleted": false, + "boundElements": [], + "updated": 1756783666200, + "link": null, + "locked": false, + "text": "▶", + "fontSize": 18, + "fontFamily": 6, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "▶", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "type": "rectangle", + "version": 575, + "versionNonce": 319989404, + "isDeleted": false, + "id": "3Ra9iobk1nccqYAYMZbYC", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 1051.925728629246, + "y": 157.41590274958705, + "strokeColor": "#000000", + "backgroundColor": "#868e96", + "width": 34.756940485339044, + "height": 8.097388585673801, + "seed": 1236508572, + "groupIds": [ + "qmYDqVLNMNBpGFqwFZA2B" + ], + "index": "b0V", + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1756782938753, + "link": null, + "locked": false + }, + { + "type": "rectangle", + "version": 598, + "versionNonce": 1203467036, + "isDeleted": false, + "id": "2rG9lcGEImQqH-j0iVBHa", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 1051.925728629246, + "y": 144.40961570121013, + "strokeColor": "#000000", + "backgroundColor": "#868e96", + "width": 34.509305951589674, + "height": 8.097388585673801, + "seed": 1372933148, + "groupIds": [ + "qmYDqVLNMNBpGFqwFZA2B" + ], + "index": "b0W", + "frameId": null, + "roundness": null, + "boundElements": [], + "updated": 1756782938753, + "link": null, + "locked": false + }, + { + "type": "rectangle", + "version": 675, + "versionNonce": 1497986212, + "isDeleted": false, + "id": "-6RmK0bLNp7Zx4vuEKcTJ", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 1051.7422184063412, + "y": 131.40332865283327, + "strokeColor": "#000000", + "backgroundColor": "#868e96", + "width": 34.42239108582276, + "height": 7.397699638354526, + "seed": 839739548, + "groupIds": [ + "qmYDqVLNMNBpGFqwFZA2B" + ], + "index": "b0X", + "frameId": null, + "roundness": null, + "boundElements": [ + { + "id": "F0fDJsXLlR0chlTMrN_Zn", + "type": "arrow" + } + ], + "updated": 1756783020643, + "link": null, + "locked": false + }, + { + "id": "F0fDJsXLlR0chlTMrN_Zn", + "type": "arrow", + "x": 1084.4885924055855, + "y": 162.15127826909176, + "width": 185.58781215122758, + "height": 123.49694755498516, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dotted", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0Y", + "roundness": { + "type": 2 + }, + "seed": 1596114724, + "version": 868, + "versionNonce": 1670926884, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "a-gvdOxFLR2JqUwOsFpGr" + } + ], + "updated": 1756783725627, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 185.58781215122758, + 36.68151754046241 + ], + [ + 13.342613377427597, + 123.49694755498516 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "-6RmK0bLNp7Zx4vuEKcTJ", + "focus": 0, + "gap": 14 + }, + "endBinding": { + "elementId": "iBaRmcgeuIwk3OfI02kuL", + "focus": -0.28597602069539235, + "gap": 3.8247799374018996 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false, + "fixedSegments": [ + { + "index": 3, + "start": [ + -220.92637416294644, + 40 + ], + "end": [ + -220.92637416294644, + 173.49697807256328 + ] + } + ], + "startIsSpecial": false, + "endIsSpecial": false + }, + { + "id": "a-gvdOxFLR2JqUwOsFpGr", + "type": "text", + "x": 1164.6264839025162, + "y": 161.33279580955417, + "width": 210.89984130859375, + "height": 75, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0Z", + "roundness": null, + "seed": 621167268, + "version": 65, + "versionNonce": 232181028, + "isDeleted": false, + "boundElements": [], + "updated": 1756783063773, + "link": null, + "locked": false, + "text": "determine signature\noverview sidebar open\nor not", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "F0fDJsXLlR0chlTMrN_Zn", + "originalText": "determine signature overview sidebar open or not", + "autoResize": true, + "lineHeight": 1.25 + }, { "id": "ZypttJv8wXC3_nnNOBniI", "type": "rectangle", - "x": 829.2352501918391, - "y": 61.91924305998282, - "width": 109.23454710748254, - "height": 36.33306860750372, + "x": 390.60676101540685, + "y": 129.3906827398418, + "width": 101.72584959771692, + "height": 33.83556182966293, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -1557,21 +2004,21 @@ "Lc9ztVjgkY1vXWy5Zdgbj" ], "frameId": null, - "index": "b07", + "index": "b0a", "roundness": null, "seed": 2121158023, - "version": 147, - "versionNonce": 1637664743, + "version": 269, + "versionNonce": 1207713700, "isDeleted": false, "boundElements": [], - "updated": 1756646695346, + "updated": 1756783261253, "link": null, "locked": false }, { "type": "rectangle", - "version": 751, - "versionNonce": 694242825, + "version": 873, + "versionNonce": 1956904092, "isDeleted": false, "id": "ioXx3rr4MNsaBB7PmtfQN", "fillStyle": "hachure", @@ -1580,29 +2027,29 @@ "roughness": 1, "opacity": 100, "angle": 0, - "x": 841.3644200044819, - "y": 73.26064731400524, + "x": 401.90218116203874, + "y": 139.9524877174958, "strokeColor": "#000000", "backgroundColor": "#868e96", - "width": 22.637490885793227, - "height": 13.582494531475936, + "width": 21.08140743103925, + "height": 12.648844458623548, "seed": 1886922409, "groupIds": [ "c9Kai9BBPEMYx9XHbYqXj", "Lc9ztVjgkY1vXWy5Zdgbj" ], "boundElements": [], - "updated": 1756646695346, + "updated": 1756783261253, "link": null, "locked": false, - "index": "b08", + "index": "b0b", "frameId": null, "roundness": null }, { "type": "line", - "version": 768, - "versionNonce": 313078535, + "version": 892, + "versionNonce": 407537052, "isDeleted": false, "id": "FHZJ-3xFc01aevwPFV0eo", "fillStyle": "hachure", @@ -1611,19 +2058,19 @@ "roughness": 1, "opacity": 100, "angle": 0, - "x": 854.9469145359578, - "y": 73.26064731400524, + "x": 414.5510256206623, + "y": 139.9524877174958, "strokeColor": "#000000", "backgroundColor": "#868e96", - "width": 13.582494531475936, - "height": 4.5274981771586456, + "width": 12.648844458623548, + "height": 4.2162814862078495, "seed": 1777297801, "groupIds": [ "c9Kai9BBPEMYx9XHbYqXj", "Lc9ztVjgkY1vXWy5Zdgbj" ], "boundElements": [], - "updated": 1756646695346, + "updated": 1756783261270, "link": null, "locked": false, "startBinding": null, @@ -1637,23 +2084,23 @@ 0 ], [ - -4.5274981771586456, - -4.5274981771586456 + -4.2162814862078495, + -4.2162814862078495 ], [ - -13.582494531475934, - -4.527498177158644 + -12.648844458623547, + -4.216281486207846 ], [ - -13.582494531475936, - -8.881784197001252e-16 + -12.648844458623548, + 0 ], [ 0, 0 ] ], - "index": "b09", + "index": "b0c", "frameId": null, "roundness": null, "polygon": false @@ -1661,10 +2108,10 @@ { "id": "oyNLDcnSYZa440t9nKF1f", "type": "text", - "x": 877.2761623064885, - "y": 65.74368400413695, - "width": 49.27995300292969, - "height": 27, + "x": 435.345378327596, + "y": 132.9522345985003, + "width": 45.89248749688999, + "height": 25.144041073707307, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -1677,18 +2124,18 @@ "Lc9ztVjgkY1vXWy5Zdgbj" ], "frameId": null, - "index": "b0A", + "index": "b0d", "roundness": null, "seed": 2083025639, - "version": 58, - "versionNonce": 771532009, + "version": 180, + "versionNonce": 1488062748, "isDeleted": false, "boundElements": [], - "updated": 1756646695346, + "updated": 1756783261253, "link": null, "locked": false, "text": "Open", - "fontSize": 20, + "fontSize": 18.62521561015356, "fontFamily": 6, "textAlign": "left", "verticalAlign": "top", @@ -1696,6 +2143,180 @@ "originalText": "Open", "autoResize": true, "lineHeight": 1.35 + }, + { + "id": "XERWDNpIQMuKdXS0vdMkz", + "type": "rectangle", + "x": 589.0013626815467, + "y": 616.2020864477456, + "width": 172.90500559219964, + "height": 64, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0e", + "roundness": null, + "seed": 1973690532, + "version": 402, + "versionNonce": 1207557156, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "p4WkRi6XxRu6H6Gl4Eaed" + }, + { + "id": "oBLDDEiNeM4ftDjo0B4oS", + "type": "arrow" + }, + { + "id": "-lwmH-9KU_cZF8GcOqfVM", + "type": "arrow" + } + ], + "updated": 1756783698806, + "link": null, + "locked": false + }, + { + "id": "p4WkRi6XxRu6H6Gl4Eaed", + "type": "text", + "x": 601.153938719834, + "y": 621.2020864477456, + "width": 148.599853515625, + "height": 54, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0f", + "roundness": null, + "seed": 1696623652, + "version": 299, + "versionNonce": 1975701276, + "isDeleted": false, + "boundElements": [], + "updated": 1756783366019, + "link": null, + "locked": false, + "text": "paced_signature\npreview", + "fontSize": 20, + "fontFamily": 6, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "XERWDNpIQMuKdXS0vdMkz", + "originalText": "paced_signature preview", + "autoResize": true, + "lineHeight": 1.35 + }, + { + "id": "OlpYnzLKDyZ_3VAGEg_Oz", + "type": "text", + "x": 510.96197186254165, + "y": 788.9623037888565, + "width": 314.0015281156262, + "height": 82.46388940250168, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "hachure", + "strokeWidth": 2, + "strokeStyle": "dotted", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0g", + "roundness": null, + "seed": 649397412, + "version": 606, + "versionNonce": 632673436, + "isDeleted": false, + "boundElements": [ + { + "id": "-lwmH-9KU_cZF8GcOqfVM", + "type": "arrow" + } + ], + "updated": 1756783737035, + "link": null, + "locked": false, + "text": "long press or right click will\nshow menu with delete\noption", + "fontSize": 21.990370507333783, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "long press or right click will show menu with delete option", + "autoResize": false, + "lineHeight": 1.25 + }, + { + "id": "-lwmH-9KU_cZF8GcOqfVM", + "type": "arrow", + "x": 690.0849965873819, + "y": 690.6803528195342, + "width": 37.51042821573526, + "height": 90.90275065104186, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dotted", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "b0h", + "roundness": { + "type": 2 + }, + "seed": 500737060, + "version": 106, + "versionNonce": 969466140, + "isDeleted": false, + "boundElements": null, + "updated": 1756783737035, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -37.51042821573526, + 90.90275065104186 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "XERWDNpIQMuKdXS0vdMkz", + "focus": -0.32057357063438363, + "gap": 10.47826637178855 + }, + "endBinding": { + "elementId": "OlpYnzLKDyZ_3VAGEg_Oz", + "focus": -0.20515111160640007, + "gap": 7.379200318280482 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false } ], "appState": { diff --git a/docs/wireframe.md b/docs/wireframe.md index 04d16d9..4902cc1 100644 --- a/docs/wireframe.md +++ b/docs/wireframe.md @@ -16,7 +16,7 @@ Route: root Design notes: - Central drop zone with hint text: “Drag a PDF here or click to select”. -- Minimal top bar with app name and a gear icon for settings. +- Minimal top bar with app name and a "Configure" button with a gear icon for settings. - Clean layout encouraging first action. Illustration: @@ -29,7 +29,7 @@ Purpose: provide basic configuration before/after opening a PDF. Route: root --> settings Design notes: -- Opened via gear icon in the top bar. +- Opened via "Configure" button in the top bar. - Modal with simple sections (e.g., General, Display). - Primary action to save, secondary to cancel. @@ -39,13 +39,32 @@ Illustration: ## PDF opened -Purpose: view and navigate the PDF; prepare for signature placement. +Purpose: view and navigate the PDF; for signature placement. Route: root --> opened Design notes: -- Main canvas shows the current page. -- Navigation: previous/next page, zoom controls are placed in toolbar which is at top of main PDF canvas. -- Drag signature onto page. +- Top: A small toolbar sits at the top edge with file name text, open pdf file button, previous/next page widgets and zoom controls. + - Navigation: Previous page, Next page, and a page number input (e.g., “2 / 10”) with jump-on-Enter. + - Zoom: Zoom out, Zoom level dropdown (percent), Zoom in, Fit width, Fit page, Reset zoom. + - Optional: Find/search within PDF (if supported by engine). +- Left pane: vertical strip of page thumbnails (e.g., page1, page2, page3). Clicking a thumbnail navigates to that page; the current page is visually indicated. +- Center: main PDF viewer shows the active page. + - Ctrl/Cmd + wheel to zoom. +- Right pane: signatures drawer displaying saved signatures as cards. + - able to drag and drop signature cards onto the PDF as placed signatures. + - Each signature card shows a preview. + - long tap/right-click will show menu with options to delete, adjust graphic of image. + - "adjust graphic" opens a simple image editor, which can remove backgrounds. + - There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw". + - "from file" opens a file picker to select an image as a signature card. + - "draw" opens a simple drawing interface (draw canvas) to create a signature card. +- Interaction: drag a signature card from the right drawer onto the currently visible page to place it. + +Signature controls (after placing on page): +- Select to show bounding box with resize handles and a small inline action bar. +- Actions: Move (drag), Resize (corner/side handles), Rotate (rotation handle), Duplicate, Delete (trash icon or Delete key). +- Lock: Lock/Unlock position. +- Keyboard: Arrow keys to nudge (Shift for 10px); Shift-resize to keep aspect; Esc to cancel; Ctrl/Cmd+D to duplicate; Del/Backspace to delete. Illustration: From db0912b12fed8c4f8f58fb4d3f66f78114c10554 Mon Sep 17 00:00:00 2001 From: insleker Date: Tue, 2 Sep 2025 15:19:36 +0800 Subject: [PATCH 3/4] feat: partially update UI view to new design --- docs/wireframe.md | 13 +- lib/app.dart | 23 +- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_CN.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + .../features/pdf/view_model/view_model.dart | 10 + .../features/pdf/widgets/pdf_page_area.dart | 49 +- lib/ui/features/pdf/widgets/pdf_screen.dart | 439 +++++++++++------- lib/ui/features/pdf/widgets/pdf_toolbar.dart | 187 +++++--- .../pdf/widgets/signature_drawer.dart | 206 ++++++++ .../welcome/widgets/welcome_screen.dart | 30 +- 18 files changed, 697 insertions(+), 271 deletions(-) create mode 100644 lib/ui/features/pdf/widgets/signature_drawer.dart diff --git a/docs/wireframe.md b/docs/wireframe.md index 4902cc1..7c21a32 100644 --- a/docs/wireframe.md +++ b/docs/wireframe.md @@ -44,17 +44,20 @@ Route: root --> opened Design notes: - Top: A small toolbar sits at the top edge with file name text, open pdf file button, previous/next page widgets and zoom controls. - - Navigation: Previous page, Next page, and a page number input (e.g., “2 / 10”) with jump-on-Enter. - - Zoom: Zoom out, Zoom level dropdown (percent), Zoom in, Fit width, Fit page, Reset zoom. - - Optional: Find/search within PDF (if supported by engine). + - On the far left of the toolbar there is a button that can turn the document pages overview sidebar on and off. + - On the far right of the toolbar there is a button that can turn the signature cards overview sidebar on and off. + - Navigation: Previous page, Next page, and a page number input (e.g., “2 / 10”) with jump-on-Enter. + - Zoom: Zoom out, Zoom level dropdown (percent), Zoom in, Fit width, Fit page, Reset zoom. + - Optional: Find/search within PDF (if supported by engine). - Left pane: vertical strip of page thumbnails (e.g., page1, page2, page3). Clicking a thumbnail navigates to that page; the current page is visually indicated. - Center: main PDF viewer shows the active page. + - wheel to scroll pages. - Ctrl/Cmd + wheel to zoom. - Right pane: signatures drawer displaying saved signatures as cards. - able to drag and drop signature cards onto the PDF as placed signatures. - Each signature card shows a preview. - long tap/right-click will show menu with options to delete, adjust graphic of image. - - "adjust graphic" opens a simple image editor, which can remove backgrounds. + - "adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle). - There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw". - "from file" opens a file picker to select an image as a signature card. - "draw" opens a simple drawing interface (draw canvas) to create a signature card. @@ -62,7 +65,7 @@ Design notes: Signature controls (after placing on page): - Select to show bounding box with resize handles and a small inline action bar. -- Actions: Move (drag), Resize (corner/side handles), Rotate (rotation handle), Duplicate, Delete (trash icon or Delete key). +- Actions: Move (drag), Resize (corner/side handles), Delete (trash icon or Delete key). - Lock: Lock/Unlock position. - Keyboard: Arrow keys to nudge (Shift for 10px); Shift-resize to keep aspect; Esc to cancel; Ctrl/Cmd+D to duplicate; Del/Backspace to delete. diff --git a/lib/app.dart b/lib/app.dart index 6a1cb89..84bbc39 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,6 +6,7 @@ import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart'; import 'ui/features/preferences/providers.dart'; +import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart'; class MyApp extends StatelessWidget { const MyApp({super.key}); @@ -62,7 +63,27 @@ class MyApp extends StatelessWidget { ...AppLocalizations.localizationsDelegates, LocaleNamesLocalizationsDelegate(), ], - home: const _RootHomeSwitcher(), + home: Builder( + builder: + (ctx) => Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(ctx).appTitle), + actions: [ + OutlinedButton.icon( + key: const Key('btn_appbar_settings'), + icon: const Icon(Icons.settings), + label: Text(AppLocalizations.of(ctx).settings), + onPressed: + () => showDialog( + context: ctx, + builder: (_) => const SettingsDialog(), + ), + ), + ], + ), + body: const _RootHomeSwitcher(), + ), + ), ); }, ); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 47129d9..a43ed67 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.", "next": "Weiter", "noPdfLoaded": "Keine PDF-Datei geladen", + "noSignatureLoaded": "Keine Signatur geladen", "nothingToSaveYet": "Noch nichts zu speichern", "openPdf": "PDF öffnen...", "pageInfo": "Seite {current}/{total}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6cd67e5..e82cd40 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -61,6 +61,8 @@ "@next": {}, "noPdfLoaded": "No PDF loaded", "@noPdfLoaded": {}, + "noSignatureLoaded": "No signature loaded", + "@noSignatureLoaded": {}, "nothingToSaveYet": "Nothing to save yet", "@nothingToSaveYet": {}, "openPdf": "Open PDF...", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b0c7f80..423b127 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.", "next": "Siguiente", "noPdfLoaded": "No se ha cargado ningún PDF", + "noSignatureLoaded": "No se ha cargado ninguna firma", "nothingToSaveYet": "Aún no hay nada que guardar", "openPdf": "Abrir PDF...", "pageInfo": "Página {current}/{total}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 01336c4..3321eb1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.", "next": "Suivant", "noPdfLoaded": "Aucun PDF chargé", + "noSignatureLoaded": "Aucune signature chargée", "nothingToSaveYet": "Rien à enregistrer pour le moment", "openPdf": "Ouvrir un PDF...", "pageInfo": "Page {current}/{total}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 174dc9f..eb55c31 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。", "next": "次へ", "noPdfLoaded": "PDFが読み込まれていません", + "noSignatureLoaded": "署名は読み込まれていません", "nothingToSaveYet": "まだ保存するものがありません", "openPdf": "PDFを開く…", "pageInfo": "ページ {current}/{total}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 617ae83..22e8834 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.", "next": "다음", "noPdfLoaded": "로드된 PDF 없음", + "noSignatureLoaded": "서명이 로드되지 않았습니다", "nothingToSaveYet": "아직 저장할 내용이 없습니다.", "openPdf": "PDF 열기...", "pageInfo": "{current}/{total} 페이지", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 0f35263..23aea3a 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.", "next": "Далі", "noPdfLoaded": "PDF не завантажено", + "noSignatureLoaded": "Не завантажено жодного підпису", "nothingToSaveYet": "Ще нічого не потрібно зберігати", "openPdf": "Відкрити PDF...", "pageInfo": "Сторінка {current}/{total}", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6f6d41b..426e4e7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -27,6 +27,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。", "next": "下一頁", "noPdfLoaded": "尚未載入 PDF", + "noSignatureLoaded": "没有加载签名", "nothingToSaveYet": "尚無可儲存的內容", "openPdf": "開啟 PDF…", "pageInfo": "第 {current}/{total} 頁", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index e31d96a..9ec0a6e 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -26,6 +26,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。", "next": "下一步", "noPdfLoaded": "未加载 PDF", + "noSignatureLoaded": "未加载签名", "nothingToSaveYet": "尚无内容保存", "openPdf": "打开 PDF...", "pageInfo": "第 {current} 页 / 共 {total} 页", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 8b4f233..f41bbd4 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -27,6 +27,7 @@ "longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。", "next": "下一頁", "noPdfLoaded": "尚未載入 PDF", + "noSignatureLoaded": "未載入任何簽名", "nothingToSaveYet": "尚無可儲存的內容", "openPdf": "開啟 PDF…", "pageInfo": "第 {current}/{total} 頁", diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 8c400da..225d1df 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -251,6 +251,16 @@ class SignatureController extends StateNotifier { state = state.copyWith(editingEnabled: true); } + void clearImage() { + state = state.copyWith(imageBytes: null, rect: null, editingEnabled: false); + } + + void placeAtCenter(Offset center, {double width = 120, double height = 60}) { + Rect r = Rect.fromCenter(center: center, width: width, height: height); + r = _clampRectToPage(r); + state = state.copyWith(rect: r, editingEnabled: true); + } + // Confirm current signature: freeze editing and place it on the PDF as an immutable overlay. // Returns the Rect placed, or null if no rect to confirm. Rect? confirmCurrentSignature(WidgetRef ref) { diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart index 69e5bb8..f9ae429 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_area.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart @@ -9,21 +9,22 @@ import '../../../../data/services/providers.dart'; import '../../../../data/model/model.dart'; import '../view_model/view_model.dart'; import '../../preferences/providers.dart'; +import 'signature_drawer.dart'; class PdfPageArea extends ConsumerStatefulWidget { const PdfPageArea({ super.key, required this.pageSize, - this.controller, required this.onDragSignature, required this.onResizeSignature, required this.onConfirmSignature, required this.onClearActiveOverlay, required this.onSelectPlaced, + this.viewerController, }); final Size pageSize; - final TransformationController? controller; + final PdfViewerController? viewerController; final ValueChanged onDragSignature; final ValueChanged onResizeSignature; final VoidCallback onConfirmSignature; @@ -35,7 +36,8 @@ class PdfPageArea extends ConsumerStatefulWidget { class _PdfPageAreaState extends ConsumerState { final Map _pageKeys = {}; - final PdfViewerController _viewerController = PdfViewerController(); + late final PdfViewerController _viewerController = + widget.viewerController ?? PdfViewerController(); // Guards to avoid scroll feedback between provider and viewer int? _programmaticTargetPage; bool _suppressProviderListen = false; @@ -58,6 +60,8 @@ class _PdfPageAreaState extends ConsumerState { }); } + // No dispose required for PdfViewerController (managed by owner if any) + GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent( page, () => GlobalKey(debugLabel: 'cont_page_$page'), @@ -216,7 +220,7 @@ class _PdfPageAreaState extends ConsumerState { } }); } - return SingleChildScrollView( + final content = SingleChildScrollView( key: const Key('pdf_continuous_mock_list'), padding: const EdgeInsets.symmetric(vertical: 8), child: Column( @@ -270,6 +274,7 @@ class _PdfPageAreaState extends ConsumerState { }), ), ); + return content; }, ); } @@ -277,14 +282,14 @@ class _PdfPageAreaState extends ConsumerState { // Real continuous mode (pdfrx): copy example patterns // https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart if (pdf.pickedPdfPath != null && isContinuous) { - return PdfViewer.file( + final viewer = PdfViewer.file( pdf.pickedPdfPath!, controller: _viewerController, params: PdfViewerParams( pageAnchor: PdfPageAnchor.top, keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true), maxScale: 8, - // scrollByMouseWheel: 0.6, + scrollByMouseWheel: 0.6, // Add overlay scroll thumbs (vertical on right, horizontal on bottom) viewerOverlayBuilder: (context, size, handleLinkTap) => [ @@ -294,7 +299,7 @@ class _PdfPageAreaState extends ConsumerState { thumbSize: const Size(40, 24), thumbBuilder: (context, thumbSize, pageNumber, controller) => Container( - color: Colors.black.withOpacity(0.7), + color: Colors.black.withValues(alpha: 0.7), child: Center( child: Text( pageNumber.toString(), @@ -309,7 +314,7 @@ class _PdfPageAreaState extends ConsumerState { thumbSize: const Size(40, 24), thumbBuilder: (context, thumbSize, pageNumber, controller) => Container( - color: Colors.black.withOpacity(0.7), + color: Colors.black.withValues(alpha: 0.7), child: Center( child: Text( pageNumber.toString(), @@ -375,6 +380,34 @@ class _PdfPageAreaState extends ConsumerState { }, ), ); + // Accept drops of signature card over the viewer + final drop = DragTarget( + onWillAcceptWithDetails: (details) => details.data is SignatureDragData, + onAcceptWithDetails: (details) { + // Map the local position to UI page coordinates of the visible page + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + final local = box.globalToLocal(details.offset); + final size = box.size; + // Assume drop targets the current visible page; compute relative center + final cx = (local.dx / size.width) * widget.pageSize.width; + final cy = (local.dy / size.height) * widget.pageSize.height; + ref.read(signatureProvider.notifier).placeAtCenter(Offset(cx, cy)); + ref + .read(pdfProvider.notifier) + .setSignedPage(ref.read(pdfProvider).currentPage); + }, + builder: + (context, candidateData, rejected) => Stack( + fit: StackFit.expand, + children: [ + viewer, + if (candidateData.isNotEmpty) + Container(color: Colors.blue.withValues(alpha: 0.08)), + ], + ), + ); + return drop; } return const SizedBox.shrink(); diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index aea2668..c6dc7d1 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -5,15 +5,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:printing/printing.dart' as printing; +import 'package:pdfrx/pdfrx.dart'; import '../../../../data/services/providers.dart'; import '../view_model/view_model.dart'; import 'draw_canvas.dart'; import 'pdf_toolbar.dart'; import 'pdf_page_area.dart'; -import 'adjustments_panel.dart'; import 'pdf_pages_overview.dart'; -import '../../preferences/widgets/settings_screen.dart'; +import 'signature_drawer.dart'; +import 'adjustments_panel.dart'; class PdfSignatureHomePage extends ConsumerStatefulWidget { const PdfSignatureHomePage({super.key}); @@ -25,7 +26,9 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget { class _PdfSignatureHomePageState extends ConsumerState { static const Size _pageSize = SignatureController.pageSize; - final TransformationController _ivController = TransformationController(); + final PdfViewerController _viewerController = PdfViewerController(); + bool _showPagesSidebar = true; + bool _showSignaturesSidebar = true; // Exposed for tests to trigger the invalid-file SnackBar without UI. @visibleForTesting @@ -52,6 +55,8 @@ class _PdfSignatureHomePageState extends ConsumerState { ref.read(pdfProvider.notifier).jumpTo(page); } + // Zoom is managed by pdfrx viewer (Ctrl +/- etc.). No custom zoom here. + Future _loadSignatureFromFile() async { final typeGroup = const fs.XTypeGroup( label: 'Image', @@ -68,25 +73,7 @@ class _PdfSignatureHomePageState extends ConsumerState { } } - void _createNewSignature() { - final sig = ref.read(signatureProvider.notifier); - if (ref.read(pdfProvider).loaded) { - sig.placeDefaultRect(); - ref - .read(pdfProvider.notifier) - .setSignedPage(ref.read(pdfProvider).currentPage); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of( - context, - ).longPressOrRightClickTheSignatureToConfirmOrDelete, - ), - duration: const Duration(seconds: 3), - ), - ); - } - } + // _createNewSignature was removed as the toolbar no longer exposes this action. void _confirmSignature() { ref.read(signatureProvider.notifier).confirmCurrentSignature(ref); @@ -250,7 +237,6 @@ class _PdfSignatureHomePageState extends ConsumerState { @override void dispose() { - _ivController.dispose(); super.dispose(); } @@ -259,64 +245,62 @@ class _PdfSignatureHomePageState extends ConsumerState { final isExporting = ref.watch(exportingProvider); final l = AppLocalizations.of(context); return Scaffold( - appBar: AppBar( - title: Text(l.appTitle), - actions: [ - IconButton( - key: const Key('btn_appbar_settings'), - tooltip: l.settings, - onPressed: - () => showDialog( - context: context, - builder: (_) => const SettingsDialog(), - ), - icon: const Icon(Icons.settings), - ), - ], - ), body: Padding( padding: const EdgeInsets.all(12), child: Stack( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + Column( children: [ - // Left: pages overview (thumbnails + navigation) - ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 140, - maxWidth: 180, - ), - child: Card( - margin: EdgeInsets.zero, - child: const PdfPagesOverview(), - ), + // Full-width toolbar row + PdfToolbar( + disabled: isExporting, + onPickPdf: _pickPdf, + onJumpToPage: _jumpToPage, + onZoomOut: () { + if (_viewerController.isReady) { + _viewerController.zoomDown(); + } + }, + onZoomIn: () { + if (_viewerController.isReady) { + _viewerController.zoomUp(); + } + }, + fileName: ref.watch(pdfProvider).pickedPdfPath, + showPagesSidebar: _showPagesSidebar, + showSignaturesSidebar: _showSignaturesSidebar, + onTogglePagesSidebar: + () => setState(() { + _showPagesSidebar = !_showPagesSidebar; + }), + onToggleSignaturesSidebar: + () => setState(() { + _showSignaturesSidebar = !_showSignaturesSidebar; + }), ), - const SizedBox(width: 12), + const SizedBox(height: 8), Expanded( - child: Column( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PdfToolbar( - disabled: isExporting, - onOpenSettings: - () => showDialog( - context: context, - builder: (_) => const SettingsDialog(), - ), - onPickPdf: _pickPdf, - onJumpToPage: _jumpToPage, - onSave: _saveSignedPdf, - onLoadSignatureFromFile: _loadSignatureFromFile, - onCreateSignature: _createNewSignature, - onOpenDrawCanvas: _openDrawCanvas, - ), - const SizedBox(height: 8), + if (_showPagesSidebar) + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 140, + maxWidth: 180, + ), + child: Card( + margin: EdgeInsets.zero, + child: const PdfPagesOverview(), + ), + ), + if (_showPagesSidebar) const SizedBox(width: 12), Expanded( child: AbsorbPointer( absorbing: isExporting, child: PdfPageArea( pageSize: _pageSize, - controller: _ivController, + viewerController: _viewerController, onDragSignature: _onDragSignature, onResizeSignature: _onResizeSignature, onConfirmSignature: _confirmSignature, @@ -329,108 +313,243 @@ class _PdfSignatureHomePageState extends ConsumerState { ), ), ), - ], - ), - ), - const SizedBox(width: 12), - ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 280, - maxWidth: 360, - ), - child: Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - if (sig.rect != null) { - return AbsorbPointer( - absorbing: isExporting, - child: Card( - margin: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Signature preview - Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).signature, - style: - Theme.of( - context, - ).textTheme.titleSmall, - ), - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: - Theme.of(context).dividerColor, + if (_showSignaturesSidebar) const SizedBox(width: 12), + if (_showSignaturesSidebar) + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 280, + maxWidth: 360, + ), + child: Consumer( + builder: (context, ref, _) { + final sig = ref.watch(signatureProvider); + final bytes = + ref.watch(processedSignatureImageProvider) ?? + sig.imageBytes; + return AbsorbPointer( + absorbing: isExporting, + child: Card( + margin: EdgeInsets.zero, + child: LayoutBuilder( + builder: (context, cons) { + return SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: cons.maxHeight, ), - borderRadius: BorderRadius.circular( - 8, - ), - ), - child: AspectRatio( - aspectRatio: 3 / 1, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Consumer( - builder: (context, ref, _) { - final bytes = - ref.watch( - processedSignatureImageProvider, - ) ?? - sig.imageBytes; - if (bytes == null) { - return Center( - child: Text( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all( + 12, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( AppLocalizations.of( context, - ).noPdfLoaded, + ).signature, + style: + Theme.of(context) + .textTheme + .titleSmall, ), - ); - } - return Image.memory( - bytes, - fit: BoxFit.contain, - ); - }, - ), + const SizedBox(height: 8), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: + Theme.of( + context, + ).dividerColor, + ), + borderRadius: + BorderRadius.circular( + 8, + ), + ), + child: AspectRatio( + aspectRatio: 3 / 1, + child: Padding( + padding: + const EdgeInsets.all( + 8.0, + ), + child: Builder( + builder: (context) { + final placeholder = Center( + child: Text( + AppLocalizations.of( + context, + ).noSignatureLoaded, + ), + ); + if (bytes == + null || + bytes + .isEmpty) { + return placeholder; + } + final img = + Image.memory( + bytes, + fit: + BoxFit + .contain, + ); + return Draggable< + Object + >( + data: + const SignatureDragData(), + feedback: Opacity( + opacity: 0.85, + child: ConstrainedBox( + constraints: + const BoxConstraints.tightFor( + width: + 160, + height: + 80, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: + Colors.white, + borderRadius: + BorderRadius.circular( + 6, + ), + boxShadow: const [ + BoxShadow( + blurRadius: + 8, + color: + Colors.black26, + ), + ], + ), + child: Padding( + padding: + const EdgeInsets.all( + 6.0, + ), + child: Image.memory( + bytes, + fit: + BoxFit.contain, + ), + ), + ), + ), + ), + childWhenDragging: + Opacity( + opacity: + 0.5, + child: + img, + ), + child: img, + ); + }, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 12, + ), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + key: const Key( + 'btn_load_signature_picker', + ), + onPressed: + !ref + .read( + pdfProvider, + ) + .loaded + ? null + : _loadSignatureFromFile, + icon: const Icon( + Icons.image_outlined, + ), + label: Text( + AppLocalizations.of( + context, + ).loadSignatureFromFile, + ), + ), + OutlinedButton.icon( + key: const Key( + 'btn_draw_signature', + ), + onPressed: + !ref + .read( + pdfProvider, + ) + .loaded + ? null + : _openDrawCanvas, + icon: const Icon( + Icons.gesture, + ), + label: Text( + AppLocalizations.of( + context, + ).drawSignature, + ), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all( + 12, + ), + child: AdjustmentsPanel( + sig: sig, + ), + ), + const Divider(height: 1), + ElevatedButton( + key: const Key('btn_save_pdf'), + onPressed: + isExporting + ? null + : _saveSignedPdf, + child: Text(l.saveSignedPdf), + ), + ], ), ), - ), - ], + ); + }, ), ), - const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: AdjustmentsPanel(sig: sig), - ), - ), - ], - ), - ), - ); - } - return Card( - margin: EdgeInsets.zero, - child: Center( - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - AppLocalizations.of(context).signature, - style: Theme.of(context).textTheme.bodyMedium, - ), + ); + }, ), ), - ); - }, + ], ), ), ], diff --git a/lib/ui/features/pdf/widgets/pdf_toolbar.dart b/lib/ui/features/pdf/widgets/pdf_toolbar.dart index c7620b9..634d41b 100644 --- a/lib/ui/features/pdf/widgets/pdf_toolbar.dart +++ b/lib/ui/features/pdf/widgets/pdf_toolbar.dart @@ -10,23 +10,27 @@ class PdfToolbar extends ConsumerStatefulWidget { const PdfToolbar({ super.key, required this.disabled, - required this.onOpenSettings, required this.onPickPdf, required this.onJumpToPage, - required this.onSave, - required this.onLoadSignatureFromFile, - required this.onCreateSignature, - required this.onOpenDrawCanvas, + required this.onZoomOut, + required this.onZoomIn, + this.fileName, + required this.showPagesSidebar, + required this.showSignaturesSidebar, + required this.onTogglePagesSidebar, + required this.onToggleSignaturesSidebar, }); final bool disabled; - final VoidCallback onOpenSettings; final VoidCallback onPickPdf; final ValueChanged onJumpToPage; - final VoidCallback onSave; - final VoidCallback onLoadSignatureFromFile; - final VoidCallback onCreateSignature; - final VoidCallback onOpenDrawCanvas; + final String? fileName; + final VoidCallback onZoomOut; + final VoidCallback onZoomIn; + final bool showPagesSidebar; + final bool showSignaturesSidebar; + final VoidCallback onTogglePagesSidebar; + final VoidCallback onToggleSignaturesSidebar; @override ConsumerState createState() => _PdfToolbarState(); @@ -57,21 +61,34 @@ class _PdfToolbarState extends ConsumerState { return LayoutBuilder( builder: (context, constraints) { final bool compact = constraints.maxWidth < 260; - final double gotoWidth = compact ? 60 : 100; - return Wrap( + final double gotoWidth = 50; + + // Center content of the toolbar + final center = Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - OutlinedButton( - key: const Key('btn_open_settings'), - onPressed: widget.disabled ? null : widget.onOpenSettings, - child: Text(l.settings), - ), OutlinedButton( key: const Key('btn_open_pdf_picker'), onPressed: widget.disabled ? null : widget.onPickPdf, - child: Text(l.openPdf), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.insert_drive_file, size: 18), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Text( + // if filename not null + widget.fileName != null + ? widget.fileName! + : 'No file selected', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ), if (pdf.loaded) ...[ Row( @@ -86,6 +103,7 @@ class _PdfToolbarState extends ConsumerState { icon: const Icon(Icons.chevron_left), tooltip: l.prev, ), + // Current page label Text(pageInfo, key: const Key('lbl_page_info')), IconButton( key: const Key('btn_next'), @@ -96,36 +114,51 @@ class _PdfToolbarState extends ConsumerState { icon: const Icon(Icons.chevron_right), tooltip: l.next, ), - ], - ), - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text(l.goTo), - SizedBox( - width: gotoWidth, - child: TextField( - key: const Key('txt_goto'), - controller: _goToController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - enabled: !widget.disabled, - decoration: InputDecoration( - isDense: true, - hintText: '1..${pdf.pageCount}', + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(l.goTo), + SizedBox( + width: gotoWidth, + child: TextField( + key: const Key('txt_goto'), + controller: _goToController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + enabled: !widget.disabled, + decoration: InputDecoration( + isDense: true, + hintText: '1..${pdf.pageCount}', + ), + onSubmitted: (_) => _submitGoTo(), + ), ), - onSubmitted: (_) => _submitGoTo(), - ), + if (!compact) + IconButton( + key: const Key('btn_goto_apply'), + tooltip: l.goTo, + icon: const Icon(Icons.arrow_forward), + onPressed: widget.disabled ? null : _submitGoTo, + ), + ], + ), + const SizedBox(width: 8), + IconButton( + key: const Key('btn_zoom_out'), + tooltip: 'Zoom out', + onPressed: widget.disabled ? null : widget.onZoomOut, + icon: const Icon(Icons.zoom_out), + ), + IconButton( + key: const Key('btn_zoom_in'), + tooltip: 'Zoom in', + onPressed: widget.disabled ? null : widget.onZoomIn, + icon: const Icon(Icons.zoom_in), ), - if (!compact) - IconButton( - key: const Key('btn_goto_apply'), - tooltip: l.goTo, - icon: const Icon(Icons.arrow_forward), - onPressed: widget.disabled ? null : _submitGoTo, - ), ], ), Row( @@ -156,38 +189,42 @@ class _PdfToolbarState extends ConsumerState { ), ], ), - ElevatedButton( - key: const Key('btn_save_pdf'), - onPressed: widget.disabled ? null : widget.onSave, - child: Text(l.saveSignedPdf), - ), - OutlinedButton( - key: const Key('btn_load_signature_picker'), - onPressed: - widget.disabled || !pdf.loaded - ? null - : widget.onLoadSignatureFromFile, - child: Text(l.loadSignatureFromFile), - ), - OutlinedButton( - key: const Key('btn_create_signature'), - onPressed: - widget.disabled || !pdf.loaded - ? null - : widget.onCreateSignature, - child: Text(l.createNewSignature), - ), - ElevatedButton( - key: const Key('btn_draw_signature'), - onPressed: - widget.disabled || !pdf.loaded - ? null - : widget.onOpenDrawCanvas, - child: Text(l.drawSignature), - ), ], ], ); + + return Row( + children: [ + IconButton( + key: const Key('btn_toggle_pages_sidebar'), + tooltip: 'Toggle pages overview', + onPressed: widget.disabled ? null : widget.onTogglePagesSidebar, + icon: Icon( + Icons.view_sidebar, + color: + widget.showPagesSidebar + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + const SizedBox(width: 8), + Expanded(child: center), + const SizedBox(width: 8), + IconButton( + key: const Key('btn_toggle_signatures_sidebar'), + tooltip: 'Toggle signatures drawer', + onPressed: + widget.disabled ? null : widget.onToggleSignaturesSidebar, + icon: Icon( + Icons.view_sidebar, + color: + widget.showSignaturesSidebar + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ], + ); }, ); } diff --git a/lib/ui/features/pdf/widgets/signature_drawer.dart b/lib/ui/features/pdf/widgets/signature_drawer.dart new file mode 100644 index 0000000..d4fbab8 --- /dev/null +++ b/lib/ui/features/pdf/widgets/signature_drawer.dart @@ -0,0 +1,206 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; + +import '../../../../data/services/providers.dart'; +import '../view_model/view_model.dart'; +import 'adjustments_panel.dart'; + +/// Data passed when dragging a signature card. +class SignatureDragData { + const SignatureDragData(); +} + +class SignatureDrawer extends ConsumerStatefulWidget { + const SignatureDrawer({ + super.key, + required this.disabled, + required this.onLoadSignatureFromFile, + required this.onOpenDrawCanvas, + }); + + final bool disabled; + final VoidCallback onLoadSignatureFromFile; + final VoidCallback onOpenDrawCanvas; + + @override + ConsumerState createState() => _SignatureDrawerState(); +} + +class _SignatureDrawerState extends ConsumerState { + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final sig = ref.watch(signatureProvider); + final processed = ref.watch(processedSignatureImageProvider); + final bytes = processed ?? sig.imageBytes; + final isExporting = ref.watch(exportingProvider); + final disabled = widget.disabled || isExporting; + + return Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Text( + l.signature, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + // Existing signature card (draggable when bytes available) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + height: 120, + child: + bytes == null + ? Center( + child: Text( + l.noPdfLoaded, + textAlign: TextAlign.center, + ), + ) + : _DraggableSignaturePreview( + bytes: bytes, + disabled: disabled, + ), + ), + ), + ), + // Actions under the card + if (bytes != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + PopupMenuButton( + key: const Key('popup_signature_card'), + tooltip: l.settings, + onSelected: (v) { + switch (v) { + case 'delete': + ref + .read(signatureProvider.notifier) + .clearActiveOverlay(); + ref.read(signatureProvider.notifier).clearImage(); + break; + default: + break; + } + }, + itemBuilder: + (ctx) => [ + PopupMenuItem( + key: const Key('mi_signature_delete'), + value: 'delete', + child: Text(l.delete), + ), + ], + child: IconButton( + icon: const Icon(Icons.more_horiz), + onPressed: disabled ? null : () {}, + ), + ), + const SizedBox(width: 4), + Text(AppLocalizations.of(context).createNewSignature), + ], + ), + ), + const SizedBox(height: 12), + const Divider(height: 1), + // New signature card + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l.createNewSignature, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + key: const Key('btn_drawer_load_signature'), + onPressed: + disabled ? null : widget.onLoadSignatureFromFile, + icon: const Icon(Icons.image_outlined), + label: Text(l.loadSignatureFromFile), + ), + OutlinedButton.icon( + key: const Key('btn_drawer_draw_signature'), + onPressed: disabled ? null : widget.onOpenDrawCanvas, + icon: const Icon(Icons.gesture), + label: Text(l.drawSignature), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: AdjustmentsPanel(sig: sig), + ), + ), + ], + ), + ); + } +} + +class _DraggableSignaturePreview extends StatelessWidget { + const _DraggableSignaturePreview({ + required this.bytes, + required this.disabled, + }); + final Uint8List bytes; + final bool disabled; + + @override + Widget build(BuildContext context) { + final child = Padding( + padding: const EdgeInsets.all(8.0), + child: Image.memory(bytes, fit: BoxFit.contain), + ); + if (disabled) return child; + return Draggable( + data: const SignatureDragData(), + feedback: Opacity( + opacity: 0.8, + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 160, height: 80), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + boxShadow: const [ + BoxShadow(blurRadius: 8, color: Colors.black26), + ], + ), + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Image.memory(bytes, fit: BoxFit.contain), + ), + ), + ), + ), + childWhenDragging: Opacity(opacity: 0.5, child: child), + child: child, + ); + } +} diff --git a/lib/ui/features/welcome/widgets/welcome_screen.dart b/lib/ui/features/welcome/widgets/welcome_screen.dart index 7e46ca2..42f535f 100644 --- a/lib/ui/features/welcome/widgets/welcome_screen.dart +++ b/lib/ui/features/welcome/widgets/welcome_screen.dart @@ -8,7 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/l10n/app_localizations.dart'; import '../../pdf/view_model/view_model.dart'; -import '../../preferences/widgets/settings_screen.dart'; +// Settings dialog is provided via global AppBar in MyApp // Abstraction to make drop handling testable without constructing // platform-specific DropItem types in widget tests. @@ -131,33 +131,19 @@ class _WelcomeScreenState extends ConsumerState { ), color: _dragging - ? Theme.of(context).colorScheme.primary.withOpacity(0.05) + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.05) : Colors.transparent, ), child: content, ), ); - return Scaffold( - appBar: AppBar( - title: Text(l.appTitle), - actions: [ - IconButton( - tooltip: l.settings, - onPressed: - () => showDialog( - context: context, - builder: (_) => const SettingsDialog(), - ), - icon: const Icon(Icons.settings), - ), - ], - ), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: dropZone, - ), + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: dropZone, ), ); } From cc8e20d310112cc1401bb1eea32d9b1e969a9d26 Mon Sep 17 00:00:00 2001 From: insleker Date: Tue, 2 Sep 2025 18:43:44 +0800 Subject: [PATCH 4/4] feat: feat partially implement signature card UI view --- .gitignore | 1 + docs/meta-arch.md | 3 +- integration_test/export_flow_test.dart | 61 ++++ lib/data/model/model.dart | 6 + .../features/pdf/view_model/view_model.dart | 12 + .../pdf/widgets/adjustments_panel.dart | 52 ++-- .../pdf/widgets/image_editor_dialog.dart | 97 +++++++ .../features/pdf/widgets/pdf_page_area.dart | 44 ++- lib/ui/features/pdf/widgets/pdf_screen.dart | 268 +++--------------- lib/ui/features/pdf/widgets/pdf_toolbar.dart | 11 + .../pdf/widgets/signature_drawer.dart | 131 +++++---- pubspec.yaml | 3 + test/widget/helpers.dart | 21 +- .../signature_card_context_menu_test.dart | 54 ++++ test/widget/signature_interaction_test.dart | 35 +++ 15 files changed, 484 insertions(+), 315 deletions(-) create mode 100644 integration_test/export_flow_test.dart create mode 100644 lib/ui/features/pdf/widgets/image_editor_dialog.dart create mode 100644 test/widget/signature_card_context_menu_test.dart diff --git a/.gitignore b/.gitignore index 7938982..cd39a77 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,4 @@ docs/wireframe.assets/*.excalidraw.svg docs/wireframe.assets/*.svg docs/wireframe.assets/*.png node_modules/ +.vscode/settings.json diff --git a/docs/meta-arch.md b/docs/meta-arch.md index c4db672..40fad8d 100644 --- a/docs/meta-arch.md +++ b/docs/meta-arch.md @@ -8,4 +8,5 @@ The repo structure follows official [Package structure](https://docs.flutter.dev * put each `/`s in `features/` sub-directory under `ui/`. * `test/features/` contains BDD unit tests for each feature. It focuses on pure logic, therefore will not access `View` but `ViewModel` and `Model`. -* `test/widget/` contains UI widget(component) tests which focus on `View` of MVVM only. +* `test/widget/` contains UI widget(component) tests which focus on `View` from MVVM of each component. +* `integration_test/` for integration tests. They should be volatile to follow UI layout changes. diff --git a/integration_test/export_flow_test.dart b/integration_test/export_flow_test.dart new file mode 100644 index 0000000..6e89bbf --- /dev/null +++ b/integration_test/export_flow_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:pdf_signature/data/services/export_service.dart'; +import 'package:pdf_signature/data/services/providers.dart'; +import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; +import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; + +class RecordingExporter extends ExportService { + bool called = false; + @override + Future saveBytesToFile({required bytes, required outputPath}) async { + called = true; + return true; + } +} + +class BasicExporter extends ExportService {} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Save uses file selector (via provider) and injected exporter', ( + tester, + ) async { + final fake = RecordingExporter(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pdfProvider.overrideWith( + (ref) => PdfController()..openPicked(path: 'test.pdf'), + ), + signatureProvider.overrideWith( + (ref) => SignatureController()..placeDefaultRect(), + ), + useMockViewerProvider.overrideWith((ref) => true), + exportServiceProvider.overrideWith((_) => fake), + savePathPickerProvider.overrideWith( + (_) => () async => 'C:/tmp/output.pdf', + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PdfSignatureHomePage(), + ), + ), + ); + await tester.pump(); + + // Trigger save directly + await tester.tap(find.byKey(const Key('btn_save_pdf'))); + await tester.pumpAndSettle(); + + // Expect success UI + expect(find.textContaining('Saved:'), findsOneWidget); + }); +} diff --git a/lib/data/model/model.dart b/lib/data/model/model.dart index 4faeeba..24e8a25 100644 --- a/lib/data/model/model.dart +++ b/lib/data/model/model.dart @@ -59,6 +59,8 @@ class SignatureState { final bool bgRemoval; final double contrast; final double brightness; + // Rotation in degrees applied to the signature image when rendering/exporting + final double rotation; final List> strokes; final Uint8List? imageBytes; // When true, the active signature overlay is movable/resizable and should not be exported. @@ -70,6 +72,7 @@ class SignatureState { required this.bgRemoval, required this.contrast, required this.brightness, + this.rotation = 0.0, required this.strokes, this.imageBytes, this.editingEnabled = false, @@ -80,6 +83,7 @@ class SignatureState { bgRemoval: false, contrast: 1.0, brightness: 0.0, + rotation: 0.0, strokes: [], imageBytes: null, editingEnabled: false, @@ -90,6 +94,7 @@ class SignatureState { bool? bgRemoval, double? contrast, double? brightness, + double? rotation, List>? strokes, Uint8List? imageBytes, bool? editingEnabled, @@ -99,6 +104,7 @@ class SignatureState { bgRemoval: bgRemoval ?? this.bgRemoval, contrast: contrast ?? this.contrast, brightness: brightness ?? this.brightness, + rotation: rotation ?? this.rotation, strokes: strokes ?? this.strokes, imageBytes: imageBytes ?? this.imageBytes, editingEnabled: editingEnabled ?? this.editingEnabled, diff --git a/lib/ui/features/pdf/view_model/view_model.dart b/lib/ui/features/pdf/view_model/view_model.dart index 225d1df..f9b6167 100644 --- a/lib/ui/features/pdf/view_model/view_model.dart +++ b/lib/ui/features/pdf/view_model/view_model.dart @@ -226,6 +226,7 @@ class SignatureController extends StateNotifier { void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v); void setContrast(double v) => state = state.copyWith(contrast: v); void setBrightness(double v) => state = state.copyWith(brightness: v); + void setRotation(double deg) => state = state.copyWith(rotation: deg); void setStrokes(List> strokes) => state = state.copyWith(strokes: strokes); @@ -308,6 +309,7 @@ final processedSignatureImageProvider = Provider((ref) { // Parameters final double contrast = s.contrast; // [0..2], 1 = neutral final double brightness = s.brightness; // [-1..1], 0 = neutral + final double rotationDeg = s.rotation; // degrees const int thrLow = 220; // begin soft transparency from this avg luminance const int thrHigh = 245; // fully transparent from this avg luminance @@ -352,6 +354,16 @@ final processedSignatureImageProvider = Provider((ref) { } } + // Apply rotation if any (around center) using bilinear interpolation and keep size + if (rotationDeg % 360 != 0) { + // The image package rotates counter-clockwise; positive degrees rotate CCW + out = img.copyRotate( + out, + angle: rotationDeg, + interpolation: img.Interpolation.linear, + ); + } + // Encode as PNG to preserve transparency final png = img.encodePng(out, level: 6); return Uint8List.fromList(png); diff --git a/lib/ui/features/pdf/widgets/adjustments_panel.dart b/lib/ui/features/pdf/widgets/adjustments_panel.dart index bc98bf7..19f6745 100644 --- a/lib/ui/features/pdf/widgets/adjustments_panel.dart +++ b/lib/ui/features/pdf/widgets/adjustments_panel.dart @@ -39,37 +39,43 @@ class AdjustmentsPanel extends ConsumerWidget { Text(AppLocalizations.of(context).backgroundRemoval), ], ), - Row( + const SizedBox(height: 8), + // Contrast control + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(AppLocalizations.of(context).contrast), - Expanded( - child: Slider( - key: const Key('sld_contrast'), - min: 0.0, - max: 2.0, - value: sig.contrast, - onChanged: - (v) => ref.read(signatureProvider.notifier).setContrast(v), - ), + Align( + alignment: Alignment.centerRight, + child: Text(sig.contrast.toStringAsFixed(2)), + ), + Slider( + key: const Key('sld_contrast'), + min: 0.0, + max: 2.0, + value: sig.contrast, + onChanged: + (v) => ref.read(signatureProvider.notifier).setContrast(v), ), - Text(sig.contrast.toStringAsFixed(2)), ], ), - Row( + // Brightness control + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(AppLocalizations.of(context).brightness), - Expanded( - child: Slider( - key: const Key('sld_brightness'), - min: -1.0, - max: 1.0, - value: sig.brightness, - onChanged: - (v) => - ref.read(signatureProvider.notifier).setBrightness(v), - ), + Align( + alignment: Alignment.centerRight, + child: Text(sig.brightness.toStringAsFixed(2)), + ), + Slider( + key: const Key('sld_brightness'), + min: -1.0, + max: 1.0, + value: sig.brightness, + onChanged: + (v) => ref.read(signatureProvider.notifier).setBrightness(v), ), - Text(sig.brightness.toStringAsFixed(2)), ], ), ], diff --git a/lib/ui/features/pdf/widgets/image_editor_dialog.dart b/lib/ui/features/pdf/widgets/image_editor_dialog.dart new file mode 100644 index 0000000..cd1f856 --- /dev/null +++ b/lib/ui/features/pdf/widgets/image_editor_dialog.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pdf_signature/l10n/app_localizations.dart'; + +import '../view_model/view_model.dart'; +import 'adjustments_panel.dart'; + +class ImageEditorDialog extends ConsumerWidget { + const ImageEditorDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = AppLocalizations.of(context); + final sig = ref.watch(signatureProvider); + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l.signature, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + // Preview + SizedBox( + height: 160, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Consumer( + builder: (context, ref, _) { + final processed = ref.watch( + processedSignatureImageProvider, + ); + final bytes = processed ?? sig.imageBytes; + if (bytes == null) { + return Text(l.noSignatureLoaded); + } + return Image.memory(bytes, fit: BoxFit.contain); + }, + ), + ), + ), + ), + const SizedBox(height: 12), + // Adjustments + AdjustmentsPanel(sig: sig), + const SizedBox(height: 8), + Row( + children: [ + Text('Rotate'), + Expanded( + child: Slider( + key: const Key('sld_rotation'), + min: -180, + max: 180, + divisions: 72, + value: sig.rotation, + onChanged: + (v) => ref + .read(signatureProvider.notifier) + .setRotation(v), + ), + ), + Text('${sig.rotation.toStringAsFixed(0)}°'), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key('btn_image_editor_close'), + onPressed: () => Navigator.of(context).pop(), + child: Text( + MaterialLocalizations.of(context).closeButtonLabel, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/features/pdf/widgets/pdf_page_area.dart b/lib/ui/features/pdf/widgets/pdf_page_area.dart index f9ae429..9b66686 100644 --- a/lib/ui/features/pdf/widgets/pdf_page_area.dart +++ b/lib/ui/features/pdf/widgets/pdf_page_area.dart @@ -10,6 +10,7 @@ import '../../../../data/model/model.dart'; import '../view_model/view_model.dart'; import '../../preferences/providers.dart'; import 'signature_drawer.dart'; +import 'image_editor_dialog.dart'; class PdfPageArea extends ConsumerStatefulWidget { const PdfPageArea({ @@ -436,6 +437,11 @@ class _PdfPageAreaState extends ConsumerState { value: 'delete', child: Text(l.delete), ), + const PopupMenuItem( + key: Key('ctx_placed_adjust'), + value: 'adjust', + child: Text('Adjust graphic'), + ), ], ).then((choice) { switch (choice) { @@ -444,6 +450,12 @@ class _PdfPageAreaState extends ConsumerState { .read(pdfProvider.notifier) .removePlacement(page: page, index: index); break; + case 'adjust': + showDialog( + context: context, + builder: (ctx) => const ImageEditorDialog(), + ); + break; default: break; } @@ -557,7 +569,17 @@ class _PdfPageAreaState extends ConsumerState { ), ); } - return Image.memory(bytes, fit: BoxFit.contain); + Widget im = Image.memory( + bytes, + fit: BoxFit.contain, + ); + if (sig.rotation % 360 != 0) { + im = Transform.rotate( + angle: sig.rotation * math.pi / 180.0, + child: im, + ); + } + return im; }, ), if (interactive) @@ -610,12 +632,22 @@ class _PdfPageAreaState extends ConsumerState { value: 'delete', child: Text(AppLocalizations.of(context).delete), ), + const PopupMenuItem( + key: Key('ctx_active_adjust'), + value: 'adjust', + child: Text('Adjust graphic'), + ), ], ).then((choice) { if (choice == 'confirm') { widget.onConfirmSignature(); } else if (choice == 'delete') { widget.onClearActiveOverlay(); + } else if (choice == 'adjust') { + showDialog( + context: context, + builder: (_) => const ImageEditorDialog(), + ); } }); }, @@ -640,12 +672,22 @@ class _PdfPageAreaState extends ConsumerState { value: 'delete', child: Text(AppLocalizations.of(context).delete), ), + const PopupMenuItem( + key: Key('ctx_active_adjust_lp'), + value: 'adjust', + child: Text('Adjust graphic'), + ), ], ).then((choice) { if (choice == 'confirm') { widget.onConfirmSignature(); } else if (choice == 'delete') { widget.onClearActiveOverlay(); + } else if (choice == 'adjust') { + showDialog( + context: context, + builder: (_) => const ImageEditorDialog(), + ); } }); }, diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index c6dc7d1..94ca433 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -14,7 +14,7 @@ import 'pdf_toolbar.dart'; import 'pdf_page_area.dart'; import 'pdf_pages_overview.dart'; import 'signature_drawer.dart'; -import 'adjustments_panel.dart'; +// adjustments are available via ImageEditorDialog class PdfSignatureHomePage extends ConsumerStatefulWidget { const PdfSignatureHomePage({super.key}); @@ -29,6 +29,7 @@ class _PdfSignatureHomePageState extends ConsumerState { final PdfViewerController _viewerController = PdfViewerController(); bool _showPagesSidebar = true; bool _showSignaturesSidebar = true; + int _zoomLevel = 100; // percentage for display only // Exposed for tests to trigger the invalid-file SnackBar without UI. @visibleForTesting @@ -260,12 +261,19 @@ class _PdfSignatureHomePageState extends ConsumerState { if (_viewerController.isReady) { _viewerController.zoomDown(); } + setState(() { + _zoomLevel = (_zoomLevel - 10).clamp(10, 800); + }); }, onZoomIn: () { if (_viewerController.isReady) { _viewerController.zoomUp(); } + setState(() { + _zoomLevel = (_zoomLevel + 10).clamp(10, 800); + }); }, + // zoomLevel omitted to avoid compact overflows in tight tests fileName: ref.watch(pdfProvider).pickedPdfPath, showPagesSidebar: _showPagesSidebar, showSignaturesSidebar: _showSignaturesSidebar, @@ -317,236 +325,38 @@ class _PdfSignatureHomePageState extends ConsumerState { if (_showSignaturesSidebar) ConstrainedBox( constraints: const BoxConstraints( - minWidth: 280, - maxWidth: 360, + minWidth: 140, + maxWidth: 250, ), - child: Consumer( - builder: (context, ref, _) { - final sig = ref.watch(signatureProvider); - final bytes = - ref.watch(processedSignatureImageProvider) ?? - sig.imageBytes; - return AbsorbPointer( - absorbing: isExporting, - child: Card( - margin: EdgeInsets.zero, - child: LayoutBuilder( - builder: (context, cons) { - return SingleChildScrollView( - padding: EdgeInsets.zero, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: cons.maxHeight, - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all( - 12, - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of( - context, - ).signature, - style: - Theme.of(context) - .textTheme - .titleSmall, - ), - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: - Theme.of( - context, - ).dividerColor, - ), - borderRadius: - BorderRadius.circular( - 8, - ), - ), - child: AspectRatio( - aspectRatio: 3 / 1, - child: Padding( - padding: - const EdgeInsets.all( - 8.0, - ), - child: Builder( - builder: (context) { - final placeholder = Center( - child: Text( - AppLocalizations.of( - context, - ).noSignatureLoaded, - ), - ); - if (bytes == - null || - bytes - .isEmpty) { - return placeholder; - } - final img = - Image.memory( - bytes, - fit: - BoxFit - .contain, - ); - return Draggable< - Object - >( - data: - const SignatureDragData(), - feedback: Opacity( - opacity: 0.85, - child: ConstrainedBox( - constraints: - const BoxConstraints.tightFor( - width: - 160, - height: - 80, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: - Colors.white, - borderRadius: - BorderRadius.circular( - 6, - ), - boxShadow: const [ - BoxShadow( - blurRadius: - 8, - color: - Colors.black26, - ), - ], - ), - child: Padding( - padding: - const EdgeInsets.all( - 6.0, - ), - child: Image.memory( - bytes, - fit: - BoxFit.contain, - ), - ), - ), - ), - ), - childWhenDragging: - Opacity( - opacity: - 0.5, - child: - img, - ), - child: img, - ); - }, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 4), - Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - ), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - key: const Key( - 'btn_load_signature_picker', - ), - onPressed: - !ref - .read( - pdfProvider, - ) - .loaded - ? null - : _loadSignatureFromFile, - icon: const Icon( - Icons.image_outlined, - ), - label: Text( - AppLocalizations.of( - context, - ).loadSignatureFromFile, - ), - ), - OutlinedButton.icon( - key: const Key( - 'btn_draw_signature', - ), - onPressed: - !ref - .read( - pdfProvider, - ) - .loaded - ? null - : _openDrawCanvas, - icon: const Icon( - Icons.gesture, - ), - label: Text( - AppLocalizations.of( - context, - ).drawSignature, - ), - ), - ], - ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.all( - 12, - ), - child: AdjustmentsPanel( - sig: sig, - ), - ), - const Divider(height: 1), - ElevatedButton( - key: const Key('btn_save_pdf'), - onPressed: - isExporting - ? null - : _saveSignedPdf, - child: Text(l.saveSignedPdf), - ), - ], - ), - ), - ); - }, + child: AbsorbPointer( + absorbing: isExporting, + child: Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SingleChildScrollView( + child: SignatureDrawer( + disabled: isExporting, + onLoadSignatureFromFile: + _loadSignatureFromFile, + onOpenDrawCanvas: _openDrawCanvas, + ), + ), ), - ), - ); - }, + Padding( + padding: const EdgeInsets.all(12), + child: ElevatedButton( + key: const Key('btn_save_pdf'), + onPressed: + isExporting ? null : _saveSignedPdf, + child: Text(l.saveSignedPdf), + ), + ), + ], + ), + ), ), ), ], diff --git a/lib/ui/features/pdf/widgets/pdf_toolbar.dart b/lib/ui/features/pdf/widgets/pdf_toolbar.dart index 634d41b..bb450bd 100644 --- a/lib/ui/features/pdf/widgets/pdf_toolbar.dart +++ b/lib/ui/features/pdf/widgets/pdf_toolbar.dart @@ -14,6 +14,7 @@ class PdfToolbar extends ConsumerStatefulWidget { required this.onJumpToPage, required this.onZoomOut, required this.onZoomIn, + this.zoomLevel, this.fileName, required this.showPagesSidebar, required this.showSignaturesSidebar, @@ -27,6 +28,8 @@ class PdfToolbar extends ConsumerStatefulWidget { final String? fileName; final VoidCallback onZoomOut; final VoidCallback onZoomIn; + // Current zoom level as a percentage (e.g., 100 for 100%) + final int? zoomLevel; final bool showPagesSidebar; final bool showSignaturesSidebar; final VoidCallback onTogglePagesSidebar; @@ -159,6 +162,14 @@ class _PdfToolbarState extends ConsumerState { onPressed: widget.disabled ? null : widget.onZoomIn, icon: const Icon(Icons.zoom_in), ), + if (!compact && widget.zoomLevel != null) ...[ + const SizedBox(width: 6), + // show zoom ratio + Text( + '${widget.zoomLevel}%', + style: const TextStyle(fontSize: 12), + ), + ], ], ), Row( diff --git a/lib/ui/features/pdf/widgets/signature_drawer.dart b/lib/ui/features/pdf/widgets/signature_drawer.dart index d4fbab8..b16c1e1 100644 --- a/lib/ui/features/pdf/widgets/signature_drawer.dart +++ b/lib/ui/features/pdf/widgets/signature_drawer.dart @@ -5,7 +5,7 @@ import 'package:pdf_signature/l10n/app_localizations.dart'; import '../../../../data/services/providers.dart'; import '../view_model/view_model.dart'; -import 'adjustments_panel.dart'; +import 'image_editor_dialog.dart'; /// Data passed when dragging a signature card. class SignatureDragData { @@ -29,6 +29,48 @@ class SignatureDrawer extends ConsumerStatefulWidget { } class _SignatureDrawerState extends ConsumerState { + Future _openSignatureMenuAt(Offset globalPosition) async { + final l = AppLocalizations.of(context); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + globalPosition.dx, + globalPosition.dy, + globalPosition.dx, + globalPosition.dy, + ), + items: [ + PopupMenuItem( + key: const Key('mi_signature_delete'), + value: 'delete', + child: Text(l.delete), + ), + PopupMenuItem( + key: const Key('mi_signature_adjust'), + value: 'adjust', + child: const Text('Adjust graphic'), + ), + ], + ); + + switch (selected) { + case 'delete': + ref.read(signatureProvider.notifier).clearActiveOverlay(); + ref.read(signatureProvider.notifier).clearImage(); + break; + case 'adjust': + if (!mounted) return; + // Open ImageEditorDialog + await showDialog( + context: context, + builder: (_) => const ImageEditorDialog(), + ); + break; + default: + break; + } + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); @@ -59,62 +101,37 @@ class _SignatureDrawerState extends ConsumerState { border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(8), ), - child: SizedBox( - height: 120, - child: - bytes == null - ? Center( - child: Text( - l.noPdfLoaded, - textAlign: TextAlign.center, + child: GestureDetector( + key: const Key('gd_signature_card_area'), + behavior: HitTestBehavior.opaque, + onSecondaryTapDown: (details) { + if (bytes != null && !disabled) { + _openSignatureMenuAt(details.globalPosition); + } + }, + onLongPressStart: (details) { + if (bytes != null && !disabled) { + _openSignatureMenuAt(details.globalPosition); + } + }, + child: SizedBox( + height: 120, + child: + bytes == null + ? Center( + child: Text( + l.noPdfLoaded, + textAlign: TextAlign.center, + ), + ) + : _DraggableSignaturePreview( + bytes: bytes, + disabled: disabled, ), - ) - : _DraggableSignaturePreview( - bytes: bytes, - disabled: disabled, - ), + ), ), ), ), - // Actions under the card - if (bytes != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - PopupMenuButton( - key: const Key('popup_signature_card'), - tooltip: l.settings, - onSelected: (v) { - switch (v) { - case 'delete': - ref - .read(signatureProvider.notifier) - .clearActiveOverlay(); - ref.read(signatureProvider.notifier).clearImage(); - break; - default: - break; - } - }, - itemBuilder: - (ctx) => [ - PopupMenuItem( - key: const Key('mi_signature_delete'), - value: 'delete', - child: Text(l.delete), - ), - ], - child: IconButton( - icon: const Icon(Icons.more_horiz), - onPressed: disabled ? null : () {}, - ), - ), - const SizedBox(width: 4), - Text(AppLocalizations.of(context).createNewSignature), - ], - ), - ), const SizedBox(height: 12), const Divider(height: 1), // New signature card @@ -150,13 +167,7 @@ class _SignatureDrawerState extends ConsumerState { ], ), ), - const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: AdjustmentsPanel(sig: sig), - ), - ), + // Adjustments are accessed via "Adjust graphic" in the popup menu ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index b67aca7..e1aac30 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,10 +51,13 @@ dependencies: intl: any flutter_localized_locales: ^2.0.5 desktop_drop: ^0.5.0 + multi_split_view: ^3.6.1 dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter build_runner: ^2.4.12 build: ^3.0.2 bdd_widget_test: ^2.0.1 diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 8a001f7..cdffd07 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'dart:typed_data'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; @@ -30,6 +32,20 @@ Future pumpWithOpenPdf(WidgetTester tester) async { } Future pumpWithOpenPdfAndSig(WidgetTester tester) async { + // Create a tiny sample signature image (PNG) for deterministic tests + final canvas = img.Image(width: 60, height: 30); + // White background + img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255)); + // Black rectangle line as a "signature" + img.drawLine( + canvas, + x1: 5, + y1: 15, + x2: 55, + y2: 15, + color: img.ColorUint8.rgb(0, 0, 0), + ); + final sigBytes = Uint8List.fromList(img.encodePng(canvas)); await tester.pumpWidget( ProviderScope( overrides: [ @@ -37,7 +53,10 @@ Future pumpWithOpenPdfAndSig(WidgetTester tester) async { (ref) => PdfController()..openPicked(path: 'test.pdf'), ), signatureProvider.overrideWith( - (ref) => SignatureController()..placeDefaultRect(), + (ref) => + SignatureController() + ..setImageBytes(sigBytes) + ..placeDefaultRect(), ), useMockViewerProvider.overrideWith((ref) => true), pageViewModeProvider.overrideWithValue('continuous'), diff --git a/test/widget/signature_card_context_menu_test.dart b/test/widget/signature_card_context_menu_test.dart new file mode 100644 index 0000000..ce7a7db --- /dev/null +++ b/test/widget/signature_card_context_menu_test.dart @@ -0,0 +1,54 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart' show kSecondaryMouseButton; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers.dart'; + +void main() { + testWidgets( + 'Signature card shows context menu on right-click with Adjust graphic', + (tester) async { + // Open app with a loaded PDF and signature prepared via helper + await pumpWithOpenPdfAndSig(tester); + await tester.pumpAndSettle(); + + // Ensure the signature card area is present + Finder cardArea = find.byKey(const Key('gd_signature_card_area')); + if (cardArea.evaluate().isEmpty) { + // Try to scroll the signatures sidebar to bring it into view + final signaturesPanelScroll = find.descendant( + of: find.byType(Card).last, + matching: find.byType(Scrollable), + ); + if (signaturesPanelScroll.evaluate().isNotEmpty) { + await tester.drag(signaturesPanelScroll, const Offset(0, -200)); + await tester.pumpAndSettle(); + } + cardArea = find.byKey(const Key('gd_signature_card_area')); + } + expect(cardArea, findsOneWidget); + + // Simulate a right-click at the center of the card area + final center = tester.getCenter(cardArea); + final TestGesture mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await mouse.addPointer(location: center); + addTearDown(mouse.removePointer); + await tester.pump(); + + await mouse.down(center); + await tester.pump(const Duration(milliseconds: 50)); + await mouse.up(); + await tester.pumpAndSettle(); + + // Verify the context menu shows "Adjust graphic" + expect(find.byKey(const Key('mi_signature_adjust')), findsOneWidget); + expect(find.text('Adjust graphic'), findsOneWidget); + + // Do not proceed to open the dialog here; the goal is just to verify menu content. + }, + ); +} diff --git a/test/widget/signature_interaction_test.dart b/test/widget/signature_interaction_test.dart index b365084..a721832 100644 --- a/test/widget/signature_interaction_test.dart +++ b/test/widget/signature_interaction_test.dart @@ -1,9 +1,31 @@ +import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart' show kSecondaryMouseButton; import 'package:flutter_test/flutter_test.dart'; import 'helpers.dart'; void main() { + Future openEditorViaContextMenu(WidgetTester tester) async { + // Prefer right-click on the signature card area to open the context menu + final cardArea = find.byKey(const Key('gd_signature_card_area')); + expect(cardArea, findsOneWidget); + final center = tester.getCenter(cardArea); + final TestGesture mouse = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await mouse.addPointer(location: center); + addTearDown(mouse.removePointer); + await tester.pump(); + await mouse.down(center); + await tester.pump(const Duration(milliseconds: 50)); + await mouse.up(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mi_signature_adjust'))); + await tester.pumpAndSettle(); + } + testWidgets('Resize and move signature within page bounds', (tester) async { await pumpWithOpenPdfAndSig(tester); @@ -35,6 +57,8 @@ void main() { final overlay = find.byKey(const Key('signature_overlay')); final sizeBefore = tester.getSize(overlay); final aspect = sizeBefore.width / sizeBefore.height; + // Open image editor via right-click context menu and toggle aspect lock there + await openEditorViaContextMenu(tester); await tester.tap(find.byKey(const Key('chk_aspect_lock'))); await tester.pump(); await tester.drag( @@ -52,6 +76,17 @@ void main() { ) async { await pumpWithOpenPdfAndSig(tester); + // Open image editor via right-click context menu + await openEditorViaContextMenu(tester); + // Ensure sliders are visible by scrolling if needed + final dialogScrollable = find.descendant( + of: find.byType(Dialog), + matching: find.byType(Scrollable), + ); + if (dialogScrollable.evaluate().isNotEmpty) { + await tester.drag(dialogScrollable, const Offset(0, -120)); + await tester.pumpAndSettle(); + } // toggle bg removal await tester.tap(find.byKey(const Key('swt_bg_removal'))); await tester.pump();