Merge branch 'feat/wireframe' into feat/multi_signature_picture
This commit is contained in:
commit
51bf7ed979
|
@ -133,3 +133,4 @@ AppDir/.DirIcon
|
||||||
AppDir/bundle/
|
AppDir/bundle/
|
||||||
appimage-build/
|
appimage-build/
|
||||||
/*.AppImage
|
/*.AppImage
|
||||||
|
.vscode/settings.json
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
"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", // detect hard-coded strings
|
"gabbygreat.flutter-l10n-checker",
|
||||||
|
"pomdtr.excalidraw-editor", // detect hard-coded strings
|
||||||
// "joaopinacio.translate-me"
|
// "joaopinacio.translate-me"
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -8,4 +8,5 @@ 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` 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.
|
||||||
|
|
|
@ -607,436 +607,6 @@
|
||||||
"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": {
|
||||||
|
|
|
@ -810,165 +810,6 @@
|
||||||
"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
|
@ -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 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.
|
- 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 gear icon in the top bar.
|
- Opened via "Configure" button 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,13 +39,35 @@ Illustration:
|
||||||
|
|
||||||
## PDF opened
|
## PDF opened
|
||||||
|
|
||||||
Purpose: view and navigate the PDF; prepare for signature placement.
|
Purpose: view and navigate the PDF; for signature placement.
|
||||||
Route: root --> opened
|
Route: root --> opened
|
||||||
|
|
||||||
Design notes:
|
Design notes:
|
||||||
- Main canvas shows the current 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/next page, zoom controls are placed in toolbar which is at top of main PDF canvas.
|
- On the far left of the toolbar there is a button that can turn the document pages overview sidebar on and off.
|
||||||
- Drag signature onto page.
|
- 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, 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:
|
||||||
|
|
||||||
|
|
|
@ -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<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);
|
||||||
|
});
|
||||||
|
}
|
23
lib/app.dart
23
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/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});
|
||||||
|
@ -62,7 +63,27 @@ class MyApp extends StatelessWidget {
|
||||||
...AppLocalizations.localizationsDelegates,
|
...AppLocalizations.localizationsDelegates,
|
||||||
LocaleNamesLocalizationsDelegate(),
|
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<bool>(
|
||||||
|
context: ctx,
|
||||||
|
builder: (_) => const SettingsDialog(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: const _RootHomeSwitcher(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -65,6 +65,8 @@ 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.
|
||||||
|
@ -76,6 +78,7 @@ 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,
|
||||||
|
@ -86,6 +89,7 @@ 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,
|
||||||
|
@ -96,6 +100,7 @@ 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,
|
||||||
|
@ -105,6 +110,7 @@ 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,
|
||||||
|
|
|
@ -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.",
|
"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}",
|
||||||
|
|
|
@ -61,6 +61,8 @@
|
||||||
"@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...",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"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}",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"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}",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "署名を長押しまたは右クリックして、確認または削除します。",
|
||||||
"next": "次へ",
|
"next": "次へ",
|
||||||
"noPdfLoaded": "PDFが読み込まれていません",
|
"noPdfLoaded": "PDFが読み込まれていません",
|
||||||
|
"noSignatureLoaded": "署名は読み込まれていません",
|
||||||
"nothingToSaveYet": "まだ保存するものがありません",
|
"nothingToSaveYet": "まだ保存するものがありません",
|
||||||
"openPdf": "PDFを開く…",
|
"openPdf": "PDFを開く…",
|
||||||
"pageInfo": "ページ {current}/{total}",
|
"pageInfo": "ページ {current}/{total}",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "서명을 길게 누르거나 마우스 오른쪽 버튼을 클릭하여 확인하거나 삭제합니다.",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"noPdfLoaded": "로드된 PDF 없음",
|
"noPdfLoaded": "로드된 PDF 없음",
|
||||||
|
"noSignatureLoaded": "서명이 로드되지 않았습니다",
|
||||||
"nothingToSaveYet": "아직 저장할 내용이 없습니다.",
|
"nothingToSaveYet": "아직 저장할 내용이 없습니다.",
|
||||||
"openPdf": "PDF 열기...",
|
"openPdf": "PDF 열기...",
|
||||||
"pageInfo": "{current}/{total} 페이지",
|
"pageInfo": "{current}/{total} 페이지",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Довго натисніть або клацніть правою кнопкою миші на підпис, щоб підтвердити або видалити.",
|
||||||
"next": "Далі",
|
"next": "Далі",
|
||||||
"noPdfLoaded": "PDF не завантажено",
|
"noPdfLoaded": "PDF не завантажено",
|
||||||
|
"noSignatureLoaded": "Не завантажено жодного підпису",
|
||||||
"nothingToSaveYet": "Ще нічого не потрібно зберігати",
|
"nothingToSaveYet": "Ще нічого не потрібно зберігати",
|
||||||
"openPdf": "Відкрити PDF...",
|
"openPdf": "Відкрити PDF...",
|
||||||
"pageInfo": "Сторінка {current}/{total}",
|
"pageInfo": "Сторінка {current}/{total}",
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
"next": "下一頁",
|
"next": "下一頁",
|
||||||
"noPdfLoaded": "尚未載入 PDF",
|
"noPdfLoaded": "尚未載入 PDF",
|
||||||
|
"noSignatureLoaded": "没有加载签名",
|
||||||
"nothingToSaveYet": "尚無可儲存的內容",
|
"nothingToSaveYet": "尚無可儲存的內容",
|
||||||
"openPdf": "開啟 PDF…",
|
"openPdf": "開啟 PDF…",
|
||||||
"pageInfo": "第 {current}/{total} 頁",
|
"pageInfo": "第 {current}/{total} 頁",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "长按或右键单击签名以确认或删除。",
|
||||||
"next": "下一步",
|
"next": "下一步",
|
||||||
"noPdfLoaded": "未加载 PDF",
|
"noPdfLoaded": "未加载 PDF",
|
||||||
|
"noSignatureLoaded": "未加载签名",
|
||||||
"nothingToSaveYet": "尚无内容保存",
|
"nothingToSaveYet": "尚无内容保存",
|
||||||
"openPdf": "打开 PDF...",
|
"openPdf": "打开 PDF...",
|
||||||
"pageInfo": "第 {current} 页 / 共 {total} 页",
|
"pageInfo": "第 {current} 页 / 共 {total} 页",
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
|
||||||
"next": "下一頁",
|
"next": "下一頁",
|
||||||
"noPdfLoaded": "尚未載入 PDF",
|
"noPdfLoaded": "尚未載入 PDF",
|
||||||
|
"noSignatureLoaded": "未載入任何簽名",
|
||||||
"nothingToSaveYet": "尚無可儲存的內容",
|
"nothingToSaveYet": "尚無可儲存的內容",
|
||||||
"openPdf": "開啟 PDF…",
|
"openPdf": "開啟 PDF…",
|
||||||
"pageInfo": "第 {current}/{total} 頁",
|
"pageInfo": "第 {current}/{total} 頁",
|
||||||
|
|
|
@ -273,6 +273,7 @@ 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);
|
||||||
|
@ -298,6 +299,16 @@ 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) {
|
||||||
|
@ -345,6 +356,7 @@ 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
|
||||||
|
|
||||||
|
@ -389,6 +401,16 @@ 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);
|
||||||
|
|
|
@ -39,11 +39,17 @@ class AdjustmentsPanel extends ConsumerWidget {
|
||||||
Text(AppLocalizations.of(context).backgroundRemoval),
|
Text(AppLocalizations.of(context).backgroundRemoval),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
const SizedBox(height: 8),
|
||||||
|
// Contrast control
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(AppLocalizations.of(context).contrast),
|
Text(AppLocalizations.of(context).contrast),
|
||||||
Expanded(
|
Align(
|
||||||
child: Slider(
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(sig.contrast.toStringAsFixed(2)),
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
key: const Key('sld_contrast'),
|
key: const Key('sld_contrast'),
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
max: 2.0,
|
max: 2.0,
|
||||||
|
@ -51,25 +57,25 @@ class AdjustmentsPanel extends ConsumerWidget {
|
||||||
onChanged:
|
onChanged:
|
||||||
(v) => ref.read(signatureProvider.notifier).setContrast(v),
|
(v) => ref.read(signatureProvider.notifier).setContrast(v),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Text(sig.contrast.toStringAsFixed(2)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
// Brightness control
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(AppLocalizations.of(context).brightness),
|
Text(AppLocalizations.of(context).brightness),
|
||||||
Expanded(
|
Align(
|
||||||
child: Slider(
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(sig.brightness.toStringAsFixed(2)),
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
key: const Key('sld_brightness'),
|
key: const Key('sld_brightness'),
|
||||||
min: -1.0,
|
min: -1.0,
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
value: sig.brightness,
|
value: sig.brightness,
|
||||||
onChanged:
|
onChanged:
|
||||||
(v) =>
|
(v) => ref.read(signatureProvider.notifier).setBrightness(v),
|
||||||
ref.read(signatureProvider.notifier).setBrightness(v),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Text(sig.brightness.toStringAsFixed(2)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
@ -8,21 +9,23 @@ 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 TransformationController? controller;
|
final PdfViewerController? viewerController;
|
||||||
final ValueChanged<Offset> onDragSignature;
|
final ValueChanged<Offset> onDragSignature;
|
||||||
final ValueChanged<Offset> onResizeSignature;
|
final ValueChanged<Offset> onResizeSignature;
|
||||||
final VoidCallback onConfirmSignature;
|
final VoidCallback onConfirmSignature;
|
||||||
|
@ -34,7 +37,8 @@ class PdfPageArea extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
final Map<int, GlobalKey> _pageKeys = {};
|
final Map<int, GlobalKey> _pageKeys = {};
|
||||||
final PdfViewerController _viewerController = PdfViewerController();
|
late final PdfViewerController _viewerController =
|
||||||
|
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;
|
||||||
|
@ -57,6 +61,8 @@ 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'),
|
||||||
|
@ -64,6 +70,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
|
|
||||||
void _scrollToPage(int page) {
|
void _scrollToPage(int page) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
final pdf = ref.read(pdfProvider);
|
final pdf = ref.read(pdfProvider);
|
||||||
final isContinuous = ref.read(pageViewModeProvider) == 'continuous';
|
final isContinuous = ref.read(pageViewModeProvider) == 'continuous';
|
||||||
|
|
||||||
|
@ -77,9 +84,10 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
anchor: PdfPageAnchor.top,
|
anchor: PdfPageAnchor.top,
|
||||||
);
|
);
|
||||||
// Fallback: if no onPageChanged arrives (e.g., same page), don't block future jumps
|
// 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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_programmaticTargetPage == page) {
|
if (_programmaticTargetPage == page) {
|
||||||
_programmaticTargetPage = null;
|
_programmaticTargetPage = null;
|
||||||
|
@ -129,7 +137,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
Scrollable.ensureVisible(
|
Scrollable.ensureVisible(
|
||||||
ctx,
|
ctx,
|
||||||
alignment: 0.1,
|
alignment: 0.1,
|
||||||
duration: const Duration(milliseconds: 1),
|
duration: Duration.zero,
|
||||||
curve: Curves.linear,
|
curve: Curves.linear,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
@ -192,51 +200,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
final useMock = ref.watch(useMockViewerProvider);
|
final useMock = ref.watch(useMockViewerProvider);
|
||||||
final isContinuous = pageViewMode == 'continuous';
|
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
|
// Mock continuous: ListView with prebuilt children, no controller
|
||||||
if (useMock && isContinuous) {
|
if (useMock && isContinuous) {
|
||||||
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
|
final count = pdf.pageCount > 0 ? pdf.pageCount : 1;
|
||||||
|
@ -250,14 +213,15 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
_pendingPage = null;
|
_pendingPage = null;
|
||||||
_scrollRetryCount = 0;
|
_scrollRetryCount = 0;
|
||||||
Future<void>.delayed(const Duration(milliseconds: 1), () {
|
// Schedule via microtask to avoid test timers remaining pending
|
||||||
|
scheduleMicrotask(() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_scrollToPage(p);
|
_scrollToPage(p);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return SingleChildScrollView(
|
final content = 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(
|
||||||
|
@ -311,70 +275,56 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
return content;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// 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) {
|
if (pdf.pickedPdfPath != null && isContinuous) {
|
||||||
return PdfViewer.file(
|
final viewer = PdfViewer.file(
|
||||||
pdf.pickedPdfPath!,
|
pdf.pickedPdfPath!,
|
||||||
controller: _viewerController,
|
controller: _viewerController,
|
||||||
params: PdfViewerParams(
|
params: PdfViewerParams(
|
||||||
pageAnchor: PdfPageAnchor.top,
|
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.withValues(alpha: 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.withValues(alpha: 0.7),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
pageNumber.toString(),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
onViewerReady: (doc, controller) {
|
onViewerReady: (doc, controller) {
|
||||||
if (pdf.pageCount != doc.pages.length) {
|
if (pdf.pageCount != doc.pages.length) {
|
||||||
ref.read(pdfProvider.notifier).setPageCount(doc.pages.length);
|
ref.read(pdfProvider.notifier).setPageCount(doc.pages.length);
|
||||||
|
@ -388,7 +338,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
// ensure we don't keep blocking provider-driven jumps.
|
// ensure we don't keep blocking provider-driven jumps.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_programmaticTargetPage == target) {
|
if (_programmaticTargetPage == target) {
|
||||||
_programmaticTargetPage = null;
|
_programmaticTargetPage = null;
|
||||||
|
@ -431,6 +381,34 @@ 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();
|
||||||
|
@ -459,6 +437,11 @@ 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) {
|
||||||
|
@ -467,6 +450,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
@ -496,9 +485,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 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 &&
|
if (sig.rect != null &&
|
||||||
sig.editingEnabled &&
|
sig.editingEnabled &&
|
||||||
(pdf.signedPage == null || pdf.signedPage == pageNumber)) {
|
(pdf.signedPage == null || pdf.signedPage == pageNumber) &&
|
||||||
|
pdf.currentPage == pageNumber) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
_buildSignatureOverlay(
|
_buildSignatureOverlay(
|
||||||
context,
|
context,
|
||||||
|
@ -577,7 +569,17 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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)
|
if (interactive)
|
||||||
|
@ -630,12 +632,22 @@ 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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -660,12 +672,22 @@ 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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -712,53 +734,4 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ZoomControls extends StatelessWidget {
|
// Zoom controls removed with single-page mode; continuous viewer manages zoom.
|
||||||
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));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,15 +5,16 @@ 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 '../../preferences/widgets/settings_screen.dart';
|
import 'signature_drawer.dart';
|
||||||
|
// adjustments are available via ImageEditorDialog
|
||||||
|
|
||||||
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
class PdfSignatureHomePage extends ConsumerStatefulWidget {
|
||||||
const PdfSignatureHomePage({super.key});
|
const PdfSignatureHomePage({super.key});
|
||||||
|
@ -25,7 +26,10 @@ 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 TransformationController _ivController = TransformationController();
|
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.
|
// Exposed for tests to trigger the invalid-file SnackBar without UI.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
|
@ -52,6 +56,8 @@ 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',
|
||||||
|
@ -68,25 +74,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _createNewSignature() {
|
// _createNewSignature was removed as the toolbar no longer exposes this action.
|
||||||
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);
|
||||||
|
@ -250,7 +238,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ivController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -259,29 +246,52 @@ 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: [
|
||||||
Row(
|
Column(
|
||||||
|
children: [
|
||||||
|
// Full-width toolbar row
|
||||||
|
PdfToolbar(
|
||||||
|
disabled: isExporting,
|
||||||
|
onPickPdf: _pickPdf,
|
||||||
|
onJumpToPage: _jumpToPage,
|
||||||
|
onZoomOut: () {
|
||||||
|
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,
|
||||||
|
onTogglePagesSidebar:
|
||||||
|
() => setState(() {
|
||||||
|
_showPagesSidebar = !_showPagesSidebar;
|
||||||
|
}),
|
||||||
|
onToggleSignaturesSidebar:
|
||||||
|
() => setState(() {
|
||||||
|
_showSignaturesSidebar = !_showSignaturesSidebar;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Left: pages overview (thumbnails + navigation)
|
if (_showPagesSidebar)
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
|
@ -292,31 +302,13 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
child: const PdfPagesOverview(),
|
child: const PdfPagesOverview(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
if (_showPagesSidebar) const SizedBox(width: 12),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
PdfToolbar(
|
|
||||||
disabled: isExporting,
|
|
||||||
onOpenSettings:
|
|
||||||
() => showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => const SettingsDialog(),
|
|
||||||
),
|
|
||||||
onPickPdf: _pickPdf,
|
|
||||||
onJumpToPage: _jumpToPage,
|
|
||||||
onSave: _saveSignedPdf,
|
|
||||||
onLoadSignatureFromFile: _loadSignatureFromFile,
|
|
||||||
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,
|
||||||
controller: _ivController,
|
viewerController: _viewerController,
|
||||||
onDragSignature: _onDragSignature,
|
onDragSignature: _onDragSignature,
|
||||||
onResizeSignature: _onResizeSignature,
|
onResizeSignature: _onResizeSignature,
|
||||||
onConfirmSignature: _confirmSignature,
|
onConfirmSignature: _confirmSignature,
|
||||||
|
@ -329,108 +321,45 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (_showSignaturesSidebar) const SizedBox(width: 12),
|
||||||
),
|
if (_showSignaturesSidebar)
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: 280,
|
minWidth: 140,
|
||||||
maxWidth: 360,
|
maxWidth: 250,
|
||||||
),
|
),
|
||||||
child: Consumer(
|
child: AbsorbPointer(
|
||||||
builder: (context, ref, _) {
|
|
||||||
final sig = ref.watch(signatureProvider);
|
|
||||||
if (sig.rect != null) {
|
|
||||||
return AbsorbPointer(
|
|
||||||
absorbing: isExporting,
|
absorbing: isExporting,
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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,
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
child: SignatureDrawer(
|
||||||
|
disabled: isExporting,
|
||||||
|
onLoadSignatureFromFile:
|
||||||
|
_loadSignatureFromFile,
|
||||||
|
onOpenDrawCanvas: _openDrawCanvas,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: AdjustmentsPanel(sig: sig),
|
child: ElevatedButton(
|
||||||
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -10,23 +10,30 @@ 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.onSave,
|
required this.onZoomOut,
|
||||||
required this.onLoadSignatureFromFile,
|
required this.onZoomIn,
|
||||||
required this.onCreateSignature,
|
this.zoomLevel,
|
||||||
required this.onOpenDrawCanvas,
|
this.fileName,
|
||||||
|
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 VoidCallback onSave;
|
final String? fileName;
|
||||||
final VoidCallback onLoadSignatureFromFile;
|
final VoidCallback onZoomOut;
|
||||||
final VoidCallback onCreateSignature;
|
final VoidCallback onZoomIn;
|
||||||
final VoidCallback onOpenDrawCanvas;
|
// Current zoom level as a percentage (e.g., 100 for 100%)
|
||||||
|
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();
|
||||||
|
@ -57,21 +64,34 @@ 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 = compact ? 60 : 100;
|
final double gotoWidth = 50;
|
||||||
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: 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) ...[
|
if (pdf.loaded) ...[
|
||||||
Row(
|
Row(
|
||||||
|
@ -86,6 +106,7 @@ 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'),
|
||||||
|
@ -96,8 +117,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
tooltip: l.next,
|
tooltip: l.next,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
|
@ -110,7 +129,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
key: const Key('txt_goto'),
|
key: const Key('txt_goto'),
|
||||||
controller: _goToController,
|
controller: _goToController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
enabled: !widget.disabled,
|
enabled: !widget.disabled,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
@ -128,6 +149,29 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
@ -156,38 +200,42 @@ 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,217 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,7 +28,7 @@ Set<String> _supportedTags() {
|
||||||
// Keys
|
// Keys
|
||||||
const _kTheme = 'theme'; // 'light'|'dark'|'system'
|
const _kTheme = 'theme'; // 'light'|'dark'|'system'
|
||||||
const _kLanguage = 'language'; // BCP-47 tag like 'en', 'zh-TW', 'es'
|
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) {
|
String _normalizeLanguageTag(String tag) {
|
||||||
final tags = _supportedTags();
|
final tags = _supportedTags();
|
||||||
|
@ -65,7 +65,7 @@ String _normalizeLanguageTag(String tag) {
|
||||||
class PreferencesState {
|
class PreferencesState {
|
||||||
final String theme; // 'light' | 'dark' | 'system'
|
final String theme; // 'light' | 'dark' | 'system'
|
||||||
final String language; // 'en' | 'zh-TW' | 'es'
|
final String language; // 'en' | 'zh-TW' | 'es'
|
||||||
final String pageView; // 'single' | 'continuous'
|
final String pageView; // only 'continuous'
|
||||||
const PreferencesState({
|
const PreferencesState({
|
||||||
required this.theme,
|
required this.theme,
|
||||||
required this.language,
|
required this.language,
|
||||||
|
@ -94,7 +94,7 @@ class PreferencesNotifier extends StateNotifier<PreferencesState> {
|
||||||
WidgetsBinding.instance.platformDispatcher.locale
|
WidgetsBinding.instance.platformDispatcher.locale
|
||||||
.toLanguageTag(),
|
.toLanguageTag(),
|
||||||
),
|
),
|
||||||
pageView: prefs.getString(_kPageView) ?? 'single',
|
pageView: prefs.getString(_kPageView) ?? 'continuous',
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// normalize language to supported/fallback
|
// normalize language to supported/fallback
|
||||||
|
@ -112,10 +112,10 @@ class PreferencesNotifier extends StateNotifier<PreferencesState> {
|
||||||
state = state.copyWith(language: normalized);
|
state = state.copyWith(language: normalized);
|
||||||
prefs.setString(_kLanguage, normalized);
|
prefs.setString(_kLanguage, normalized);
|
||||||
}
|
}
|
||||||
final pageViewValid = {'single', 'continuous'};
|
final pageViewValid = {'continuous'};
|
||||||
if (!pageViewValid.contains(state.pageView)) {
|
if (!pageViewValid.contains(state.pageView)) {
|
||||||
state = state.copyWith(pageView: 'single');
|
state = state.copyWith(pageView: 'continuous');
|
||||||
prefs.setString(_kPageView, 'single');
|
prefs.setString(_kPageView, 'continuous');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,15 +139,15 @@ class PreferencesNotifier extends StateNotifier<PreferencesState> {
|
||||||
state = PreferencesState(
|
state = PreferencesState(
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
language: normalized,
|
language: normalized,
|
||||||
pageView: 'single',
|
pageView: 'continuous',
|
||||||
);
|
);
|
||||||
await prefs.setString(_kTheme, 'system');
|
await prefs.setString(_kTheme, 'system');
|
||||||
await prefs.setString(_kLanguage, normalized);
|
await prefs.setString(_kLanguage, normalized);
|
||||||
await prefs.setString(_kPageView, 'single');
|
await prefs.setString(_kPageView, 'continuous');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setPageView(String pageView) async {
|
Future<void> setPageView(String pageView) async {
|
||||||
final valid = {'single', 'continuous'};
|
final valid = {'continuous'};
|
||||||
if (!valid.contains(pageView)) return;
|
if (!valid.contains(pageView)) return;
|
||||||
state = state.copyWith(pageView: pageView);
|
state = state.copyWith(pageView: pageView);
|
||||||
await prefs.setString(_kPageView, pageView);
|
await prefs.setString(_kPageView, pageView);
|
||||||
|
@ -173,13 +173,13 @@ final preferencesProvider =
|
||||||
return PreferencesNotifier(prefs);
|
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).
|
/// SharedPreferences is available (useful for lightweight widget tests).
|
||||||
final pageViewModeProvider = Provider<String>((ref) {
|
final pageViewModeProvider = Provider<String>((ref) {
|
||||||
final sp = ref.watch(sharedPreferencesProvider);
|
final sp = ref.watch(sharedPreferencesProvider);
|
||||||
return sp.maybeWhen(
|
return sp.maybeWhen(
|
||||||
data: (_) => ref.watch(preferencesProvider).pageView,
|
data: (_) => ref.watch(preferencesProvider).pageView,
|
||||||
orElse: () => 'single',
|
orElse: () => 'continuous',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class SettingsDialog extends ConsumerStatefulWidget {
|
||||||
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
String? _theme;
|
String? _theme;
|
||||||
String? _language;
|
String? _language;
|
||||||
String? _pageView; // 'single' | 'continuous'
|
// Page view removed; continuous-only
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -21,7 +21,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
final prefs = ref.read(preferencesProvider);
|
final prefs = ref.read(preferencesProvider);
|
||||||
_theme = prefs.theme;
|
_theme = prefs.theme;
|
||||||
_language = prefs.language;
|
_language = prefs.language;
|
||||||
_pageView = prefs.pageView;
|
// pageView no longer configurable (continuous-only)
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -149,31 +149,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
// Page view setting removed (continuous-only)
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(width: 140, child: Text('${l.pageView}:')),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: DropdownButton<String>(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
@ -188,7 +164,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
final n = ref.read(preferencesProvider.notifier);
|
final n = ref.read(preferencesProvider.notifier);
|
||||||
if (_theme != null) await n.setTheme(_theme!);
|
if (_theme != null) await n.setTheme(_theme!);
|
||||||
if (_language != null) await n.setLanguage(_language!);
|
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);
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
},
|
},
|
||||||
child: Text(l.save),
|
child: Text(l.save),
|
||||||
|
|
|
@ -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';
|
||||||
import '../../preferences/widgets/settings_screen.dart';
|
// Settings dialog is provided via global AppBar in MyApp
|
||||||
|
|
||||||
// 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,34 +131,20 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
||||||
),
|
),
|
||||||
color:
|
color:
|
||||||
_dragging
|
_dragging
|
||||||
? Theme.of(context).colorScheme.primary.withOpacity(0.05)
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.05)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
child: content,
|
child: content,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Center(
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(l.appTitle),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
tooltip: l.settings,
|
|
||||||
onPressed:
|
|
||||||
() => showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => const SettingsDialog(),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: dropZone,
|
child: dropZone,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,10 +51,13 @@ 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
|
||||||
|
|
|
@ -36,12 +36,7 @@ Feature: PDF browser
|
||||||
Then page {5} becomes visible in the scroll area
|
Then page {5} becomes visible in the scroll area
|
||||||
And the left pages overview highlights page {5}
|
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
|
Scenario: Go to clamps out-of-range inputs to valid bounds
|
||||||
Given the document is open
|
Given the document is open
|
||||||
|
|
|
@ -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<void> onlyPageIsRenderedInTheCanvas(
|
|
||||||
WidgetTester tester,
|
|
||||||
num param1,
|
|
||||||
) async {
|
|
||||||
final page = param1.toInt();
|
|
||||||
final c = TestWorld.container ?? ProviderContainer();
|
|
||||||
expect(c.read(pdfProvider).currentPage, page);
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import '_world.dart';
|
|
||||||
|
|
||||||
/// Usage: the Page view mode is set to Single
|
|
||||||
Future<void> thePageViewModeIsSetToSingle(WidgetTester tester) async {
|
|
||||||
TestWorld.prefs['page_view'] = 'single';
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import '_world.dart';
|
|
||||||
|
|
||||||
/// Usage: the preference 'language' is saved as {"<language>"}
|
|
||||||
Future<void> 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);
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import '_world.dart';
|
|
||||||
|
|
||||||
/// Usage: the preference 'theme' is saved as {"<theme>"}
|
|
||||||
Future<void> 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);
|
|
||||||
}
|
|
|
@ -1,11 +1,14 @@
|
||||||
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';
|
||||||
import 'package:pdf_signature/data/services/providers.dart';
|
import 'package:pdf_signature/data/services/providers.dart';
|
||||||
import 'package:pdf_signature/l10n/app_localizations.dart';
|
import 'package:pdf_signature/l10n/app_localizations.dart';
|
||||||
|
import 'package:pdf_signature/ui/features/preferences/providers.dart';
|
||||||
|
|
||||||
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
|
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
@ -15,6 +18,8 @@ Future<void> pumpWithOpenPdf(WidgetTester tester) async {
|
||||||
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
||||||
),
|
),
|
||||||
useMockViewerProvider.overrideWith((ref) => true),
|
useMockViewerProvider.overrideWith((ref) => true),
|
||||||
|
// Force continuous mode regardless of prefs
|
||||||
|
pageViewModeProvider.overrideWithValue('continuous'),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
@ -27,6 +32,20 @@ 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: [
|
||||||
|
@ -34,9 +53,13 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
|
||||||
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
(ref) => PdfController()..openPicked(path: 'test.pdf'),
|
||||||
),
|
),
|
||||||
signatureProvider.overrideWith(
|
signatureProvider.overrideWith(
|
||||||
(ref) => SignatureController()..placeDefaultRect(),
|
(ref) =>
|
||||||
|
SignatureController()
|
||||||
|
..setImageBytes(sigBytes)
|
||||||
|
..placeDefaultRect(),
|
||||||
),
|
),
|
||||||
useMockViewerProvider.overrideWith((ref) => true),
|
useMockViewerProvider.overrideWith((ref) => true),
|
||||||
|
pageViewModeProvider.overrideWithValue('continuous'),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
|
|
@ -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);
|
expect(find.byKey(const Key('lbl_page_info')), findsOneWidget);
|
||||||
Text label() => tester.widget<Text>(find.byKey(const Key('lbl_page_info')));
|
Text label() => tester.widget<Text>(find.byKey(const Key('lbl_page_info')));
|
||||||
expect(label().data, equals('Page 1/5'));
|
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
|
// Next
|
||||||
await tester.tap(find.byKey(const Key('btn_next')));
|
await tester.tap(find.byKey(const Key('btn_next')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(label().data, equals('Page 2/5'));
|
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
|
// Prev
|
||||||
await tester.tap(find.byKey(const Key('btn_prev')));
|
await tester.tap(find.byKey(const Key('btn_prev')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(label().data, equals('Page 1/5'));
|
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
|
// Goto specific page
|
||||||
await tester.tap(find.byKey(const Key('txt_goto')));
|
await tester.tap(find.byKey(const Key('txt_goto')));
|
||||||
|
@ -63,7 +63,7 @@ void main() {
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(label().data, equals('Page 4/5'));
|
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
|
// Goto beyond upper bound -> clamp to 5
|
||||||
await tester.tap(find.byKey(const Key('txt_goto')));
|
await tester.tap(find.byKey(const Key('txt_goto')));
|
||||||
|
@ -72,7 +72,7 @@ void main() {
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(label().data, equals('Page 5/5'));
|
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
|
// Goto below 1 -> clamp to 1
|
||||||
await tester.tap(find.byKey(const Key('txt_goto')));
|
await tester.tap(find.byKey(const Key('txt_goto')));
|
||||||
|
@ -81,6 +81,6 @@ void main() {
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(label().data, equals('Page 1/5'));
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,31 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
@ -35,6 +57,8 @@ 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(
|
||||||
|
@ -52,6 +76,17 @@ 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();
|
||||||
|
|
Loading…
Reference in New Issue