Compare commits

..

No commits in common. "cc8e20d310112cc1401bb1eea32d9b1e969a9d26" and "df1bf2755390fd3856c57f8b2de350f7a1fd9d3e" have entirely different histories.

32 changed files with 1351 additions and 1998 deletions

1
.gitignore vendored
View File

@ -129,4 +129,3 @@ docs/wireframe.assets/*.excalidraw.svg
docs/wireframe.assets/*.svg docs/wireframe.assets/*.svg
docs/wireframe.assets/*.png docs/wireframe.assets/*.png
node_modules/ node_modules/
.vscode/settings.json

View File

@ -6,8 +6,7 @@
"dart-code.flutter", "dart-code.flutter",
"lsaudon.l10nization", // quick translation gen "lsaudon.l10nization", // quick translation gen
"oke331.flutter-l10n-helper", // show arb string "oke331.flutter-l10n-helper", // show arb string
"gabbygreat.flutter-l10n-checker", "gabbygreat.flutter-l10n-checker", // detect hard-coded strings
"pomdtr.excalidraw-editor", // detect hard-coded strings
// "joaopinacio.translate-me" // "joaopinacio.translate-me"
] ]
} }

View File

@ -8,5 +8,4 @@ The repo structure follows official [Package structure](https://docs.flutter.dev
* put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`. * put each `<FEATURE NAME>/`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/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` from MVVM of each component. * `test/widget/` contains UI widget(component) tests which focus on `View` of MVVM only.
* `integration_test/` for integration tests. They should be volatile to follow UI layout changes.

View File

@ -607,6 +607,436 @@
"originalText": "search", "originalText": "search",
"autoResize": true, "autoResize": true,
"lineHeight": 1.35 "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": { "appState": {

View File

@ -810,6 +810,165 @@
"originalText": "Configure", "originalText": "Configure",
"autoResize": true, "autoResize": true,
"lineHeight": 1.35 "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": { "appState": {

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ Route: root
Design notes: Design notes:
- Central drop zone with hint text: “Drag a PDF here or click to select”. - Central drop zone with hint text: “Drag a PDF here or click to select”.
- Minimal top bar with app name and a "Configure" button with a gear icon for settings. - Minimal top bar with app name and a gear icon for settings.
- Clean layout encouraging first action. - Clean layout encouraging first action.
Illustration: Illustration:
@ -29,7 +29,7 @@ Purpose: provide basic configuration before/after opening a PDF.
Route: root --> settings Route: root --> settings
Design notes: Design notes:
- Opened via "Configure" button in the top bar. - Opened via gear icon in the top bar.
- Modal with simple sections (e.g., General, Display). - Modal with simple sections (e.g., General, Display).
- Primary action to save, secondary to cancel. - Primary action to save, secondary to cancel.
@ -39,35 +39,13 @@ Illustration:
## PDF opened ## PDF opened
Purpose: view and navigate the PDF; for signature placement. Purpose: view and navigate the PDF; prepare for signature placement.
Route: root --> opened Route: root --> opened
Design notes: 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. - Main canvas shows the current page.
- On the far left of the toolbar there is a button that can turn the document pages overview sidebar on and off. - Navigation: previous/next page, zoom controls are placed in toolbar which is at top of main PDF canvas.
- On the far right of the toolbar there is a button that can turn the signature cards overview sidebar on and off. - Drag signature onto page.
- 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, 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.
- 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), 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: Illustration:

View File

@ -1,61 +0,0 @@
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<bool> 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);
});
}

View File

@ -6,7 +6,6 @@ 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/pdf/view_model/view_model.dart';
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart'; import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
import 'ui/features/preferences/providers.dart'; import 'ui/features/preferences/providers.dart';
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
@ -63,27 +62,7 @@ class MyApp extends StatelessWidget {
...AppLocalizations.localizationsDelegates, ...AppLocalizations.localizationsDelegates,
LocaleNamesLocalizationsDelegate(), LocaleNamesLocalizationsDelegate(),
], ],
home: Builder( home: const _RootHomeSwitcher(),
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<bool>(
context: ctx,
builder: (_) => const SettingsDialog(),
),
),
],
),
body: const _RootHomeSwitcher(),
),
),
); );
}, },
); );

View File

@ -59,8 +59,6 @@ class SignatureState {
final bool bgRemoval; final bool bgRemoval;
final double contrast; final double contrast;
final double brightness; final double brightness;
// Rotation in degrees applied to the signature image when rendering/exporting
final double rotation;
final List<List<Offset>> strokes; final List<List<Offset>> strokes;
final Uint8List? imageBytes; final Uint8List? imageBytes;
// When true, the active signature overlay is movable/resizable and should not be exported. // When true, the active signature overlay is movable/resizable and should not be exported.
@ -72,7 +70,6 @@ class SignatureState {
required this.bgRemoval, required this.bgRemoval,
required this.contrast, required this.contrast,
required this.brightness, required this.brightness,
this.rotation = 0.0,
required this.strokes, required this.strokes,
this.imageBytes, this.imageBytes,
this.editingEnabled = false, this.editingEnabled = false,
@ -83,7 +80,6 @@ class SignatureState {
bgRemoval: false, bgRemoval: false,
contrast: 1.0, contrast: 1.0,
brightness: 0.0, brightness: 0.0,
rotation: 0.0,
strokes: [], strokes: [],
imageBytes: null, imageBytes: null,
editingEnabled: false, editingEnabled: false,
@ -94,7 +90,6 @@ class SignatureState {
bool? bgRemoval, bool? bgRemoval,
double? contrast, double? contrast,
double? brightness, double? brightness,
double? rotation,
List<List<Offset>>? strokes, List<List<Offset>>? strokes,
Uint8List? imageBytes, Uint8List? imageBytes,
bool? editingEnabled, bool? editingEnabled,
@ -104,7 +99,6 @@ class SignatureState {
bgRemoval: bgRemoval ?? this.bgRemoval, bgRemoval: bgRemoval ?? this.bgRemoval,
contrast: contrast ?? this.contrast, contrast: contrast ?? this.contrast,
brightness: brightness ?? this.brightness, brightness: brightness ?? this.brightness,
rotation: rotation ?? this.rotation,
strokes: strokes ?? this.strokes, strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes, imageBytes: imageBytes ?? this.imageBytes,
editingEnabled: editingEnabled ?? this.editingEnabled, editingEnabled: editingEnabled ?? this.editingEnabled,

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Drücken Sie lange oder klicken Sie mit der rechten Maustaste auf die Signatur, um sie zu bestätigen oder zu löschen.", "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", "next": "Weiter",
"noPdfLoaded": "Keine PDF-Datei geladen", "noPdfLoaded": "Keine PDF-Datei geladen",
"noSignatureLoaded": "Keine Signatur geladen",
"nothingToSaveYet": "Noch nichts zu speichern", "nothingToSaveYet": "Noch nichts zu speichern",
"openPdf": "PDF öffnen...", "openPdf": "PDF öffnen...",
"pageInfo": "Seite {current}/{total}", "pageInfo": "Seite {current}/{total}",

View File

@ -61,8 +61,6 @@
"@next": {}, "@next": {},
"noPdfLoaded": "No PDF loaded", "noPdfLoaded": "No PDF loaded",
"@noPdfLoaded": {}, "@noPdfLoaded": {},
"noSignatureLoaded": "No signature loaded",
"@noSignatureLoaded": {},
"nothingToSaveYet": "Nothing to save yet", "nothingToSaveYet": "Nothing to save yet",
"@nothingToSaveYet": {}, "@nothingToSaveYet": {},
"openPdf": "Open PDF...", "openPdf": "Open PDF...",

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
"next": "Siguiente", "next": "Siguiente",
"noPdfLoaded": "No se ha cargado ningún PDF", "noPdfLoaded": "No se ha cargado ningún PDF",
"noSignatureLoaded": "No se ha cargado ninguna firma",
"nothingToSaveYet": "Aún no hay nada que guardar", "nothingToSaveYet": "Aún no hay nada que guardar",
"openPdf": "Abrir PDF...", "openPdf": "Abrir PDF...",
"pageInfo": "Página {current}/{total}", "pageInfo": "Página {current}/{total}",

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Appuyez longuement ou cliquez droit sur la signature pour la confirmer ou la supprimer.",
"next": "Suivant", "next": "Suivant",
"noPdfLoaded": "Aucun PDF chargé", "noPdfLoaded": "Aucun PDF chargé",
"noSignatureLoaded": "Aucune signature chargée",
"nothingToSaveYet": "Rien à enregistrer pour le moment", "nothingToSaveYet": "Rien à enregistrer pour le moment",
"openPdf": "Ouvrir un PDF...", "openPdf": "Ouvrir un PDF...",
"pageInfo": "Page {current}/{total}", "pageInfo": "Page {current}/{total}",

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
"next": "次へ", "next": "次へ",
"noPdfLoaded": "PDFが読み込まれていません", "noPdfLoaded": "PDFが読み込まれていません",
"noSignatureLoaded": "署名は読み込まれていません",
"nothingToSaveYet": "まだ保存するものがありません", "nothingToSaveYet": "まだ保存するものがありません",
"openPdf": "PDFを開く…", "openPdf": "PDFを開く…",
"pageInfo": "ページ {current}/{total}", "pageInfo": "ページ {current}/{total}",

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
"next": "다음", "next": "다음",
"noPdfLoaded": "로드된 PDF 없음", "noPdfLoaded": "로드된 PDF 없음",
"noSignatureLoaded": "서명이 로드되지 않았습니다",
"nothingToSaveYet": "아직 저장할 내용이 없습니다.", "nothingToSaveYet": "아직 저장할 내용이 없습니다.",
"openPdf": "PDF 열기...", "openPdf": "PDF 열기...",
"pageInfo": "{current}/{total} 페이지", "pageInfo": "{current}/{total} 페이지",

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.", "longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
"next": "Далі", "next": "Далі",
"noPdfLoaded": "PDF не завантажено", "noPdfLoaded": "PDF не завантажено",
"noSignatureLoaded": "Не завантажено жодного підпису",
"nothingToSaveYet": "Ще нічого не потрібно зберігати", "nothingToSaveYet": "Ще нічого не потрібно зберігати",
"openPdf": "Відкрити PDF...", "openPdf": "Відкрити PDF...",
"pageInfo": "Сторінка {current}/{total}", "pageInfo": "Сторінка {current}/{total}",

View File

@ -27,7 +27,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁", "next": "下一頁",
"noPdfLoaded": "尚未載入 PDF", "noPdfLoaded": "尚未載入 PDF",
"noSignatureLoaded": "没有加载签名",
"nothingToSaveYet": "尚無可儲存的內容", "nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…", "openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁", "pageInfo": "第 {current}/{total} 頁",

View File

@ -26,7 +26,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
"next": "下一步", "next": "下一步",
"noPdfLoaded": "未加载 PDF", "noPdfLoaded": "未加载 PDF",
"noSignatureLoaded": "未加载签名",
"nothingToSaveYet": "尚无内容保存", "nothingToSaveYet": "尚无内容保存",
"openPdf": "打开 PDF...", "openPdf": "打开 PDF...",
"pageInfo": "第 {current} 页 / 共 {total} 页", "pageInfo": "第 {current} 页 / 共 {total} 页",

View File

@ -27,7 +27,6 @@
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。", "longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁", "next": "下一頁",
"noPdfLoaded": "尚未載入 PDF", "noPdfLoaded": "尚未載入 PDF",
"noSignatureLoaded": "未載入任何簽名",
"nothingToSaveYet": "尚無可儲存的內容", "nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…", "openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁", "pageInfo": "第 {current}/{total} 頁",

View File

@ -226,7 +226,6 @@ class SignatureController extends StateNotifier<SignatureState> {
void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v); void setBgRemoval(bool v) => state = state.copyWith(bgRemoval: v);
void setContrast(double v) => state = state.copyWith(contrast: v); void setContrast(double v) => state = state.copyWith(contrast: v);
void setBrightness(double v) => state = state.copyWith(brightness: v); void setBrightness(double v) => state = state.copyWith(brightness: v);
void setRotation(double deg) => state = state.copyWith(rotation: deg);
void setStrokes(List<List<Offset>> strokes) => void setStrokes(List<List<Offset>> strokes) =>
state = state.copyWith(strokes: strokes); state = state.copyWith(strokes: strokes);
@ -252,16 +251,6 @@ class SignatureController extends StateNotifier<SignatureState> {
state = state.copyWith(editingEnabled: true); 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. // 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. // Returns the Rect placed, or null if no rect to confirm.
Rect? confirmCurrentSignature(WidgetRef ref) { Rect? confirmCurrentSignature(WidgetRef ref) {
@ -309,7 +298,6 @@ final processedSignatureImageProvider = Provider<Uint8List?>((ref) {
// Parameters // Parameters
final double contrast = s.contrast; // [0..2], 1 = neutral final double contrast = s.contrast; // [0..2], 1 = neutral
final double brightness = s.brightness; // [-1..1], 0 = 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 thrLow = 220; // begin soft transparency from this avg luminance
const int thrHigh = 245; // fully transparent from this avg luminance const int thrHigh = 245; // fully transparent from this avg luminance
@ -354,16 +342,6 @@ final processedSignatureImageProvider = Provider<Uint8List?>((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 // Encode as PNG to preserve transparency
final png = img.encodePng(out, level: 6); final png = img.encodePng(out, level: 6);
return Uint8List.fromList(png); return Uint8List.fromList(png);

View File

@ -39,43 +39,37 @@ class AdjustmentsPanel extends ConsumerWidget {
Text(AppLocalizations.of(context).backgroundRemoval), Text(AppLocalizations.of(context).backgroundRemoval),
], ],
), ),
const SizedBox(height: 8), Row(
// Contrast control
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(AppLocalizations.of(context).contrast), Text(AppLocalizations.of(context).contrast),
Align( Expanded(
alignment: Alignment.centerRight, child: Slider(
child: Text(sig.contrast.toStringAsFixed(2)), key: const Key('sld_contrast'),
), min: 0.0,
Slider( max: 2.0,
key: const Key('sld_contrast'), value: sig.contrast,
min: 0.0, onChanged:
max: 2.0, (v) => ref.read(signatureProvider.notifier).setContrast(v),
value: sig.contrast, ),
onChanged:
(v) => ref.read(signatureProvider.notifier).setContrast(v),
), ),
Text(sig.contrast.toStringAsFixed(2)),
], ],
), ),
// Brightness control Row(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(AppLocalizations.of(context).brightness), Text(AppLocalizations.of(context).brightness),
Align( Expanded(
alignment: Alignment.centerRight, child: Slider(
child: Text(sig.brightness.toStringAsFixed(2)), key: const Key('sld_brightness'),
), min: -1.0,
Slider( max: 1.0,
key: const Key('sld_brightness'), value: sig.brightness,
min: -1.0, onChanged:
max: 1.0, (v) =>
value: sig.brightness, ref.read(signatureProvider.notifier).setBrightness(v),
onChanged: ),
(v) => ref.read(signatureProvider.notifier).setBrightness(v),
), ),
Text(sig.brightness.toStringAsFixed(2)),
], ],
), ),
], ],

View File

@ -1,97 +0,0 @@
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,
),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -9,23 +9,21 @@ import '../../../../data/services/providers.dart';
import '../../../../data/model/model.dart'; import '../../../../data/model/model.dart';
import '../view_model/view_model.dart'; import '../view_model/view_model.dart';
import '../../preferences/providers.dart'; import '../../preferences/providers.dart';
import 'signature_drawer.dart';
import 'image_editor_dialog.dart';
class PdfPageArea extends ConsumerStatefulWidget { class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({ const PdfPageArea({
super.key, super.key,
required this.pageSize, required this.pageSize,
this.controller,
required this.onDragSignature, required this.onDragSignature,
required this.onResizeSignature, required this.onResizeSignature,
required this.onConfirmSignature, required this.onConfirmSignature,
required this.onClearActiveOverlay, required this.onClearActiveOverlay,
required this.onSelectPlaced, required this.onSelectPlaced,
this.viewerController,
}); });
final Size pageSize; final Size pageSize;
final PdfViewerController? viewerController; final TransformationController? controller;
final ValueChanged<Offset> onDragSignature; final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature; final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature; final VoidCallback onConfirmSignature;
@ -37,8 +35,7 @@ class PdfPageArea extends ConsumerStatefulWidget {
class _PdfPageAreaState extends ConsumerState<PdfPageArea> { class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final Map<int, GlobalKey> _pageKeys = {}; final Map<int, GlobalKey> _pageKeys = {};
late final PdfViewerController _viewerController = final PdfViewerController _viewerController = PdfViewerController();
widget.viewerController ?? PdfViewerController();
// Guards to avoid scroll feedback between provider and viewer // Guards to avoid scroll feedback between provider and viewer
int? _programmaticTargetPage; int? _programmaticTargetPage;
bool _suppressProviderListen = false; bool _suppressProviderListen = false;
@ -61,8 +58,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}); });
} }
// No dispose required for PdfViewerController (managed by owner if any)
GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent( GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page, page,
() => GlobalKey(debugLabel: 'cont_page_$page'), () => GlobalKey(debugLabel: 'cont_page_$page'),
@ -221,7 +216,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
} }
}); });
} }
final content = SingleChildScrollView( return SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'), key: const Key('pdf_continuous_mock_list'),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Column( child: Column(
@ -275,7 +270,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}), }),
), ),
); );
return content;
}, },
); );
} }
@ -283,14 +277,14 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Real continuous mode (pdfrx): copy example patterns // Real continuous mode (pdfrx): copy example patterns
// https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart // https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart
if (pdf.pickedPdfPath != null && isContinuous) { if (pdf.pickedPdfPath != null && isContinuous) {
final viewer = PdfViewer.file( return PdfViewer.file(
pdf.pickedPdfPath!, pdf.pickedPdfPath!,
controller: _viewerController, controller: _viewerController,
params: PdfViewerParams( params: PdfViewerParams(
pageAnchor: PdfPageAnchor.top, pageAnchor: PdfPageAnchor.top,
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true), keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8, maxScale: 8,
scrollByMouseWheel: 0.6, // scrollByMouseWheel: 0.6,
// Add overlay scroll thumbs (vertical on right, horizontal on bottom) // Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder: viewerOverlayBuilder:
(context, size, handleLinkTap) => [ (context, size, handleLinkTap) => [
@ -300,7 +294,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
thumbSize: const Size(40, 24), thumbSize: const Size(40, 24),
thumbBuilder: thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container( (context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withOpacity(0.7),
child: Center( child: Center(
child: Text( child: Text(
pageNumber.toString(), pageNumber.toString(),
@ -315,7 +309,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
thumbSize: const Size(40, 24), thumbSize: const Size(40, 24),
thumbBuilder: thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container( (context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withOpacity(0.7),
child: Center( child: Center(
child: Text( child: Text(
pageNumber.toString(), pageNumber.toString(),
@ -381,34 +375,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}, },
), ),
); );
// Accept drops of signature card over the viewer
final drop = DragTarget<Object>(
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(); return const SizedBox.shrink();
@ -437,11 +403,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
value: 'delete', value: 'delete',
child: Text(l.delete), child: Text(l.delete),
), ),
const PopupMenuItem<String>(
key: Key('ctx_placed_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
], ],
).then((choice) { ).then((choice) {
switch (choice) { switch (choice) {
@ -450,12 +411,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
.read(pdfProvider.notifier) .read(pdfProvider.notifier)
.removePlacement(page: page, index: index); .removePlacement(page: page, index: index);
break; break;
case 'adjust':
showDialog(
context: context,
builder: (ctx) => const ImageEditorDialog(),
);
break;
default: default:
break; break;
} }
@ -569,17 +524,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
), ),
); );
} }
Widget im = Image.memory( return Image.memory(bytes, fit: BoxFit.contain);
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) if (interactive)
@ -632,22 +577,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
value: 'delete', value: 'delete',
child: Text(AppLocalizations.of(context).delete), child: Text(AppLocalizations.of(context).delete),
), ),
const PopupMenuItem<String>(
key: Key('ctx_active_adjust'),
value: 'adjust',
child: Text('Adjust graphic'),
),
], ],
).then((choice) { ).then((choice) {
if (choice == 'confirm') { if (choice == 'confirm') {
widget.onConfirmSignature(); widget.onConfirmSignature();
} else if (choice == 'delete') { } else if (choice == 'delete') {
widget.onClearActiveOverlay(); widget.onClearActiveOverlay();
} else if (choice == 'adjust') {
showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
} }
}); });
}, },
@ -672,22 +607,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
value: 'delete', value: 'delete',
child: Text(AppLocalizations.of(context).delete), child: Text(AppLocalizations.of(context).delete),
), ),
const PopupMenuItem<String>(
key: Key('ctx_active_adjust_lp'),
value: 'adjust',
child: Text('Adjust graphic'),
),
], ],
).then((choice) { ).then((choice) {
if (choice == 'confirm') { if (choice == 'confirm') {
widget.onConfirmSignature(); widget.onConfirmSignature();
} else if (choice == 'delete') { } else if (choice == 'delete') {
widget.onClearActiveOverlay(); widget.onClearActiveOverlay();
} else if (choice == 'adjust') {
showDialog(
context: context,
builder: (_) => const ImageEditorDialog(),
);
} }
}); });
}, },

View File

@ -5,16 +5,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:printing/printing.dart' as printing; import 'package:printing/printing.dart' as printing;
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart'; import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart'; import '../view_model/view_model.dart';
import 'draw_canvas.dart'; import 'draw_canvas.dart';
import 'pdf_toolbar.dart'; import 'pdf_toolbar.dart';
import 'pdf_page_area.dart'; import 'pdf_page_area.dart';
import 'adjustments_panel.dart';
import 'pdf_pages_overview.dart'; import 'pdf_pages_overview.dart';
import 'signature_drawer.dart'; import '../../preferences/widgets/settings_screen.dart';
// adjustments are available via ImageEditorDialog
class PdfSignatureHomePage extends ConsumerStatefulWidget { class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key}); const PdfSignatureHomePage({super.key});
@ -26,10 +25,7 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> { class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize; static const Size _pageSize = SignatureController.pageSize;
final PdfViewerController _viewerController = PdfViewerController(); final TransformationController _ivController = TransformationController();
bool _showPagesSidebar = true;
bool _showSignaturesSidebar = true;
int _zoomLevel = 100; // percentage for display only
// Exposed for tests to trigger the invalid-file SnackBar without UI. // Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting @visibleForTesting
@ -56,8 +52,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).jumpTo(page); ref.read(pdfProvider.notifier).jumpTo(page);
} }
// Zoom is managed by pdfrx viewer (Ctrl +/- etc.). No custom zoom here.
Future<void> _loadSignatureFromFile() async { Future<void> _loadSignatureFromFile() async {
final typeGroup = const fs.XTypeGroup( final typeGroup = const fs.XTypeGroup(
label: 'Image', label: 'Image',
@ -74,7 +68,25 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
} }
} }
// _createNewSignature was removed as the toolbar no longer exposes this action. 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),
),
);
}
}
void _confirmSignature() { void _confirmSignature() {
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref); ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
@ -238,6 +250,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
@override @override
void dispose() { void dispose() {
_ivController.dispose();
super.dispose(); super.dispose();
} }
@ -246,69 +259,64 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final isExporting = ref.watch(exportingProvider); final isExporting = ref.watch(exportingProvider);
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(
title: Text(l.appTitle),
actions: [
IconButton(
key: const Key('btn_appbar_settings'),
tooltip: l.settings,
onPressed:
() => showDialog<bool>(
context: context,
builder: (_) => const SettingsDialog(),
),
icon: const Icon(Icons.settings),
),
],
),
body: Padding( body: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Stack( child: Stack(
children: [ children: [
Column( Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Full-width toolbar row // Left: pages overview (thumbnails + navigation)
PdfToolbar( ConstrainedBox(
disabled: isExporting, constraints: const BoxConstraints(
onPickPdf: _pickPdf, minWidth: 140,
onJumpToPage: _jumpToPage, maxWidth: 180,
onZoomOut: () { ),
if (_viewerController.isReady) { child: Card(
_viewerController.zoomDown(); margin: EdgeInsets.zero,
} child: const PdfPagesOverview(),
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,
onTogglePagesSidebar:
() => setState(() {
_showPagesSidebar = !_showPagesSidebar;
}),
onToggleSignaturesSidebar:
() => setState(() {
_showSignaturesSidebar = !_showSignaturesSidebar;
}),
), ),
const SizedBox(height: 8), const SizedBox(width: 12),
Expanded( Expanded(
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_showPagesSidebar) PdfToolbar(
ConstrainedBox( disabled: isExporting,
constraints: const BoxConstraints( onOpenSettings:
minWidth: 140, () => showDialog<bool>(
maxWidth: 180, context: context,
), builder: (_) => const SettingsDialog(),
child: Card( ),
margin: EdgeInsets.zero, onPickPdf: _pickPdf,
child: const PdfPagesOverview(), onJumpToPage: _jumpToPage,
), onSave: _saveSignedPdf,
), onLoadSignatureFromFile: _loadSignatureFromFile,
if (_showPagesSidebar) const SizedBox(width: 12), onCreateSignature: _createNewSignature,
onOpenDrawCanvas: _openDrawCanvas,
),
const SizedBox(height: 8),
Expanded( Expanded(
child: AbsorbPointer( child: AbsorbPointer(
absorbing: isExporting, absorbing: isExporting,
child: PdfPageArea( child: PdfPageArea(
pageSize: _pageSize, pageSize: _pageSize,
viewerController: _viewerController, controller: _ivController,
onDragSignature: _onDragSignature, onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature, onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature, onConfirmSignature: _confirmSignature,
@ -321,45 +329,108 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
), ),
), ),
), ),
if (_showSignaturesSidebar) const SizedBox(width: 12), ],
if (_showSignaturesSidebar) ),
ConstrainedBox( ),
constraints: const BoxConstraints( const SizedBox(width: 12),
minWidth: 140, ConstrainedBox(
maxWidth: 250, constraints: const BoxConstraints(
), minWidth: 280,
child: AbsorbPointer( maxWidth: 360,
absorbing: isExporting, ),
child: Card( child: Consumer(
margin: EdgeInsets.zero, builder: (context, ref, _) {
child: Column( final sig = ref.watch(signatureProvider);
crossAxisAlignment: CrossAxisAlignment.stretch, if (sig.rect != null) {
children: [ return AbsorbPointer(
Expanded( absorbing: isExporting,
child: SingleChildScrollView( child: Card(
child: SignatureDrawer( margin: EdgeInsets.zero,
disabled: isExporting, child: Column(
onLoadSignatureFromFile: crossAxisAlignment: CrossAxisAlignment.stretch,
_loadSignatureFromFile, children: [
onOpenDrawCanvas: _openDrawCanvas, // 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,
),
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(
AppLocalizations.of(
context,
).noPdfLoaded,
),
);
}
return Image.memory(
bytes,
fit: BoxFit.contain,
);
},
),
),
),
),
],
), ),
Padding( ),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: ElevatedButton( child: AdjustmentsPanel(sig: sig),
key: const Key('btn_save_pdf'),
onPressed:
isExporting ? null : _saveSignedPdf,
child: Text(l.saveSignedPdf),
),
), ),
], ),
), ],
),
),
);
}
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,
), ),
), ),
), ),
], );
},
), ),
), ),
], ],

View File

@ -10,30 +10,23 @@ class PdfToolbar extends ConsumerStatefulWidget {
const PdfToolbar({ const PdfToolbar({
super.key, super.key,
required this.disabled, required this.disabled,
required this.onOpenSettings,
required this.onPickPdf, required this.onPickPdf,
required this.onJumpToPage, required this.onJumpToPage,
required this.onZoomOut, required this.onSave,
required this.onZoomIn, required this.onLoadSignatureFromFile,
this.zoomLevel, required this.onCreateSignature,
this.fileName, required this.onOpenDrawCanvas,
required this.showPagesSidebar,
required this.showSignaturesSidebar,
required this.onTogglePagesSidebar,
required this.onToggleSignaturesSidebar,
}); });
final bool disabled; final bool disabled;
final VoidCallback onOpenSettings;
final VoidCallback onPickPdf; final VoidCallback onPickPdf;
final ValueChanged<int> onJumpToPage; final ValueChanged<int> onJumpToPage;
final String? fileName; final VoidCallback onSave;
final VoidCallback onZoomOut; final VoidCallback onLoadSignatureFromFile;
final VoidCallback onZoomIn; final VoidCallback onCreateSignature;
// Current zoom level as a percentage (e.g., 100 for 100%) final VoidCallback onOpenDrawCanvas;
final int? zoomLevel;
final bool showPagesSidebar;
final bool showSignaturesSidebar;
final VoidCallback onTogglePagesSidebar;
final VoidCallback onToggleSignaturesSidebar;
@override @override
ConsumerState<PdfToolbar> createState() => _PdfToolbarState(); ConsumerState<PdfToolbar> createState() => _PdfToolbarState();
@ -64,34 +57,21 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260; final bool compact = constraints.maxWidth < 260;
final double gotoWidth = 50; final double gotoWidth = compact ? 60 : 100;
return Wrap(
// Center content of the toolbar
final center = Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
OutlinedButton(
key: const Key('btn_open_settings'),
onPressed: widget.disabled ? null : widget.onOpenSettings,
child: Text(l.settings),
),
OutlinedButton( OutlinedButton(
key: const Key('btn_open_pdf_picker'), key: const Key('btn_open_pdf_picker'),
onPressed: widget.disabled ? null : widget.onPickPdf, onPressed: widget.disabled ? null : widget.onPickPdf,
child: Row( child: Text(l.openPdf),
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) ...[ if (pdf.loaded) ...[
Row( Row(
@ -106,7 +86,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
icon: const Icon(Icons.chevron_left), icon: const Icon(Icons.chevron_left),
tooltip: l.prev, tooltip: l.prev,
), ),
// Current page label
Text(pageInfo, key: const Key('lbl_page_info')), Text(pageInfo, key: const Key('lbl_page_info')),
IconButton( IconButton(
key: const Key('btn_next'), key: const Key('btn_next'),
@ -117,59 +96,36 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
tooltip: l.next, tooltip: l.next,
), ),
Wrap( ],
spacing: 6, ),
runSpacing: 4, Wrap(
crossAxisAlignment: WrapCrossAlignment.center, spacing: 6,
children: [ runSpacing: 4,
Text(l.goTo), crossAxisAlignment: WrapCrossAlignment.center,
SizedBox( children: [
width: gotoWidth, Text(l.goTo),
child: TextField( SizedBox(
key: const Key('txt_goto'), width: gotoWidth,
controller: _goToController, child: TextField(
keyboardType: TextInputType.number, key: const Key('txt_goto'),
inputFormatters: [ controller: _goToController,
FilteringTextInputFormatter.digitsOnly, keyboardType: TextInputType.number,
], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
enabled: !widget.disabled, enabled: !widget.disabled,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
hintText: '1..${pdf.pageCount}', hintText: '1..${pdf.pageCount}',
),
onSubmitted: (_) => _submitGoTo(),
),
), ),
if (!compact) onSubmitted: (_) => _submitGoTo(),
IconButton( ),
key: const Key('btn_goto_apply'), ),
tooltip: l.goTo, if (!compact)
icon: const Icon(Icons.arrow_forward), IconButton(
onPressed: widget.disabled ? null : _submitGoTo, 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 && widget.zoomLevel != null) ...[
const SizedBox(width: 6),
// show zoom ratio
Text(
'${widget.zoomLevel}%',
style: const TextStyle(fontSize: 12),
), ),
],
], ],
), ),
Row( Row(
@ -200,42 +156,38 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
), ),
], ],
), ),
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,
),
),
],
);
}, },
); );
} }

View File

@ -1,217 +0,0 @@
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 'image_editor_dialog.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<SignatureDrawer> createState() => _SignatureDrawerState();
}
class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
Future<void> _openSignatureMenuAt(Offset globalPosition) async {
final l = AppLocalizations.of(context);
final selected = await showMenu<String>(
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);
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: 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,
),
),
),
),
),
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),
),
],
),
],
),
),
// Adjustments are accessed via "Adjust graphic" in the popup menu
],
),
);
}
}
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<SignatureDragData>(
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,
);
}
}

View File

@ -8,7 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../pdf/view_model/view_model.dart'; import '../../pdf/view_model/view_model.dart';
// Settings dialog is provided via global AppBar in MyApp import '../../preferences/widgets/settings_screen.dart';
// Abstraction to make drop handling testable without constructing // Abstraction to make drop handling testable without constructing
// platform-specific DropItem types in widget tests. // platform-specific DropItem types in widget tests.
@ -131,19 +131,33 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
), ),
color: color:
_dragging _dragging
? Theme.of( ? Theme.of(context).colorScheme.primary.withOpacity(0.05)
context,
).colorScheme.primary.withValues(alpha: 0.05)
: Colors.transparent, : Colors.transparent,
), ),
child: content, child: content,
), ),
); );
return Center( return Scaffold(
child: ConstrainedBox( appBar: AppBar(
constraints: const BoxConstraints(maxWidth: 560), title: Text(l.appTitle),
child: dropZone, actions: [
IconButton(
tooltip: l.settings,
onPressed:
() => showDialog<bool>(
context: context,
builder: (_) => const SettingsDialog(),
),
icon: const Icon(Icons.settings),
),
],
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: dropZone,
),
), ),
); );
} }

View File

@ -51,13 +51,10 @@ dependencies:
intl: any intl: any
flutter_localized_locales: ^2.0.5 flutter_localized_locales: ^2.0.5
desktop_drop: ^0.5.0 desktop_drop: ^0.5.0
multi_split_view: ^3.6.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
integration_test:
sdk: flutter
build_runner: ^2.4.12 build_runner: ^2.4.12
build: ^3.0.2 build: ^3.0.2
bdd_widget_test: ^2.0.1 bdd_widget_test: ^2.0.1

View File

@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
@ -32,20 +30,6 @@ Future<void> pumpWithOpenPdf(WidgetTester tester) async {
} }
Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async { Future<void> 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( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@ -53,10 +37,7 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
(ref) => PdfController()..openPicked(path: 'test.pdf'), (ref) => PdfController()..openPicked(path: 'test.pdf'),
), ),
signatureProvider.overrideWith( signatureProvider.overrideWith(
(ref) => (ref) => SignatureController()..placeDefaultRect(),
SignatureController()
..setImageBytes(sigBytes)
..placeDefaultRect(),
), ),
useMockViewerProvider.overrideWith((ref) => true), useMockViewerProvider.overrideWith((ref) => true),
pageViewModeProvider.overrideWithValue('continuous'), pageViewModeProvider.overrideWithValue('continuous'),

View File

@ -1,54 +0,0 @@
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.
},
);
}

View File

@ -1,31 +1,9 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart'; import 'helpers.dart';
void main() { void main() {
Future<void> 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 { testWidgets('Resize and move signature within page bounds', (tester) async {
await pumpWithOpenPdfAndSig(tester); await pumpWithOpenPdfAndSig(tester);
@ -57,8 +35,6 @@ void main() {
final overlay = find.byKey(const Key('signature_overlay')); final overlay = find.byKey(const Key('signature_overlay'));
final sizeBefore = tester.getSize(overlay); final sizeBefore = tester.getSize(overlay);
final aspect = sizeBefore.width / sizeBefore.height; 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.tap(find.byKey(const Key('chk_aspect_lock')));
await tester.pump(); await tester.pump();
await tester.drag( await tester.drag(
@ -76,17 +52,6 @@ void main() {
) async { ) async {
await pumpWithOpenPdfAndSig(tester); 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 // toggle bg removal
await tester.tap(find.byKey(const Key('swt_bg_removal'))); await tester.tap(find.byKey(const Key('swt_bg_removal')));
await tester.pump(); await tester.pump();