Compare commits

...

6 Commits

56 changed files with 1922 additions and 1896 deletions

2
.gitignore vendored
View File

@ -123,3 +123,5 @@ docs/.*
.vscode/launch.json
devtools_options.yaml
test/features/*_test.dart
**/app_localizations*.dart
.env

12
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"recommendations": [
"yzhang.markdown-all-in-one",
"alexkrechik.cucumberautocomplete",
"google.arb-editor",
"dart-code.flutter",
"lsaudon.l10nization", // quick translation gen
"oke331.flutter-l10n-helper", // show arb string
"gabbygreat.flutter-l10n-checker", // detect hard-coded strings
// "joaopinacio.translate-me"
]
}

View File

@ -10,6 +10,7 @@ checkout [`docs/FRs.md`](docs/FRs.md)
```bash
# flutter clean
# arb_translate
flutter pub get
# generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs

View File

@ -2,35 +2,43 @@
## user stories
* name: PDF browser
* name: [PDF browser](../test/features/pdf_browser.feature)
* role: user
* functionality: view and navigate PDF documents
* benefit: select page to add signature
* name: load signature picture
* name: [load signature picture](../test/features/load_signature_picture.feature)
* role: user
* functionality: load a signature picture file
* benefit: easily add signature to PDF
* name: geometrically adjust signature picture
* name: [geometrically adjust signature picture](../test/features/geometrically_adjust_signature_picture.feature)
* role: user
* functionality: adjust the size and position of the signature picture
* benefit: ensure the signature fits well on the PDF page
* name: graphically adjust signature picture
* name: [graphically adjust signature picture](../test/features/graphically_adjust_signature_picture.feature)
* role: user
* functionality: background removal, contrast adjustment...
* benefit: easily improve the appearance of the signature on the PDF without additional software.
* name: draw signature
* name: [draw signature](../test/features/draw_signature.feature)
* role: user
* functionality: draw a signature using mouse or touch input
* benefit: create a custom signature directly on the PDF if no pre-made signature is available.
* name: save signed PDF
* name: [save signed PDF](../test/features/save_signed_pdf.feature)
* role: user
* functionality: save/export the signed PDF document
* benefit: easily keep a copy of the signed document for records.
* name: preferences for app
* name: [preferences for app](../test/features/app_preferences.feature)
* role: user
* functionality: configure app preferences such as `theme`, `language`.
* benefit: customize the app experience to better fit user needs
* name: remember preferences
* name: [remember preferences](../test/features/remember_preferences.feature)
* role: user
* functionality: remember user preferences for future sessions
* benefit: provide a consistent and personalized experience
* name: [internationalizing](../test/features/internationalizing.feature)
* role: user
* functionality: app provide localization support
* benefit: improve accessibility and usability for non-English speakers
* name: [support multiple signatures](../test/features/support_multiple_signatures.feature)
* role: user
* functionality: the ability to sign multiple locations within a PDF document
* benefit: documents requiring multiple signatures can be signed simultaneously

View File

@ -3,4 +3,3 @@ template-arb-file: app_en.arb
output-class: AppLocalizations
output-localization-file: app_localizations.dart
nullable-getter: false
untranslated-messages-file: build/l10n_missing.txt

View File

@ -5,43 +5,53 @@ class PdfState {
final bool loaded;
final int pageCount;
final int currentPage;
final bool markedForSigning;
final String? pickedPdfPath;
final Uint8List? pickedPdfBytes;
final int? signedPage;
// Multiple signature placements per page, stored as UI-space rects (e.g., 400x560)
final Map<int, List<Rect>> placementsByPage;
// UI state: selected placement index on the current page (if any)
final int? selectedPlacementIndex;
const PdfState({
required this.loaded,
required this.pageCount,
required this.currentPage,
required this.markedForSigning,
this.pickedPdfPath,
this.pickedPdfBytes,
this.signedPage,
this.placementsByPage = const {},
this.selectedPlacementIndex,
});
factory PdfState.initial() => const PdfState(
loaded: false,
pageCount: 0,
currentPage: 1,
markedForSigning: false,
pickedPdfBytes: null,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
PdfState copyWith({
bool? loaded,
int? pageCount,
int? currentPage,
bool? markedForSigning,
String? pickedPdfPath,
Uint8List? pickedPdfBytes,
int? signedPage,
Map<int, List<Rect>>? placementsByPage,
int? selectedPlacementIndex,
}) => PdfState(
loaded: loaded ?? this.loaded,
pageCount: pageCount ?? this.pageCount,
currentPage: currentPage ?? this.currentPage,
markedForSigning: markedForSigning ?? this.markedForSigning,
pickedPdfPath: pickedPdfPath ?? this.pickedPdfPath,
pickedPdfBytes: pickedPdfBytes ?? this.pickedPdfBytes,
signedPage: signedPage ?? this.signedPage,
placementsByPage: placementsByPage ?? this.placementsByPage,
selectedPlacementIndex:
selectedPlacementIndex == null
? this.selectedPlacementIndex
: selectedPlacementIndex,
);
}
@ -53,6 +63,9 @@ class SignatureState {
final double brightness;
final List<List<Offset>> strokes;
final Uint8List? imageBytes;
// When true, the active signature overlay is movable/resizable and should not be exported.
// When false, the overlay is confirmed (unmovable) and eligible for export.
final bool editingEnabled;
const SignatureState({
required this.rect,
required this.aspectLocked,
@ -61,6 +74,7 @@ class SignatureState {
required this.brightness,
required this.strokes,
this.imageBytes,
this.editingEnabled = false,
});
factory SignatureState.initial() => const SignatureState(
rect: null,
@ -70,6 +84,7 @@ class SignatureState {
brightness: 0.0,
strokes: [],
imageBytes: null,
editingEnabled: false,
);
SignatureState copyWith({
Rect? rect,
@ -79,6 +94,7 @@ class SignatureState {
double? brightness,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
bool? editingEnabled,
}) => SignatureState(
rect: rect ?? this.rect,
aspectLocked: aspectLocked ?? this.aspectLocked,
@ -87,5 +103,6 @@ class SignatureState {
brightness: brightness ?? this.brightness,
strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes,
editingEnabled: editingEnabled ?? this.editingEnabled,
);
}

View File

@ -32,6 +32,7 @@ class ExportService {
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage,
double targetDpi = 144.0,
}) async {
// print(
@ -51,6 +52,7 @@ class ExportService {
signatureRectUi: signatureRectUi,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: placementsByPage,
targetDpi: targetDpi,
);
if (bytes == null) return false;
@ -70,6 +72,7 @@ class ExportService {
required Rect? signatureRectUi,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<Rect>>? placementsByPage,
double targetDpi = 144.0,
}) async {
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
@ -91,13 +94,25 @@ class ExportService {
final bgImg = pw.MemoryImage(bgPng);
pw.MemoryImage? sigImgObj;
final shouldStamp =
final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements =
hasMulti
? (placementsByPage[pageIndex] ?? const <Rect>[])
: const <Rect>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
pageIndex == signedPage &&
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStamp) {
final shouldStampMulti =
hasMulti &&
pagePlacements.isNotEmpty &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle || shouldStampMulti) {
try {
sigImgObj = pw.MemoryImage(signatureImageBytes);
} catch (_) {
@ -125,18 +140,34 @@ class ExportService {
),
];
if (sigImgObj != null) {
final r = signatureRectUi!;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
if (hasMulti && pagePlacements.isNotEmpty) {
for (final r in pagePlacements) {
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
} else if (shouldStampSingle) {
final r = signatureRectUi;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
}
return pw.Stack(children: children);
},
@ -152,13 +183,23 @@ class ExportService {
final widthPts = pdf.PdfPageFormat.a4.width;
final heightPts = pdf.PdfPageFormat.a4.height;
pw.MemoryImage? sigImgObj;
final shouldStamp =
final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements =
hasMulti ? (placementsByPage[1] ?? const <Rect>[]) : const <Rect>[];
final shouldStampSingle =
!hasMulti &&
signedPage != null &&
signedPage == 1 &&
signatureRectUi != null &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStamp) {
final shouldStampMulti =
hasMulti &&
pagePlacements.isNotEmpty &&
signatureImageBytes != null &&
signatureImageBytes.isNotEmpty;
if (shouldStampSingle || shouldStampMulti) {
try {
// If it's already PNG, keep as-is to preserve alpha; otherwise decode/encode PNG
final asStr = String.fromCharCodes(signatureImageBytes.take(8));
@ -192,18 +233,34 @@ class ExportService {
),
];
if (sigImgObj != null) {
final r = signatureRectUi!;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
if (hasMulti && pagePlacements.isNotEmpty) {
for (final r in pagePlacements) {
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
} else if (shouldStampSingle) {
final r = signatureRectUi;
final left = r.left / uiPageSize.width * widthPts;
final top = r.top / uiPageSize.height * heightPts;
final w = r.width / uiPageSize.width * widthPts;
final h = r.height / uiPageSize.height * heightPts;
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.Image(sigImgObj, width: w, height: h),
),
);
}
}
return pw.Stack(children: children);
},

View File

@ -1,61 +1,107 @@
{
"@@locale": "en",
"appTitle": "PDF Signature",
"@appTitle": {},
"backgroundRemoval": "Background removal",
"@backgroundRemoval": {},
"brightness": "Brightness",
"@brightness": {},
"clear": "Clear",
"@clear": {},
"confirm": "Confirm",
"@confirm": {},
"contrast": "Contrast",
"@contrast": {},
"createNewSignature": "Create new signature",
"@createNewSignature": {},
"delete": "Delete",
"@delete": {},
"downloadStarted": "Download started",
"@downloadStarted": {},
"dpi": "DPI:",
"@dpi": {},
"drawSignature": "Draw Signature",
"@drawSignature": {},
"errorWithMessage": "Error: {message}",
"@errorWithMessage": {
"description": "Generic error text with message",
"placeholders": {"message": {"type": "String"}}
"placeholders": {
"message": {
"type": "String"
}
}
},
"settings": "Settings",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"exportingPleaseWait": "Exporting... Please wait",
"@exportingPleaseWait": {},
"failedToGeneratePdf": "Failed to generate PDF",
"@failedToGeneratePdf": {},
"failedToSavePdf": "Failed to save PDF",
"@failedToSavePdf": {},
"goTo": "Go to:",
"@goTo": {},
"invalidOrUnsupportedFile": "Invalid or unsupported file",
"@invalidOrUnsupportedFile": {},
"language": "Language",
"languageEnglish": "English",
"@language": {},
"languageChineseTraditional": "Traditional Chinese",
"@languageChineseTraditional": {},
"languageEnglish": "English",
"@languageEnglish": {},
"languageSpanish": "Spanish",
"resetToDefaults": "Reset to defaults",
"openPdf": "Open PDF...",
"prev": "Prev",
"@languageSpanish": {},
"loadSignatureFromFile": "Load Signature from file",
"@loadSignatureFromFile": {},
"lockAspectRatio": "Lock aspect ratio",
"@lockAspectRatio": {},
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Long-press or right-click the signature to Confirm or Delete.",
"@longPressOrRightClickTheSignatureToConfirmOrDelete": {},
"next": "Next",
"@next": {},
"noPdfLoaded": "No PDF loaded",
"@noPdfLoaded": {},
"nothingToSaveYet": "Nothing to save yet",
"@nothingToSaveYet": {},
"openPdf": "Open PDF...",
"@openPdf": {},
"pageInfo": "Page {current}/{total}",
"@pageInfo": {
"description": "Label showing current page and total",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"goTo": "Go to:",
"dpi": "DPI:",
"markForSigning": "Mark for Signing",
"unmarkSigning": "Unmark Signing",
"saveSignedPdf": "Save Signed PDF",
"loadSignatureFromFile": "Load Signature from file",
"drawSignature": "Draw Signature",
"noPdfLoaded": "No PDF loaded",
"signature": "Signature",
"lockAspectRatio": "Lock aspect ratio",
"backgroundRemoval": "Background removal",
"contrast": "Contrast",
"brightness": "Brightness",
"exportingPleaseWait": "Exporting... Please wait",
"nothingToSaveYet": "Nothing to save yet",
"prev": "Prev",
"@prev": {},
"resetToDefaults": "Reset to defaults",
"@resetToDefaults": {},
"savedWithPath": "Saved: {path}",
"@savedWithPath": {
"description": "Snackbar text showing where file saved",
"placeholders": {"path": {"type": "String"}}
"placeholders": {
"path": {
"type": "String"
}
}
},
"failedToSavePdf": "Failed to save PDF",
"downloadStarted": "Download started",
"failedToGeneratePdf": "Failed to generate PDF",
"invalidOrUnsupportedFile": "Invalid or unsupported file",
"confirm": "Confirm",
"saveSignedPdf": "Save Signed PDF",
"@saveSignedPdf": {},
"settings": "Settings",
"@settings": {},
"signature": "Signature",
"@signature": {},
"theme": "Theme",
"@theme": {},
"themeDark": "Dark",
"@themeDark": {},
"themeLight": "Light",
"@themeLight": {},
"themeSystem": "System",
"@themeSystem": {},
"undo": "Undo",
"clear": "Clear"
}
"@undo": {}
}

View File

@ -1,46 +1,42 @@
{
"@@locale": "es",
"appTitle": "Firma PDF",
"errorWithMessage": "Error: {message}",
"settings": "Ajustes",
"theme": "Tema",
"themeLight": "Claro",
"themeDark": "Oscuro",
"themeSystem": "Del sistema",
"language": "Idioma",
"languageEnglish": "Inglés",
"languageChineseTraditional": "Chino tradicional",
"languageSpanish": "Español",
"resetToDefaults": "Restablecer valores",
"openPdf": "Abrir PDF…",
"prev": "Anterior",
"next": "Siguiente",
"pageInfo": "Página {current}/{total}",
"goTo": "Ir a:",
"dpi": "DPI:",
"markForSigning": "Marcar para firmar",
"unmarkSigning": "Quitar marca",
"saveSignedPdf": "Guardar PDF firmado",
"loadSignatureFromFile": "Cargar firma desde archivo",
"drawSignature": "Dibujar firma",
"noPdfLoaded": "No hay PDF cargado",
"signature": "Firma",
"lockAspectRatio": "Bloquear relación de aspecto",
"backgroundRemoval": "Eliminación de fondo",
"contrast": "Contraste",
"backgroundRemoval": "Eliminar fondo",
"brightness": "Brillo",
"exportingPleaseWait": "Exportando... Por favor espera",
"nothingToSaveYet": "Nada que guardar todavía",
"savedWithPath": "Guardado: {path}",
"failedToSavePdf": "Error al guardar el PDF",
"downloadStarted": "Descarga iniciada",
"failedToGeneratePdf": "Error al generar el PDF",
"invalidOrUnsupportedFile": "Archivo no válido o no compatible",
"clear": "Limpiar",
"confirm": "Confirmar",
"undo": "Deshacer",
"clear": "Limpiar"
}
"contrast": "Contraste",
"createNewSignature": "Crear nueva firma",
"delete": "Eliminar",
"downloadStarted": "Descarga iniciada",
"dpi": "DPI:",
"drawSignature": "Dibujar firma",
"errorWithMessage": "Error: {message}",
"exportingPleaseWait": "Exportando... Por favor, espere",
"failedToGeneratePdf": "No se pudo generar el PDF",
"failedToSavePdf": "No se pudo guardar el PDF",
"goTo": "Ir a:",
"invalidOrUnsupportedFile": "Archivo inválido o no compatible",
"language": "Idioma",
"languageChineseTraditional": "Chino tradicional",
"languageEnglish": "Inglés",
"languageSpanish": "Español",
"loadSignatureFromFile": "Cargar firma desde archivo",
"lockAspectRatio": "Bloquear relación de aspecto",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "Pulse prolongadamente o haga clic derecho en la firma para confirmar o eliminar.",
"next": "Siguiente",
"noPdfLoaded": "No se ha cargado ningún PDF",
"nothingToSaveYet": "Aún no hay nada que guardar",
"openPdf": "Abrir PDF...",
"pageInfo": "Página {current}/{total}",
"prev": "Anterior",
"resetToDefaults": "Restablecer valores predeterminados",
"savedWithPath": "Guardado: {path}",
"saveSignedPdf": "Guardar PDF firmado",
"settings": "Ajustes",
"signature": "Firma",
"theme": "Tema",
"themeDark": "Oscuro",
"themeLight": "Claro",
"themeSystem": "Sistema",
"undo": "Deshacer"
}

View File

@ -1,385 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_es.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('es'),
Locale('zh'),
Locale('zh', 'TW'),
];
/// No description provided for @appTitle.
///
/// In en, this message translates to:
/// **'PDF Signature'**
String get appTitle;
/// Generic error text with message
///
/// In en, this message translates to:
/// **'Error: {message}'**
String errorWithMessage(String message);
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @theme.
///
/// In en, this message translates to:
/// **'Theme'**
String get theme;
/// No description provided for @themeLight.
///
/// In en, this message translates to:
/// **'Light'**
String get themeLight;
/// No description provided for @themeDark.
///
/// In en, this message translates to:
/// **'Dark'**
String get themeDark;
/// No description provided for @themeSystem.
///
/// In en, this message translates to:
/// **'System'**
String get themeSystem;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @languageEnglish.
///
/// In en, this message translates to:
/// **'English'**
String get languageEnglish;
/// No description provided for @languageChineseTraditional.
///
/// In en, this message translates to:
/// **'Traditional Chinese'**
String get languageChineseTraditional;
/// No description provided for @languageSpanish.
///
/// In en, this message translates to:
/// **'Spanish'**
String get languageSpanish;
/// No description provided for @resetToDefaults.
///
/// In en, this message translates to:
/// **'Reset to defaults'**
String get resetToDefaults;
/// No description provided for @openPdf.
///
/// In en, this message translates to:
/// **'Open PDF...'**
String get openPdf;
/// No description provided for @prev.
///
/// In en, this message translates to:
/// **'Prev'**
String get prev;
/// No description provided for @next.
///
/// In en, this message translates to:
/// **'Next'**
String get next;
/// Label showing current page and total
///
/// In en, this message translates to:
/// **'Page {current}/{total}'**
String pageInfo(int current, int total);
/// No description provided for @goTo.
///
/// In en, this message translates to:
/// **'Go to:'**
String get goTo;
/// No description provided for @dpi.
///
/// In en, this message translates to:
/// **'DPI:'**
String get dpi;
/// No description provided for @markForSigning.
///
/// In en, this message translates to:
/// **'Mark for Signing'**
String get markForSigning;
/// No description provided for @unmarkSigning.
///
/// In en, this message translates to:
/// **'Unmark Signing'**
String get unmarkSigning;
/// No description provided for @saveSignedPdf.
///
/// In en, this message translates to:
/// **'Save Signed PDF'**
String get saveSignedPdf;
/// No description provided for @loadSignatureFromFile.
///
/// In en, this message translates to:
/// **'Load Signature from file'**
String get loadSignatureFromFile;
/// No description provided for @drawSignature.
///
/// In en, this message translates to:
/// **'Draw Signature'**
String get drawSignature;
/// No description provided for @noPdfLoaded.
///
/// In en, this message translates to:
/// **'No PDF loaded'**
String get noPdfLoaded;
/// No description provided for @signature.
///
/// In en, this message translates to:
/// **'Signature'**
String get signature;
/// No description provided for @lockAspectRatio.
///
/// In en, this message translates to:
/// **'Lock aspect ratio'**
String get lockAspectRatio;
/// No description provided for @backgroundRemoval.
///
/// In en, this message translates to:
/// **'Background removal'**
String get backgroundRemoval;
/// No description provided for @contrast.
///
/// In en, this message translates to:
/// **'Contrast'**
String get contrast;
/// No description provided for @brightness.
///
/// In en, this message translates to:
/// **'Brightness'**
String get brightness;
/// No description provided for @exportingPleaseWait.
///
/// In en, this message translates to:
/// **'Exporting... Please wait'**
String get exportingPleaseWait;
/// No description provided for @nothingToSaveYet.
///
/// In en, this message translates to:
/// **'Nothing to save yet'**
String get nothingToSaveYet;
/// Snackbar text showing where file saved
///
/// In en, this message translates to:
/// **'Saved: {path}'**
String savedWithPath(String path);
/// No description provided for @failedToSavePdf.
///
/// In en, this message translates to:
/// **'Failed to save PDF'**
String get failedToSavePdf;
/// No description provided for @downloadStarted.
///
/// In en, this message translates to:
/// **'Download started'**
String get downloadStarted;
/// No description provided for @failedToGeneratePdf.
///
/// In en, this message translates to:
/// **'Failed to generate PDF'**
String get failedToGeneratePdf;
/// No description provided for @invalidOrUnsupportedFile.
///
/// In en, this message translates to:
/// **'Invalid or unsupported file'**
String get invalidOrUnsupportedFile;
/// No description provided for @confirm.
///
/// In en, this message translates to:
/// **'Confirm'**
String get confirm;
/// No description provided for @undo.
///
/// In en, this message translates to:
/// **'Undo'**
String get undo;
/// No description provided for @clear.
///
/// In en, this message translates to:
/// **'Clear'**
String get clear;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'es', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified.
switch (locale.languageCode) {
case 'zh':
{
switch (locale.countryCode) {
case 'TW':
return AppLocalizationsZhTw();
}
break;
}
}
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'es':
return AppLocalizationsEs();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View File

@ -1,133 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'PDF Signature';
@override
String errorWithMessage(String message) {
return 'Error: $message';
}
@override
String get settings => 'Settings';
@override
String get theme => 'Theme';
@override
String get themeLight => 'Light';
@override
String get themeDark => 'Dark';
@override
String get themeSystem => 'System';
@override
String get language => 'Language';
@override
String get languageEnglish => 'English';
@override
String get languageChineseTraditional => 'Traditional Chinese';
@override
String get languageSpanish => 'Spanish';
@override
String get resetToDefaults => 'Reset to defaults';
@override
String get openPdf => 'Open PDF...';
@override
String get prev => 'Prev';
@override
String get next => 'Next';
@override
String pageInfo(int current, int total) {
return 'Page $current/$total';
}
@override
String get goTo => 'Go to:';
@override
String get dpi => 'DPI:';
@override
String get markForSigning => 'Mark for Signing';
@override
String get unmarkSigning => 'Unmark Signing';
@override
String get saveSignedPdf => 'Save Signed PDF';
@override
String get loadSignatureFromFile => 'Load Signature from file';
@override
String get drawSignature => 'Draw Signature';
@override
String get noPdfLoaded => 'No PDF loaded';
@override
String get signature => 'Signature';
@override
String get lockAspectRatio => 'Lock aspect ratio';
@override
String get backgroundRemoval => 'Background removal';
@override
String get contrast => 'Contrast';
@override
String get brightness => 'Brightness';
@override
String get exportingPleaseWait => 'Exporting... Please wait';
@override
String get nothingToSaveYet => 'Nothing to save yet';
@override
String savedWithPath(String path) {
return 'Saved: $path';
}
@override
String get failedToSavePdf => 'Failed to save PDF';
@override
String get downloadStarted => 'Download started';
@override
String get failedToGeneratePdf => 'Failed to generate PDF';
@override
String get invalidOrUnsupportedFile => 'Invalid or unsupported file';
@override
String get confirm => 'Confirm';
@override
String get undo => 'Undo';
@override
String get clear => 'Clear';
}

View File

@ -1,133 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Spanish Castilian (`es`).
class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
@override
String get appTitle => 'Firma PDF';
@override
String errorWithMessage(String message) {
return 'Error: $message';
}
@override
String get settings => 'Ajustes';
@override
String get theme => 'Tema';
@override
String get themeLight => 'Claro';
@override
String get themeDark => 'Oscuro';
@override
String get themeSystem => 'Del sistema';
@override
String get language => 'Idioma';
@override
String get languageEnglish => 'Inglés';
@override
String get languageChineseTraditional => 'Chino tradicional';
@override
String get languageSpanish => 'Español';
@override
String get resetToDefaults => 'Restablecer valores';
@override
String get openPdf => 'Abrir PDF…';
@override
String get prev => 'Anterior';
@override
String get next => 'Siguiente';
@override
String pageInfo(int current, int total) {
return 'Página $current/$total';
}
@override
String get goTo => 'Ir a:';
@override
String get dpi => 'DPI:';
@override
String get markForSigning => 'Marcar para firmar';
@override
String get unmarkSigning => 'Quitar marca';
@override
String get saveSignedPdf => 'Guardar PDF firmado';
@override
String get loadSignatureFromFile => 'Cargar firma desde archivo';
@override
String get drawSignature => 'Dibujar firma';
@override
String get noPdfLoaded => 'No hay PDF cargado';
@override
String get signature => 'Firma';
@override
String get lockAspectRatio => 'Bloquear relación de aspecto';
@override
String get backgroundRemoval => 'Eliminación de fondo';
@override
String get contrast => 'Contraste';
@override
String get brightness => 'Brillo';
@override
String get exportingPleaseWait => 'Exportando... Por favor espera';
@override
String get nothingToSaveYet => 'Nada que guardar todavía';
@override
String savedWithPath(String path) {
return 'Guardado: $path';
}
@override
String get failedToSavePdf => 'Error al guardar el PDF';
@override
String get downloadStarted => 'Descarga iniciada';
@override
String get failedToGeneratePdf => 'Error al generar el PDF';
@override
String get invalidOrUnsupportedFile => 'Archivo no válido o no compatible';
@override
String get confirm => 'Confirmar';
@override
String get undo => 'Deshacer';
@override
String get clear => 'Limpiar';
}

View File

@ -1,261 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'PDF 簽名';
@override
String errorWithMessage(String message) {
return '錯誤:$message';
}
@override
String get settings => '設定';
@override
String get theme => '主題';
@override
String get themeLight => '淺色';
@override
String get themeDark => '深色';
@override
String get themeSystem => '系統';
@override
String get language => '語言';
@override
String get languageEnglish => '英文';
@override
String get languageChineseTraditional => '繁體中文';
@override
String get languageSpanish => '西班牙文';
@override
String get resetToDefaults => '重設為預設值';
@override
String get openPdf => '開啟 PDF…';
@override
String get prev => '上一頁';
@override
String get next => '下一頁';
@override
String pageInfo(int current, int total) {
return '$current/$total';
}
@override
String get goTo => '前往:';
@override
String get dpi => 'DPI';
@override
String get markForSigning => '標記簽署';
@override
String get unmarkSigning => '取消標記';
@override
String get saveSignedPdf => '儲存已簽名 PDF';
@override
String get loadSignatureFromFile => '從檔案載入簽名';
@override
String get drawSignature => '手寫簽名';
@override
String get noPdfLoaded => '尚未載入 PDF';
@override
String get signature => '簽名';
@override
String get lockAspectRatio => '鎖定長寬比';
@override
String get backgroundRemoval => '去除背景';
@override
String get contrast => '對比';
@override
String get brightness => '亮度';
@override
String get exportingPleaseWait => '匯出中…請稍候';
@override
String get nothingToSaveYet => '尚無可儲存的內容';
@override
String savedWithPath(String path) {
return '已儲存:$path';
}
@override
String get failedToSavePdf => '儲存 PDF 失敗';
@override
String get downloadStarted => '已開始下載';
@override
String get failedToGeneratePdf => '產生 PDF 失敗';
@override
String get invalidOrUnsupportedFile => '無效或不支援的檔案';
@override
String get confirm => '確認';
@override
String get undo => '復原';
@override
String get clear => '清除';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
class AppLocalizationsZhTw extends AppLocalizationsZh {
AppLocalizationsZhTw() : super('zh_TW');
@override
String get appTitle => 'PDF 簽名';
@override
String errorWithMessage(String message) {
return '錯誤:$message';
}
@override
String get settings => '設定';
@override
String get theme => '主題';
@override
String get themeLight => '淺色';
@override
String get themeDark => '深色';
@override
String get themeSystem => '系統';
@override
String get language => '語言';
@override
String get languageEnglish => '英文';
@override
String get languageChineseTraditional => '繁體中文';
@override
String get languageSpanish => '西班牙文';
@override
String get resetToDefaults => '重設為預設值';
@override
String get openPdf => '開啟 PDF…';
@override
String get prev => '上一頁';
@override
String get next => '下一頁';
@override
String pageInfo(int current, int total) {
return '$current/$total';
}
@override
String get goTo => '前往:';
@override
String get dpi => 'DPI';
@override
String get markForSigning => '標記簽署';
@override
String get unmarkSigning => '取消標記';
@override
String get saveSignedPdf => '儲存已簽名 PDF';
@override
String get loadSignatureFromFile => '從檔案載入簽名';
@override
String get drawSignature => '手寫簽名';
@override
String get noPdfLoaded => '尚未載入 PDF';
@override
String get signature => '簽名';
@override
String get lockAspectRatio => '鎖定長寬比';
@override
String get backgroundRemoval => '去除背景';
@override
String get contrast => '對比';
@override
String get brightness => '亮度';
@override
String get exportingPleaseWait => '匯出中…請稍候';
@override
String get nothingToSaveYet => '尚無可儲存的內容';
@override
String savedWithPath(String path) {
return '已儲存:$path';
}
@override
String get failedToSavePdf => '儲存 PDF 失敗';
@override
String get downloadStarted => '已開始下載';
@override
String get failedToGeneratePdf => '產生 PDF 失敗';
@override
String get invalidOrUnsupportedFile => '無效或不支援的檔案';
@override
String get confirm => '確認';
@override
String get undo => '復原';
@override
String get clear => '清除';
}

View File

@ -1,46 +1,43 @@
{
"@@locale": "zh",
"appTitle": "PDF 簽名",
"errorWithMessage": "錯誤:{message}",
"settings": "設定",
"theme": "主題",
"themeLight": "淺色",
"themeDark": "深色",
"themeSystem": "系統",
"language": "語言",
"languageEnglish": "英文",
"languageChineseTraditional": "繁體中文",
"languageSpanish": "西班牙文",
"resetToDefaults": "重設為預設值",
"openPdf": "開啟 PDF…",
"prev": "上一頁",
"next": "下一頁",
"pageInfo": "第 {current}/{total} 頁",
"goTo": "前往:",
"dpi": "DPI",
"markForSigning": "標記簽署",
"unmarkSigning": "取消標記",
"saveSignedPdf": "儲存已簽名 PDF",
"loadSignatureFromFile": "從檔案載入簽名",
"drawSignature": "手寫簽名",
"noPdfLoaded": "尚未載入 PDF",
"signature": "簽名",
"lockAspectRatio": "鎖定長寬比",
"backgroundRemoval": "去除背景",
"contrast": "對比",
"brightness": "亮度",
"exportingPleaseWait": "匯出中…請稍候",
"nothingToSaveYet": "尚無可儲存的內容",
"savedWithPath": "已儲存:{path}",
"failedToSavePdf": "儲存 PDF 失敗",
"downloadStarted": "已開始下載",
"failedToGeneratePdf": "產生 PDF 失敗",
"invalidOrUnsupportedFile": "無效或不支援的檔案",
"clear": "清除",
"confirm": "確認",
"undo": "復原",
"clear": "清除"
}
"contrast": "對比",
"createNewSignature": "建立新簽名",
"delete": "刪除",
"downloadStarted": "已開始下載",
"dpi": "DPI",
"drawSignature": "手寫簽名",
"errorWithMessage": "錯誤:{message}",
"exportingPleaseWait": "匯出中…請稍候",
"failedToGeneratePdf": "產生 PDF 失敗",
"failedToSavePdf": "儲存 PDF 失敗",
"goTo": "前往:",
"invalidOrUnsupportedFile": "無效或不支援的檔案",
"language": "語言",
"languageChineseTraditional": "繁體中文",
"languageEnglish": "英文",
"languageSpanish": "西班牙文",
"loadSignatureFromFile": "從檔案載入簽名",
"lockAspectRatio": "鎖定長寬比",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁",
"noPdfLoaded": "尚未載入 PDF",
"nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁",
"prev": "上一頁",
"resetToDefaults": "重設為預設值",
"savedWithPath": "已儲存:{path}",
"saveSignedPdf": "儲存已簽名 PDF",
"settings": "設定",
"signature": "簽名",
"theme": "主題",
"themeDark": "深色",
"themeLight": "淺色",
"themeSystem": "系統",
"undo": "復原"
}

View File

@ -1,46 +1,43 @@
{
"@@locale": "zh_TW",
"appTitle": "PDF 簽名",
"errorWithMessage": "錯誤:{message}",
"settings": "設定",
"theme": "主題",
"themeLight": "淺色",
"themeDark": "深色",
"themeSystem": "系統",
"language": "語言",
"languageEnglish": "英文",
"languageChineseTraditional": "繁體中文",
"languageSpanish": "西班牙文",
"resetToDefaults": "重設為預設值",
"openPdf": "開啟 PDF…",
"prev": "上一頁",
"next": "下一頁",
"pageInfo": "第 {current}/{total} 頁",
"goTo": "前往:",
"dpi": "DPI",
"markForSigning": "標記簽署",
"unmarkSigning": "取消標記",
"saveSignedPdf": "儲存已簽名 PDF",
"loadSignatureFromFile": "從檔案載入簽名",
"drawSignature": "手寫簽名",
"noPdfLoaded": "尚未載入 PDF",
"signature": "簽名",
"lockAspectRatio": "鎖定長寬比",
"backgroundRemoval": "去除背景",
"contrast": "對比",
"brightness": "亮度",
"exportingPleaseWait": "匯出中…請稍候",
"nothingToSaveYet": "尚無可儲存的內容",
"savedWithPath": "已儲存:{path}",
"failedToSavePdf": "儲存 PDF 失敗",
"downloadStarted": "已開始下載",
"failedToGeneratePdf": "產生 PDF 失敗",
"invalidOrUnsupportedFile": "無效或不支援的檔案",
"clear": "清除",
"confirm": "確認",
"undo": "復原",
"clear": "清除"
}
"contrast": "對比",
"createNewSignature": "建立新簽名",
"delete": "刪除",
"downloadStarted": "已開始下載",
"dpi": "DPI",
"drawSignature": "手寫簽名",
"errorWithMessage": "錯誤:{message}",
"exportingPleaseWait": "匯出中…請稍候",
"failedToGeneratePdf": "產生 PDF 失敗",
"failedToSavePdf": "儲存 PDF 失敗",
"goTo": "前往:",
"invalidOrUnsupportedFile": "無效或不支援的檔案",
"language": "語言",
"languageChineseTraditional": "繁體中文",
"languageEnglish": "英文",
"languageSpanish": "西班牙文",
"loadSignatureFromFile": "從檔案載入簽名",
"lockAspectRatio": "鎖定長寬比",
"longPressOrRightClickTheSignatureToConfirmOrDelete": "長按或右鍵點擊簽名以確認或刪除。",
"next": "下一頁",
"noPdfLoaded": "尚未載入 PDF",
"nothingToSaveYet": "尚無可儲存的內容",
"openPdf": "開啟 PDF…",
"pageInfo": "第 {current}/{total} 頁",
"prev": "上一頁",
"resetToDefaults": "重設為預設值",
"savedWithPath": "已儲存:{path}",
"saveSignedPdf": "儲存已簽名 PDF",
"settings": "設定",
"signature": "簽名",
"theme": "主題",
"themeDark": "深色",
"themeLight": "淺色",
"themeSystem": "系統",
"undo": "復原"
}

View File

@ -16,9 +16,10 @@ class PdfController extends StateNotifier<PdfState> {
loaded: true,
pageCount: samplePageCount,
currentPage: 1,
markedForSigning: false,
pickedPdfPath: null,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
}
@ -31,28 +32,28 @@ class PdfController extends StateNotifier<PdfState> {
loaded: true,
pageCount: pageCount,
currentPage: 1,
markedForSigning: false,
pickedPdfPath: path,
pickedPdfBytes: bytes,
signedPage: null,
placementsByPage: {},
selectedPlacementIndex: null,
);
}
void jumpTo(int page) {
if (!state.loaded) return;
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(currentPage: clamped);
state = state.copyWith(currentPage: clamped, selectedPlacementIndex: null);
}
void toggleMark() {
// Set or clear the page that will receive the signature overlay.
void setSignedPage(int? page) {
if (!state.loaded) return;
if (state.signedPage != null) {
state = state.copyWith(markedForSigning: false, signedPage: null);
if (page == null) {
state = state.copyWith(signedPage: null, selectedPlacementIndex: null);
} else {
state = state.copyWith(
markedForSigning: true,
signedPage: state.currentPage,
);
final clamped = page.clamp(1, state.pageCount);
state = state.copyWith(signedPage: clamped, selectedPlacementIndex: null);
}
}
@ -60,6 +61,61 @@ class PdfController extends StateNotifier<PdfState> {
if (!state.loaded) return;
state = state.copyWith(pageCount: count.clamp(1, 9999));
}
// Multiple-signature helpers
void addPlacement({required int page, required Rect rect}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []);
list.add(rect);
map[p] = list;
state = state.copyWith(placementsByPage: map, selectedPlacementIndex: null);
}
void removePlacement({required int page, required int index}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<Rect>>.from(state.placementsByPage);
final list = List<Rect>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) {
list.removeAt(index);
if (list.isEmpty) {
map.remove(p);
} else {
map[p] = list;
}
state = state.copyWith(
placementsByPage: map,
selectedPlacementIndex: null,
);
}
}
List<Rect> placementsOn(int page) {
return List<Rect>.from(state.placementsByPage[page] ?? const []);
}
void selectPlacement(int? index) {
if (!state.loaded) return;
// Only allow valid index on current page; otherwise clear
if (index == null) {
state = state.copyWith(selectedPlacementIndex: null);
return;
}
final list = state.placementsByPage[state.currentPage] ?? const [];
if (index >= 0 && index < list.length) {
state = state.copyWith(selectedPlacementIndex: index);
} else {
state = state.copyWith(selectedPlacementIndex: null);
}
}
void deleteSelectedPlacement() {
final idx = state.selectedPlacementIndex;
if (idx == null) return;
removePlacement(page: state.currentPage, index: idx);
}
}
final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
@ -82,6 +138,7 @@ class SignatureController extends StateNotifier<SignatureState> {
width: w,
height: h,
),
editingEnabled: true,
);
}
@ -93,6 +150,7 @@ class SignatureController extends StateNotifier<SignatureState> {
width: w,
height: h,
),
editingEnabled: true,
);
}
@ -104,13 +162,13 @@ class SignatureController extends StateNotifier<SignatureState> {
}
void drag(Offset delta) {
if (state.rect == null) return;
if (state.rect == null || !state.editingEnabled) return;
final moved = state.rect!.shift(delta);
state = state.copyWith(rect: _clampRectToPage(moved));
}
void resize(Offset delta) {
if (state.rect == null) return;
if (state.rect == null || !state.editingEnabled) return;
final r = state.rect!;
double newW = r.width + delta.dx;
double newH = r.height + delta.dy;
@ -180,6 +238,7 @@ class SignatureController extends StateNotifier<SignatureState> {
width: 140,
height: 70,
),
editingEnabled: true,
);
}
@ -188,6 +247,27 @@ class SignatureController extends StateNotifier<SignatureState> {
if (state.rect == null) {
placeDefaultRect();
}
// Mark as draft/editable when user just loaded image
state = state.copyWith(editingEnabled: true);
}
// Confirm current signature: freeze editing and place it on the PDF as an immutable overlay.
// Returns the Rect placed, or null if no rect to confirm.
Rect? confirmCurrentSignature(WidgetRef ref) {
final r = state.rect;
if (r == null) return null;
// Place onto the current page
final pdf = ref.read(pdfProvider);
if (!pdf.loaded) return null;
ref.read(pdfProvider.notifier).addPlacement(page: pdf.currentPage, rect: r);
// Freeze editing: keep rect for preview but disable interaction
state = state.copyWith(editingEnabled: false);
return r;
}
// Remove the active overlay (draft or confirmed preview) but keep image settings intact
void clearActiveOverlay() {
state = state.copyWith(rect: null, editingEnabled: false);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart';
class AdjustmentsPanel extends ConsumerWidget {
const AdjustmentsPanel({super.key, required this.sig});
final SignatureState sig;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
key: const Key('adjustments_panel'),
children: [
Row(
children: [
Checkbox(
key: const Key('chk_aspect_lock'),
value: sig.aspectLocked,
onChanged:
(v) => ref
.read(signatureProvider.notifier)
.toggleAspect(v ?? false),
),
Text(AppLocalizations.of(context).lockAspectRatio),
const SizedBox(width: 16),
Switch(
key: const Key('swt_bg_removal'),
value: sig.bgRemoval,
onChanged:
(v) => ref.read(signatureProvider.notifier).setBgRemoval(v),
),
Text(AppLocalizations.of(context).backgroundRemoval),
],
),
Row(
children: [
Text(AppLocalizations.of(context).contrast),
Expanded(
child: Slider(
key: const Key('sld_contrast'),
min: 0.0,
max: 2.0,
value: sig.contrast,
onChanged:
(v) => ref.read(signatureProvider.notifier).setContrast(v),
),
),
Text(sig.contrast.toStringAsFixed(2)),
],
),
Row(
children: [
Text(AppLocalizations.of(context).brightness),
Expanded(
child: Slider(
key: const Key('sld_brightness'),
min: -1.0,
max: 1.0,
value: sig.brightness,
onChanged:
(v) =>
ref.read(signatureProvider.notifier).setBrightness(v),
),
),
Text(sig.brightness.toStringAsFixed(2)),
],
),
],
);
}
}

View File

@ -0,0 +1,373 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart';
import '../../../../data/model/model.dart';
import '../view_model/view_model.dart';
class PdfPageArea extends ConsumerWidget {
const PdfPageArea({
super.key,
required this.pageSize,
required this.onDragSignature,
required this.onResizeSignature,
required this.onConfirmSignature,
required this.onClearActiveOverlay,
required this.onSelectPlaced,
});
final Size pageSize;
final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature;
final VoidCallback onClearActiveOverlay;
final ValueChanged<int?> onSelectPlaced;
Future<void> _showContextMenuForPlaced({
required BuildContext context,
required WidgetRef ref,
required Offset globalPos,
required int index,
}) async {
onSelectPlaced(index);
final choice = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
globalPos.dx,
globalPos.dy,
globalPos.dx,
globalPos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_delete_signature'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (choice == 'delete') {
final currentPage = ref.read(pdfProvider).currentPage;
ref
.read(pdfProvider.notifier)
.removePlacement(page: currentPage, index: index);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider);
if (!pdf.loaded) {
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
}
final useMock = ref.watch(useMockViewerProvider);
if (useMock) {
return Center(
child: AspectRatio(
aspectRatio: pageSize.width / pageSize.height,
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)
: const SizedBox.shrink();
},
),
],
),
),
);
}
if (pdf.pickedPdfPath != null) {
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: 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)
: const SizedBox.shrink();
},
),
],
),
),
);
},
);
}
return const SizedBox.shrink();
}
Widget _buildPageOverlays(
BuildContext context,
WidgetRef ref,
SignatureState sig,
) {
final pdf = ref.watch(pdfProvider);
final current = pdf.currentPage;
final placed = pdf.placementsByPage[current] ?? const <Rect>[];
final widgets = <Widget>[];
for (int i = 0; i < placed.length; i++) {
final r = placed[i];
widgets.add(
_buildSignatureOverlay(
context,
ref,
sig,
r,
interactive: false,
placedIndex: i,
),
);
}
if (sig.rect != null &&
sig.editingEnabled &&
(pdf.signedPage == null || pdf.signedPage == current)) {
widgets.add(
_buildSignatureOverlay(context, ref, sig, sig.rect!, interactive: true),
);
}
return Stack(children: widgets);
}
Widget _buildSignatureOverlay(
BuildContext context,
WidgetRef ref,
SignatureState sig,
Rect r, {
bool interactive = true,
int? placedIndex,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / pageSize.width;
final scaleY = constraints.maxHeight / pageSize.height;
final left = r.left * scaleX;
final top = r.top * scaleY;
final width = r.width * scaleX;
final height = r.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: Builder(
builder: (context) {
final selectedIdx =
ref.read(pdfProvider).selectedPlacementIndex;
final bool isPlaced = placedIndex != null;
final bool isSelected =
isPlaced && selectedIdx == placedIndex;
final Color borderColor =
isPlaced ? Colors.red : Colors.indigo;
final double borderWidth =
isPlaced ? (isSelected ? 3.0 : 2.0) : 2.0;
Widget content = DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
child: Stack(
children: [
Consumer(
builder: (context, ref, _) {
final processed = ref.watch(
processedSignatureImageProvider,
);
final bytes = processed ?? sig.imageBytes;
if (bytes == null) {
return Center(
child: Text(
AppLocalizations.of(context).signature,
),
);
}
return Image.memory(bytes, fit: BoxFit.contain);
},
),
if (interactive)
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => onResizeSignature(
Offset(
d.delta.dx / scaleX,
d.delta.dy / scaleY,
),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
);
if (interactive && sig.editingEnabled) {
content = GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => onDragSignature(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
onSecondaryTapDown: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_active_confirm'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: Key('ctx_active_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature();
} else if (choice == 'delete') {
onClearActiveOverlay();
}
});
},
onLongPressStart: (d) {
final pos = d.globalPosition;
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
pos.dx,
pos.dy,
pos.dx,
pos.dy,
),
items: [
PopupMenuItem<String>(
key: Key('ctx_active_confirm_lp'),
value: 'confirm',
child: Text(AppLocalizations.of(context).confirm),
),
PopupMenuItem<String>(
key: Key('ctx_active_delete_lp'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
).then((choice) {
if (choice == 'confirm') {
onConfirmSignature();
} else if (choice == 'delete') {
onClearActiveOverlay();
}
});
},
child: content,
);
} else {
content = GestureDetector(
key: Key('placed_signature_${placedIndex ?? 'x'}'),
behavior: HitTestBehavior.opaque,
onTap: () => onSelectPlaced(placedIndex),
onSecondaryTapDown: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
context: context,
ref: ref,
globalPos: d.globalPosition,
index: placedIndex,
);
}
},
onLongPressStart: (d) {
if (placedIndex != null) {
_showContextMenuForPlaced(
context: context,
ref: ref,
globalPos: d.globalPosition,
index: placedIndex,
);
}
},
child: content,
);
}
return content;
},
),
),
],
);
},
);
}
}

View File

@ -1,17 +1,17 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:printing/printing.dart' as printing;
import '../../../../data/model/model.dart';
import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart';
import 'draw_canvas.dart';
import 'pdf_toolbar.dart';
import 'pdf_page_area.dart';
import 'adjustments_panel.dart';
import '../../preferences/widgets/settings_screen.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget {
@ -24,7 +24,6 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize;
final GlobalKey _captureKey = GlobalKey();
// Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting
@ -51,13 +50,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).jumpTo(page);
}
void _toggleMarkForSigning() {
ref.read(pdfProvider.notifier).toggleMark();
}
// mark-for-signing removed; no toggle needed
Future<void> _loadSignatureFromFile() async {
final pdf = ref.read(pdfProvider);
if (!pdf.markedForSigning) return;
final typeGroup = const fs.XTypeGroup(
label: 'Image',
extensions: ['png', 'jpg', 'jpeg', 'webp'],
@ -67,6 +62,38 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final bytes = await file.readAsBytes();
final sig = ref.read(signatureProvider.notifier);
sig.setImageBytes(bytes);
// When a signature is added, set the current page as signed.
final p = ref.read(pdfProvider);
if (p.loaded) {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
}
}
void _createNewSignature() {
// Create a movable signature (draft) that won't be exported until confirmed
final sig = ref.read(signatureProvider.notifier);
if (ref.read(pdfProvider).loaded) {
sig.placeDefaultRect();
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
// Hint: how to confirm/delete via context menu
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).longPressOrRightClickTheSignatureToConfirmOrDelete,
),
duration: Duration(seconds: 3),
),
);
}
}
void _confirmSignature() {
// Confirm: make current signature immutable and eligible for export by placing it
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
}
void _onDragSignature(Offset delta) {
@ -77,9 +104,11 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(signatureProvider.notifier).resize(delta);
}
void _onSelectPlaced(int? index) {
ref.read(pdfProvider.notifier).selectPlacement(index);
}
Future<void> _openDrawCanvas() async {
final pdf = ref.read(pdfProvider);
if (!pdf.markedForSigning) return;
final result = await showModalBottomSheet<Uint8List>(
context: context,
isScrollControlled: true,
@ -89,6 +118,11 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (result != null && result.isNotEmpty) {
// Use the drawn image as signature content
ref.read(signatureProvider.notifier).setImageBytes(result);
// Mark current page as signed when a signature is created
final p = ref.read(pdfProvider);
if (p.loaded) {
ref.read(pdfProvider.notifier).setSignedPage(p.currentPage);
}
}
}
@ -126,6 +160,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
if (bytes != null) {
@ -157,6 +192,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
if (useMock) {
@ -183,6 +219,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
}
@ -236,7 +273,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
@override
Widget build(BuildContext context) {
final pdf = ref.watch(pdfProvider);
final isExporting = ref.watch(exportingProvider);
final l = AppLocalizations.of(context);
return Scaffold(
@ -247,12 +283,36 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
children: [
Column(
children: [
_buildToolbar(pdf, disabled: isExporting),
PdfToolbar(
disabled: isExporting,
onOpenSettings: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
onPickPdf: _pickPdf,
onJumpToPage: _jumpToPage,
onSave: _saveSignedPdf,
onLoadSignatureFromFile: _loadSignatureFromFile,
onCreateSignature: _createNewSignature,
onOpenDrawCanvas: _openDrawCanvas,
),
const SizedBox(height: 8),
Expanded(
child: AbsorbPointer(
absorbing: isExporting,
child: _buildPageArea(pdf),
child: PdfPageArea(
pageSize: _pageSize,
onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature,
onClearActiveOverlay:
() =>
ref
.read(signatureProvider.notifier)
.clearActiveOverlay(),
onSelectPlaced: _onSelectPlaced,
),
),
),
Consumer(
@ -261,7 +321,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
return sig.rect != null
? AbsorbPointer(
absorbing: isExporting,
child: _buildAdjustmentsPanel(sig),
child: AdjustmentsPanel(sig: sig),
)
: const SizedBox.shrink();
},
@ -292,368 +352,4 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
),
);
}
Widget _buildToolbar(PdfState pdf, {bool disabled = false}) {
final dpi = ref.watch(exportDpiProvider);
final l = AppLocalizations.of(context);
final pageInfo = l.pageInfo(pdf.currentPage, pdf.pageCount);
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton(
key: const Key('btn_open_settings'),
onPressed:
disabled
? null
: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
child: Text(l.settings),
),
OutlinedButton(
key: const Key('btn_open_pdf_picker'),
onPressed: disabled ? null : _pickPdf,
child: Text(l.openPdf),
),
if (pdf.loaded) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const Key('btn_prev'),
onPressed:
disabled ? null : () => _jumpToPage(pdf.currentPage - 1),
icon: const Icon(Icons.chevron_left),
tooltip: l.prev,
),
Text(pageInfo, key: const Key('lbl_page_info')),
IconButton(
key: const Key('btn_next'),
onPressed:
disabled ? null : () => _jumpToPage(pdf.currentPage + 1),
icon: const Icon(Icons.chevron_right),
tooltip: l.next,
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.goTo),
SizedBox(
width: 60,
child: TextField(
key: const Key('txt_goto'),
keyboardType: TextInputType.number,
enabled: !disabled,
onSubmitted: (v) {
final n = int.tryParse(v);
if (n != null) _jumpToPage(n);
},
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.dpi),
const SizedBox(width: 8),
DropdownButton<double>(
key: const Key('ddl_export_dpi'),
value: dpi,
items:
const [96.0, 144.0, 200.0, 300.0]
.map(
(v) => DropdownMenuItem(
value: v,
child: Text(v.toStringAsFixed(0)),
),
)
.toList(),
onChanged:
disabled
? null
: (v) {
if (v != null) {
ref.read(exportDpiProvider.notifier).state = v;
}
},
),
],
),
ElevatedButton(
key: const Key('btn_mark_signing'),
onPressed: disabled ? null : _toggleMarkForSigning,
child: Text(
pdf.markedForSigning ? l.unmarkSigning : l.markForSigning,
),
),
if (pdf.loaded)
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: disabled ? null : _saveSignedPdf,
child: Text(l.saveSignedPdf),
),
if (pdf.markedForSigning) ...[
OutlinedButton(
key: const Key('btn_load_signature_picker'),
onPressed: disabled ? null : _loadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed: disabled ? null : _openDrawCanvas,
child: Text(l.drawSignature),
),
],
],
],
);
}
Widget _buildPageArea(PdfState pdf) {
if (!pdf.loaded) {
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
}
final useMock = ref.watch(useMockViewerProvider);
if (useMock) {
return Center(
child: AspectRatio(
aspectRatio: _pageSize.width / _pageSize.height,
child: RepaintBoundary(
key: _captureKey,
child: Stack(
key: const Key('page_stack'),
children: [
Container(
key: const Key('pdf_page'),
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 sig.rect != null && visible
? _buildSignatureOverlay(sig)
: const SizedBox.shrink();
},
),
],
),
),
),
);
}
// If a real PDF path is selected, show actual viewer. Otherwise, keep mock sample.
if (pdf.pickedPdfPath != null) {
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;
// Update page count in state if needed (post-frame to avoid build loop)
if (pdf.pageCount != pages.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(pdfProvider.notifier).setPageCount(pages.length);
}
});
}
return Center(
child: AspectRatio(
aspectRatio: aspect,
child: RepaintBoundary(
key: _captureKey,
child: Stack(
key: const Key('page_stack'),
children: [
PdfPageView(
document: document,
pageNumber: pageNum,
alignment: Alignment.center,
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
final visible = ref.watch(signatureVisibilityProvider);
return sig.rect != null && visible
? _buildSignatureOverlay(sig)
: const SizedBox.shrink();
},
),
],
),
),
),
);
},
);
}
// Fallback should not occur when not using mock; still return empty view
return const SizedBox.shrink();
}
Widget _buildSignatureOverlay(SignatureState sig) {
final r = sig.rect!;
return LayoutBuilder(
builder: (context, constraints) {
final scaleX = constraints.maxWidth / _pageSize.width;
final scaleY = constraints.maxHeight / _pageSize.height;
final left = r.left * scaleX;
final top = r.top * scaleY;
final width = r.width * scaleX;
final height = r.height * scaleY;
return Stack(
children: [
Positioned(
left: left,
top: top,
width: width,
height: height,
child: GestureDetector(
key: const Key('signature_overlay'),
behavior: HitTestBehavior.opaque,
onPanStart: (_) {},
onPanUpdate:
(d) => _onDragSignature(
Offset(d.delta.dx / scaleX, d.delta.dy / scaleY),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Color.fromRGBO(
0,
0,
0,
0.05 + math.min(0.25, (sig.contrast - 1.0).abs()),
),
border: Border.all(color: Colors.indigo, width: 2),
),
child: Stack(
children: [
Consumer(
builder: (context, ref, _) {
final processed = ref.watch(
processedSignatureImageProvider,
);
final bytes = processed ?? sig.imageBytes;
if (bytes == null) {
return Center(
child: Text(
AppLocalizations.of(context).signature,
),
);
}
return Image.memory(bytes, fit: BoxFit.contain);
},
),
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
key: const Key('signature_handle'),
behavior: HitTestBehavior.opaque,
onPanUpdate:
(d) => _onResizeSignature(
Offset(
d.delta.dx / scaleX,
d.delta.dy / scaleY,
),
),
child: const Icon(Icons.open_in_full, size: 20),
),
),
],
),
),
),
),
],
);
},
);
}
Widget _buildAdjustmentsPanel(SignatureState sig) {
return Column(
key: const Key('adjustments_panel'),
children: [
Row(
children: [
Checkbox(
key: const Key('chk_aspect_lock'),
value: sig.aspectLocked,
onChanged:
(v) => ref
.read(signatureProvider.notifier)
.toggleAspect(v ?? false),
),
Text(AppLocalizations.of(context).lockAspectRatio),
const SizedBox(width: 16),
Switch(
key: const Key('swt_bg_removal'),
value: sig.bgRemoval,
onChanged:
(v) => ref.read(signatureProvider.notifier).setBgRemoval(v),
),
Text(AppLocalizations.of(context).backgroundRemoval),
],
),
Row(
children: [
Text(AppLocalizations.of(context).contrast),
Expanded(
child: Slider(
key: const Key('sld_contrast'),
min: 0.0,
max: 2.0,
value: sig.contrast,
onChanged:
(v) => ref.read(signatureProvider.notifier).setContrast(v),
),
),
Text(sig.contrast.toStringAsFixed(2)),
],
),
Row(
children: [
Text(AppLocalizations.of(context).brightness),
Expanded(
child: Slider(
key: const Key('sld_brightness'),
min: -1.0,
max: 1.0,
value: sig.brightness,
onChanged:
(v) =>
ref.read(signatureProvider.notifier).setBrightness(v),
),
),
Text(sig.brightness.toStringAsFixed(2)),
],
),
],
);
}
}

View File

@ -0,0 +1,143 @@
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';
class PdfToolbar extends ConsumerWidget {
const PdfToolbar({
super.key,
required this.disabled,
required this.onOpenSettings,
required this.onPickPdf,
required this.onJumpToPage,
required this.onSave,
required this.onLoadSignatureFromFile,
required this.onCreateSignature,
required this.onOpenDrawCanvas,
});
final bool disabled;
final VoidCallback onOpenSettings;
final VoidCallback onPickPdf;
final ValueChanged<int> onJumpToPage;
final VoidCallback onSave;
final VoidCallback onLoadSignatureFromFile;
final VoidCallback onCreateSignature;
final VoidCallback onOpenDrawCanvas;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdf = ref.watch(pdfProvider);
final dpi = ref.watch(exportDpiProvider);
final l = AppLocalizations.of(context);
final pageInfo = l.pageInfo(pdf.currentPage, pdf.pageCount);
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton(
key: const Key('btn_open_settings'),
onPressed: disabled ? null : onOpenSettings,
child: Text(l.settings),
),
OutlinedButton(
key: const Key('btn_open_pdf_picker'),
onPressed: disabled ? null : onPickPdf,
child: Text(l.openPdf),
),
if (pdf.loaded) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const Key('btn_prev'),
onPressed:
disabled ? null : () => onJumpToPage(pdf.currentPage - 1),
icon: const Icon(Icons.chevron_left),
tooltip: l.prev,
),
Text(pageInfo, key: const Key('lbl_page_info')),
IconButton(
key: const Key('btn_next'),
onPressed:
disabled ? null : () => onJumpToPage(pdf.currentPage + 1),
icon: const Icon(Icons.chevron_right),
tooltip: l.next,
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.goTo),
SizedBox(
width: 60,
child: TextField(
key: const Key('txt_goto'),
keyboardType: TextInputType.number,
enabled: !disabled,
onSubmitted: (v) {
final n = int.tryParse(v);
if (n != null) onJumpToPage(n);
},
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.dpi),
const SizedBox(width: 8),
DropdownButton<double>(
key: const Key('ddl_export_dpi'),
value: dpi,
items:
const [96.0, 144.0, 200.0, 300.0]
.map(
(v) => DropdownMenuItem(
value: v,
child: Text(v.toStringAsFixed(0)),
),
)
.toList(),
onChanged:
disabled
? null
: (v) {
if (v != null) {
ref.read(exportDpiProvider.notifier).state = v;
}
},
),
],
),
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: disabled ? null : onSave,
child: Text(l.saveSignedPdf),
),
OutlinedButton(
key: const Key('btn_load_signature_picker'),
onPressed: disabled || !pdf.loaded ? null : onLoadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
OutlinedButton(
key: const Key('btn_create_signature'),
onPressed: disabled || !pdf.loaded ? null : onCreateSignature,
child: Text(l.createNewSignature),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed: disabled || !pdf.loaded ? null : onOpenDrawCanvas,
child: Text(l.drawSignature),
),
],
],
);
}
}

View File

@ -1,9 +1,5 @@
Feature: App preferences
As a user
I want to configure app preferences such as theme and language
So that the app matches my personal and regional needs, and remembers them next time
Scenario Outline: Choose a theme and apply it immediately
Given the settings screen is open
When the user selects the "<theme>" theme
@ -28,34 +24,3 @@ Feature: App preferences
| zh-TW |
| es |
Scenario Outline: Remember preferences across app restarts
Given the user previously set theme {"<theme>"} and language {"<language>"}
When the app launches
Then the app UI theme is {"<theme>"}
And the app language is {"<language>"}
Examples:
| theme | language |
| dark | en |
| light | zh-TW |
| system | es |
Scenario: Follow system appearance when theme is set to system
Given the user selects the "system" theme
And the OS appearance switches to dark mode
When the app is resumed or returns to foreground
Then the app UI updates to use the "dark" theme
Scenario: Reset preferences to defaults
Given the user has theme {"dark"} and language {"es"} saved
When the user taps "Reset to defaults"
Then the theme is set to {"system"}
And the language is set to the device locale
And both preferences are saved
Scenario: Ignore invalid stored values and fall back safely
Given stored preferences contain theme {"sepia"} and language {"xx"}
When the app launches
Then the theme falls back to {"system"}
And the language falls back to the device locale
And invalid values are replaced with valid defaults in storage

View File

@ -0,0 +1,13 @@
Feature: internationalizing
Scenario: Default language follows the device locale on first launch
When the app launches
Then the language is set to the device locale
Scenario: Invalid stored language falls back to the device locale
Given stored preferences contain theme {sepia} and language {xx}
When the app launches
Then the language falls back to the device locale
Scenario: Supported languages are available
Then the app supports languages {en, zh-TW, es}

View File

@ -0,0 +1,33 @@
Feature: remember preferences
Scenario Outline: Remember preferences across app restarts
Given the user previously set theme {"<theme>"} and language {"<language>"}
When the app launches
Then the app UI theme is {"<theme>"}
And the app language is {"<language>"}
Examples:
| theme | language |
| dark | en |
| light | zh-TW |
| system | es |
Scenario: Follow system appearance when theme is set to system
Given the user selects the "system" theme
And the OS appearance switches to dark mode
When the app is resumed or returns to foreground
Then the app UI updates to use the "dark" theme
Scenario: Reset preferences to defaults
Given the user has theme {"dark"} and language {"es"} saved
When the user taps "Reset to defaults"
Then the theme is set to {"system"}
And the language is set to the device locale
And both preferences are saved
Scenario: Ignore invalid stored values and fall back safely
Given stored preferences contain theme {"sepia"} and language {"xx"}
When the app launches
Then the theme falls back to {"system"}
And the language falls back to the device locale
And invalid values are replaced with valid defaults in storage

View File

@ -6,6 +6,14 @@ class _Token {
String get jpeg => '$base.jpeg';
String get webp => '$base.webp';
String get bmp => '$base.bmp';
// Allow combining tokens with a dash, e.g., zh - TW -> 'zh-TW'
_Token operator -(Object other) {
if (other is _Token) {
return _Token('$base-${other.base}');
}
return _Token(base);
}
@override
String toString() => base;
}
@ -14,3 +22,15 @@ class _Token {
const corrupted = _Token('corrupted');
const signature = _Token('signature');
const empty = _Token('empty');
// Preferences & i18n tokens used by generated tests
const light = _Token('light');
const dark = _Token('dark');
const system = _Token('system');
const en = _Token('en');
const es = _Token('es');
const zh = _Token('zh');
// ignore: constant_identifier_names
const TW = _Token('TW');
const theme = _Token('theme');
const language = _Token('language');

View File

@ -17,7 +17,7 @@ Future<void> aPdfIsOpenAndContainsAtLeastOnePlacedSignature(
pageCount: 2,
bytes: Uint8List.fromList([1, 2, 3]),
);
container.read(pdfProvider.notifier).toggleMark();
container.read(pdfProvider.notifier).setSignedPage(1);
container.read(signatureProvider.notifier).placeDefaultRect();
container
.read(signatureProvider.notifier)

View File

@ -0,0 +1,43 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a PDF is open and contains multiple placed signatures across pages
Future<void> aPdfIsOpenAndContainsMultiplePlacedSignaturesAcrossPages(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 6);
// Ensure signature image exists
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
// Place on two pages
container
.read(pdfProvider.notifier)
.addPlacement(page: 1, rect: const Rect.fromLTWH(10, 10, 80, 40));
container
.read(pdfProvider.notifier)
.addPlacement(page: 4, rect: const Rect.fromLTWH(120, 200, 100, 50));
// Keep backward compatibility with existing export step expectations
container.read(pdfProvider.notifier).setSignedPage(1);
container.read(signatureProvider.notifier).placeDefaultRect();
}
/// Usage: all placed signatures appear on their corresponding pages in the output
Future<void> allPlacedSignaturesAppearOnTheirCorrespondingPagesInTheOutput(
WidgetTester tester,
) async {
// In this logic-level test suite, we simply assert that placements exist
// on multiple pages and that a simulated export has bytes.
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(1), isNotEmpty);
expect(container.read(pdfProvider.notifier).placementsOn(4), isNotEmpty);
expect(TestWorld.lastExportBytes, isNotNull);
}

View File

@ -10,5 +10,5 @@ Future<void> aPdfPageIsSelectedForSigning(WidgetTester tester) async {
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 1);
container.read(pdfProvider.notifier).toggleMark();
container.read(pdfProvider.notifier).setSignedPage(1);
}

View File

@ -0,0 +1,14 @@
import 'dart:typed_data';
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: a signature image is loaded or drawn
Future<void> aSignatureImageIsLoadedOrDrawn(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
}

View File

@ -11,7 +11,7 @@ Future<void> aSignatureImageIsPlacedOnThePage(WidgetTester tester) async {
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 5);
container.read(pdfProvider.notifier).toggleMark();
container.read(pdfProvider.notifier).setSignedPage(1);
// Set an image to ensure rect exists
container
.read(signatureProvider.notifier)

View File

@ -11,7 +11,7 @@ Future<void> aSignatureImageIsSelected(WidgetTester tester) async {
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 2);
container.read(pdfProvider.notifier).toggleMark();
container.read(pdfProvider.notifier).setSignedPage(1);
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));

View File

@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a signature is placed on page 2
Future<void> aSignatureIsPlacedOnPage2(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 8);
container
.read(pdfProvider.notifier)
.addPlacement(page: 2, rect: const Rect.fromLTWH(50, 100, 80, 40));
}
/// Usage: the user navigates to page 5 and places another signature
Future<void> theUserNavigatesToPage5AndPlacesAnotherSignature(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
container.read(pdfProvider.notifier).jumpTo(5);
container
.read(pdfProvider.notifier)
.addPlacement(page: 5, rect: const Rect.fromLTWH(60, 120, 80, 40));
}
/// Usage: the signature on page 2 remains
Future<void> theSignatureOnPage2Remains(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(2), isNotEmpty);
}
/// Usage: the signature on page 5 is shown on page 5
Future<void> theSignatureOnPage5IsShownOnPage5(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
expect(container.read(pdfProvider.notifier).placementsOn(5), isNotEmpty);
}

View File

@ -18,7 +18,7 @@ Future<void> aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(
pageCount: 2,
bytes: Uint8List.fromList([1, 2, 3]),
);
container.read(pdfProvider.notifier).toggleMark();
container.read(pdfProvider.notifier).setSignedPage(1);
final r = Rect.fromLTWH(50, 100, 120, 60);
final sigN = container.read(signatureProvider.notifier);
sigN.placeDefaultRect();

View File

@ -0,0 +1,15 @@
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: both signatures are shown on their respective pages
Future<void> bothSignaturesAreShownOnTheirRespectivePages(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final p1 = container.read(pdfProvider.notifier).placementsOn(1);
final p3 = container.read(pdfProvider.notifier).placementsOn(3);
expect(p1, isNotEmpty);
expect(p3, isNotEmpty);
}

View File

@ -1,9 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I toggle mark
Future<void> iToggleMark(WidgetTester tester) async {
final c = TestWorld.container!;
c.read(pdfProvider.notifier).toggleMark();
// Feature removed; no-op for backward-compatible tests
TestWorld.container; // keep reference to avoid unused warnings
}

View File

@ -1,9 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf marked for signing is {false}
Future<void> pdfMarkedForSigningIs(WidgetTester tester, bool expected) async {
final c = TestWorld.container!;
expect(c.read(pdfProvider).markedForSigning, expected);
// Feature removed; assert expectation is false for backward compatibility
expect(expected, false);
}

View File

@ -4,10 +4,10 @@ import '_world.dart';
/// Usage: the app language is {"<language>"}
Future<void> theAppLanguageIs(
WidgetTester tester,
String param1,
dynamic language,
String languageWrapped,
) async {
final lang = language.toString();
expect(param1, '{${lang}}');
String unwrap(String s) =>
s.startsWith('{') && s.endsWith('}') ? s.substring(1, s.length - 1) : s;
final lang = unwrap(languageWrapped);
expect(TestWorld.currentLanguage, lang);
}

View File

@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
/// Usage: the app supports languages {en, zh-TW, es}
Future<void> theAppSupportsLanguages(
WidgetTester tester,
String languages,
) async {
// Normalize the example token string "{en, zh-TW, es}" into a set
final raw = languages.trim();
final inner =
raw.startsWith('{') && raw.endsWith('}')
? raw.substring(1, raw.length - 1)
: raw;
final expected = inner.split(',').map((s) => s.trim()).toSet();
// Keep this in sync with the app's supported locales
const actual = {'en', 'zh-TW', 'es'};
expect(actual, expected);
}

View File

@ -2,13 +2,10 @@ import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the app UI theme is {"<theme>"}
Future<void> theAppUiThemeIs(
WidgetTester tester,
String param1,
dynamic theme,
) async {
final t = theme.toString();
expect(param1, '{${t}}');
Future<void> theAppUiThemeIs(WidgetTester tester, String themeWrapped) async {
String unwrap(String s) =>
s.startsWith('{') && s.endsWith('}') ? s.substring(1, s.length - 1) : s;
final t = unwrap(themeWrapped);
if (t == 'system') {
// When checking for 'system', we validate that selectedTheme is system
expect(TestWorld.selectedTheme, 'system');

View File

@ -3,6 +3,10 @@ import '_world.dart';
/// Usage: the language is set to the device locale
Future<void> theLanguageIsSetToTheDeviceLocale(WidgetTester tester) async {
expect(TestWorld.prefs['language'], TestWorld.deviceLocale);
// On first launch there may be no stored preference yet; only the
// effective current language must match the device locale.
if (TestWorld.prefs['language'] != null) {
expect(TestWorld.prefs['language'], TestWorld.deviceLocale);
}
expect(TestWorld.currentLanguage, TestWorld.deviceLocale);
}

View File

@ -4,14 +4,12 @@ import '_world.dart';
/// Usage: the preference {language} is saved as {"<language>"}
Future<void> thePreferenceIsSavedAs(
WidgetTester tester,
dynamic param1,
String param2,
dynamic _value,
dynamic keyToken,
String valueWrapped,
) async {
final key = param1.toString();
final expectedTokenWrapped = param2; // like "{light}"
final expectedValue = _value.toString();
// Check token string matches braces-syntax just for parity
expect(expectedTokenWrapped, '{${expectedValue}}');
expect(TestWorld.prefs[key], expectedValue);
String unwrap(String s) =>
s.startsWith('{') && s.endsWith('}') ? s.substring(1, s.length - 1) : s;
final key = keyToken.toString();
final expected = unwrap(valueWrapped);
expect(TestWorld.prefs[key], expected);
}

View File

@ -0,0 +1,20 @@
import 'dart:typed_data';
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: the user navigates to page 3 and places another signature
Future<void> theUserNavigatesToPage3AndPlacesAnotherSignature(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container.read(pdfProvider.notifier).jumpTo(3);
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
container.read(signatureProvider.notifier).placeDefaultRect();
final rect = container.read(signatureProvider).rect!;
container.read(pdfProvider.notifier).addPlacement(page: 3, rect: rect);
}

View File

@ -0,0 +1,19 @@
import 'dart:typed_data';
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: the user places a signature on page 1
Future<void> theUserPlacesASignatureOnPage1(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
// Ensure image exists so placement is meaningful
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
// Place a default rect on page 1
container.read(signatureProvider.notifier).placeDefaultRect();
final rect = container.read(signatureProvider).rect!;
container.read(pdfProvider.notifier).addPlacement(page: 1, rect: rect);
}

View File

@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the user places it in multiple locations in the document
Future<void> theUserPlacesItInMultipleLocationsInTheDocument(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
final notifier = container.read(pdfProvider.notifier);
// Always open a fresh doc to avoid state bleed between scenarios
notifier.openPicked(path: 'mock.pdf', pageCount: 6);
// Place two on page 2 and one on page 4
notifier.addPlacement(page: 2, rect: const Rect.fromLTWH(10, 10, 80, 40));
notifier.addPlacement(page: 2, rect: const Rect.fromLTWH(120, 50, 80, 40));
notifier.addPlacement(page: 4, rect: const Rect.fromLTWH(20, 200, 100, 50));
}
/// Usage: identical signature instances appear in each location
Future<void> identicalSignatureInstancesAppearInEachLocation(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
final state = container.read(pdfProvider);
final p2 = state.placementsByPage[2] ?? const [];
final p4 = state.placementsByPage[4] ?? const [];
expect(p2.length, greaterThanOrEqualTo(2));
expect(p4.length, greaterThanOrEqualTo(1));
}
/// Usage: adjusting one instance does not affect the others
Future<void> adjustingOneInstanceDoesNotAffectTheOthers(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final before = container.read(pdfProvider.notifier).placementsOn(2);
expect(before.length, greaterThanOrEqualTo(2));
final modified = before[0].inflate(5);
container.read(pdfProvider.notifier).removePlacement(page: 2, index: 0);
container.read(pdfProvider.notifier).addPlacement(page: 2, rect: modified);
final after = container.read(pdfProvider.notifier).placementsOn(2);
expect(after.any((r) => r == before[1]), isTrue);
}

View File

@ -0,0 +1,57 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the user places two signatures on the same page
Future<void> theUserPlacesTwoSignaturesOnTheSamePage(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
// First
container.read(signatureProvider.notifier).placeDefaultRect();
final r1 = container.read(signatureProvider).rect!;
container.read(pdfProvider.notifier).addPlacement(page: 1, rect: r1);
// Second (offset a bit)
final r2 = r1.shift(const Offset(30, 30));
container.read(pdfProvider.notifier).addPlacement(page: 1, rect: r2);
}
/// Usage: each signature can be dragged and resized independently
Future<void> eachSignatureCanBeDraggedAndResizedIndependently(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, greaterThanOrEqualTo(2));
// Independence is modeled by distinct rects; ensure not equal and both within page
expect(list[0], isNot(equals(list[1])));
for (final r in list.take(2)) {
expect(r.left, greaterThanOrEqualTo(0));
expect(r.top, greaterThanOrEqualTo(0));
}
}
/// Usage: dragging or resizing one does not change the other
Future<void> draggingOrResizingOneDoesNotChangeTheOther(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, greaterThanOrEqualTo(2));
final before = List<Rect>.from(list.take(2));
// Simulate changing the first only
final changed = before[0].shift(const Offset(5, 5));
container.read(pdfProvider.notifier).removePlacement(page: 1, index: 0);
container.read(pdfProvider.notifier).addPlacement(page: 1, rect: changed);
final after = container.read(pdfProvider.notifier).placementsOn(1);
expect(after[0], isNot(equals(before[0])));
// The other remains the same (order may differ after remove/add, check set containment)
expect(after.any((r) => r == before[1]), isTrue);
}

View File

@ -4,15 +4,13 @@ import '_world.dart';
/// Usage: the user previously set theme {"<theme>"} and language {"<language>"}
Future<void> theUserPreviouslySetThemeAndLanguage(
WidgetTester tester,
String param1,
String param2,
dynamic theme,
dynamic language,
String themeWrapped,
String languageWrapped,
) async {
final t = theme.toString();
final lang = language.toString();
expect(param1, '{${t}}');
expect(param2, '{${lang}}');
String unwrap(String s) =>
s.startsWith('{') && s.endsWith('}') ? s.substring(1, s.length - 1) : s;
final t = unwrap(themeWrapped);
final lang = unwrap(languageWrapped);
// Simulate stored values
TestWorld.prefs['theme'] = t;
TestWorld.prefs['language'] = lang;

View File

@ -13,7 +13,7 @@ Future<void> theUserSelects(WidgetTester tester, dynamic file) async {
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 1);
container.read(pdfProvider.notifier).toggleMark();
container.read(pdfProvider.notifier).setSignedPage(1);
// For invalid/unsupported/empty selections we do NOT set image bytes.
// This simulates a failed load and keeps rect null.
final token = file.toString();

View File

@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: three signatures are placed on the current page
Future<void> threeSignaturesArePlacedOnTheCurrentPage(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(pdfProvider.notifier)
.openPicked(path: 'mock.pdf', pageCount: 5);
final n = container.read(pdfProvider.notifier);
n.addPlacement(page: 1, rect: const Rect.fromLTWH(10, 10, 80, 40));
n.addPlacement(page: 1, rect: const Rect.fromLTWH(100, 50, 80, 40));
n.addPlacement(page: 1, rect: const Rect.fromLTWH(200, 90, 80, 40));
}
/// Usage: the user deletes one selected signature
Future<void> theUserDeletesOneSelectedSignature(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
// Remove the middle one (index 1)
container.read(pdfProvider.notifier).removePlacement(page: 1, index: 1);
}
/// Usage: only the selected signature is removed
Future<void> onlyTheSelectedSignatureIsRemoved(WidgetTester tester) async {
final container = TestWorld.container ?? ProviderContainer();
final list = container.read(pdfProvider.notifier).placementsOn(1);
expect(list.length, 2);
expect(list[0].left, equals(10));
expect(list[1].left, equals(200));
}

View File

@ -0,0 +1,38 @@
Feature: support multiple signatures
Scenario: Place signatures on different pages
Given a multi-page PDF is open
When the user places a signature on page 1
And the user navigates to page 3 and places another signature
Then both signatures are shown on their respective pages
Scenario: Place multiple signatures on the same page independently
Given a PDF page is selected for signing
When the user places two signatures on the same page
Then each signature can be dragged and resized independently
And dragging or resizing one does not change the other
Scenario: Reuse the same signature asset in multiple locations
Given a signature image is loaded or drawn
When the user places it in multiple locations in the document
Then identical signature instances appear in each location
And adjusting one instance does not affect the others
Scenario: Remove one of many signatures
Given three signatures are placed on the current page
When the user deletes one selected signature
Then only the selected signature is removed
And the other signatures remain unchanged
Scenario: Keep earlier signatures while navigating between pages
Given a signature is placed on page 2
When the user navigates to page 5 and places another signature
Then the signature on page 2 remains
And the signature on page 5 is shown on page 5
Scenario: Save a document with multiple signatures across pages
Given a PDF is open and contains multiple placed signatures across pages
When the user saves/exports the document
Then all placed signatures appear on their corresponding pages in the output
And other page content remains unaltered

View File

@ -0,0 +1,65 @@
import 'dart:typed_data';
import 'dart:ui' show PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hand_signature/signature.dart' as hand;
import 'package:pdf_signature/ui/features/pdf/widgets/draw_canvas.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
void main() {
testWidgets('DrawCanvas exports non-empty bytes on confirm', (tester) async {
Uint8List? exported;
final sink = ValueNotifier<Uint8List?>(null);
final control = hand.HandSignatureControl();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: DrawCanvas(
control: control,
debugBytesSink: sink,
onConfirm: (bytes) {
exported = bytes;
},
),
),
),
);
await tester.pumpAndSettle();
// Draw a simple stroke inside the pad
final pad = find.byKey(const Key('hand_signature_pad'));
expect(pad, findsOneWidget);
final rect = tester.getRect(pad);
final g = await tester.startGesture(
Offset(rect.left + 20, rect.center.dy),
kind: PointerDeviceKind.touch,
);
for (int i = 0; i < 10; i++) {
await g.moveBy(
const Offset(12, 0),
timeStamp: Duration(milliseconds: 16 * (i + 1)),
);
await tester.pump(const Duration(milliseconds: 16));
}
await g.up();
await tester.pump(const Duration(milliseconds: 50));
// Confirm export
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
// Wait until notifier receives bytes
await tester.pumpAndSettle(const Duration(milliseconds: 50));
await tester.runAsync(() async {
final end = DateTime.now().add(const Duration(seconds: 2));
while (sink.value == null && DateTime.now().isBefore(end)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
});
exported ??= sink.value;
expect(exported, isNotNull);
expect(exported!.isNotEmpty, isTrue);
});
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.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() {
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: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PdfSignatureHomePage(),
),
),
);
await tester.pump();
// Trigger save directly (mark toggle no longer required)
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// Expect success UI
expect(find.textContaining('Saved:'), findsOneWidget);
});
}

49
test/widget/helpers.dart Normal file
View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/data/services/providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
useMockViewerProvider.overrideWith((ref) => true),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PdfSignatureHomePage(),
),
),
);
await tester.pump();
}
Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PdfSignatureHomePage(),
),
),
);
await tester.pump();
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
void main() {
testWidgets('Open a PDF and navigate pages', (tester) async {
await pumpWithOpenPdf(tester);
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect(pageInfo, findsOneWidget);
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
await tester.tap(find.byKey(const Key('btn_next')));
await tester.pump();
expect((tester.widget<Text>(pageInfo)).data, 'Page 2/5');
await tester.tap(find.byKey(const Key('btn_prev')));
await tester.pump();
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
});
testWidgets('Jump to a specific page', (tester) async {
await pumpWithOpenPdf(tester);
final goto = find.byKey(const Key('txt_goto'));
await tester.enterText(goto, '4');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect((tester.widget<Text>(pageInfo)).data, 'Page 4/5');
});
}

View File

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/data/model/model.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
class _TestPdfController extends PdfController {
_TestPdfController() : super() {
// Start with a loaded multi-page doc, page 1 of 5
state = PdfState.initial().copyWith(
loaded: true,
pageCount: 5,
currentPage: 1,
);
}
}
void main() {
testWidgets('PDF navigation: prev/next and goto update page label', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
useMockViewerProvider.overrideWithValue(true),
pdfProvider.overrideWith((ref) => _TestPdfController()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const PdfSignatureHomePage(),
),
),
);
// Initial label and page view key
expect(find.byKey(const Key('lbl_page_info')), findsOneWidget);
Text label() => tester.widget<Text>(find.byKey(const Key('lbl_page_info')));
expect(label().data, equals('Page 1/5'));
expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget);
// Next
await tester.tap(find.byKey(const Key('btn_next')));
await tester.pumpAndSettle();
expect(label().data, equals('Page 2/5'));
expect(find.byKey(const ValueKey('pdf_page_view_2')), findsOneWidget);
// Prev
await tester.tap(find.byKey(const Key('btn_prev')));
await tester.pumpAndSettle();
expect(label().data, equals('Page 1/5'));
expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget);
// Goto specific page
await tester.tap(find.byKey(const Key('txt_goto')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('txt_goto')), '4');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(label().data, equals('Page 4/5'));
expect(find.byKey(const ValueKey('pdf_page_view_4')), findsOneWidget);
// Goto beyond upper bound -> clamp to 5
await tester.tap(find.byKey(const Key('txt_goto')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('txt_goto')), '999');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(label().data, equals('Page 5/5'));
expect(find.byKey(const ValueKey('pdf_page_view_5')), findsOneWidget);
// Goto below 1 -> clamp to 1
await tester.tap(find.byKey(const Key('txt_goto')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('txt_goto')), '0');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(label().data, equals('Page 1/5'));
expect(find.byKey(const ValueKey('pdf_page_view_1')), findsOneWidget);
});
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
void main() {
testWidgets('Resize and move signature within page bounds', (tester) async {
await pumpWithOpenPdfAndSig(tester);
final overlay = find.byKey(const Key('signature_overlay'));
expect(overlay, findsOneWidget);
final posBefore = tester.getTopLeft(overlay);
// drag the overlay
await tester.drag(overlay, const Offset(30, -20));
await tester.pump();
final posAfter = tester.getTopLeft(overlay);
// Allow equality in case clamped at edges
expect(posAfter.dx >= posBefore.dx, isTrue);
expect(posAfter.dy <= posBefore.dy, isTrue);
// resize via handle
final handle = find.byKey(const Key('signature_handle'));
final sizeBefore = tester.getSize(overlay);
await tester.drag(handle, const Offset(40, 40));
await tester.pump();
final sizeAfter = tester.getSize(overlay);
expect(sizeAfter.width >= sizeBefore.width, isTrue);
expect(sizeAfter.height >= sizeBefore.height, isTrue);
});
testWidgets('Lock aspect ratio while resizing', (tester) async {
await pumpWithOpenPdfAndSig(tester);
final overlay = find.byKey(const Key('signature_overlay'));
final sizeBefore = tester.getSize(overlay);
final aspect = sizeBefore.width / sizeBefore.height;
await tester.tap(find.byKey(const Key('chk_aspect_lock')));
await tester.pump();
await tester.drag(
find.byKey(const Key('signature_handle')),
const Offset(60, 10),
);
await tester.pump();
final sizeAfter = tester.getSize(overlay);
final newAspect = (sizeAfter.width / sizeAfter.height);
expect((newAspect - aspect).abs() < 0.15, isTrue);
});
testWidgets('Background removal and adjustments controls change state', (
tester,
) async {
await pumpWithOpenPdfAndSig(tester);
// toggle bg removal
await tester.tap(find.byKey(const Key('swt_bg_removal')));
await tester.pump();
// move sliders
await tester.drag(
find.byKey(const Key('sld_contrast')),
const Offset(50, 0),
);
await tester.drag(
find.byKey(const Key('sld_brightness')),
const Offset(-50, 0),
);
await tester.pump();
// basic smoke: overlay still present
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
}

View File

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
void main() {
testWidgets('Show invalid/unsupported file SnackBar via test hook', (
tester,
) async {
await pumpWithOpenPdf(tester);
final dynamic state =
tester.state(find.byType(PdfSignatureHomePage)) as dynamic;
state.debugShowInvalidSignatureSnackBar();
await tester.pump();
expect(find.text('Invalid or unsupported file'), findsOneWidget);
});
}

View File

@ -1,313 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:typed_data';
import 'dart:ui' show PointerDeviceKind;
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/data/services/providers.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/draw_canvas.dart';
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:hand_signature/signature.dart' as hand;
// Fakes for export service (top-level; Dart does not allow local class declarations)
class RecordingExporter extends ExportService {
bool called = false;
}
class BasicExporter extends ExportService {}
void main() {
Future<void> pumpWithOpenPdf(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
useMockViewerProvider.overrideWith((ref) => true),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
}
Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
}
testWidgets('Open a PDF and navigate pages', (tester) async {
await pumpWithOpenPdf(tester);
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect(pageInfo, findsOneWidget);
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
await tester.tap(find.byKey(const Key('btn_next')));
await tester.pump();
expect((tester.widget<Text>(pageInfo)).data, 'Page 2/5');
await tester.tap(find.byKey(const Key('btn_prev')));
await tester.pump();
expect((tester.widget<Text>(pageInfo)).data, 'Page 1/5');
});
testWidgets('Jump to a specific page', (tester) async {
await pumpWithOpenPdf(tester);
final goto = find.byKey(const Key('txt_goto'));
await tester.enterText(goto, '4');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
final pageInfo = find.byKey(const Key('lbl_page_info'));
expect((tester.widget<Text>(pageInfo)).data, 'Page 4/5');
});
testWidgets('Select a page for signing', (tester) async {
await pumpWithOpenPdf(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// signature actions appear (picker-based now)
expect(find.byKey(const Key('btn_load_signature_picker')), findsOneWidget);
});
testWidgets('Show invalid/unsupported file SnackBar via test hook', (
tester,
) async {
await pumpWithOpenPdf(tester);
final dynamic state =
tester.state(find.byType(PdfSignatureHomePage)) as dynamic;
state.debugShowInvalidSignatureSnackBar();
await tester.pump();
expect(find.text('Invalid or unsupported file'), findsOneWidget);
});
testWidgets('Import a signature image', (tester) async {
await pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// overlay present from provider override
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
// Removed: Load Invalid button is not part of normal app UI.
testWidgets('Resize and move signature within page bounds', (tester) async {
await pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
final overlay = find.byKey(const Key('signature_overlay'));
final posBefore = tester.getTopLeft(overlay);
// drag the overlay
await tester.drag(overlay, const Offset(30, -20));
await tester.pump();
final posAfter = tester.getTopLeft(overlay);
// Allow equality in case clamped at edges
expect(posAfter.dx >= posBefore.dx, isTrue);
expect(posAfter.dy <= posBefore.dy, isTrue);
// resize via handle
final handle = find.byKey(const Key('signature_handle'));
final sizeBefore = tester.getSize(overlay);
await tester.drag(handle, const Offset(40, 40));
await tester.pump();
final sizeAfter = tester.getSize(overlay);
expect(sizeAfter.width >= sizeBefore.width, isTrue);
expect(sizeAfter.height >= sizeBefore.height, isTrue);
});
testWidgets('Lock aspect ratio while resizing', (tester) async {
await pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
final overlay = find.byKey(const Key('signature_overlay'));
final sizeBefore = tester.getSize(overlay);
final aspect = sizeBefore.width / sizeBefore.height;
await tester.tap(find.byKey(const Key('chk_aspect_lock')));
await tester.pump();
await tester.drag(
find.byKey(const Key('signature_handle')),
const Offset(60, 10),
);
await tester.pump();
final sizeAfter = tester.getSize(overlay);
final newAspect = (sizeAfter.width / sizeAfter.height);
expect(
(newAspect - aspect).abs() < 0.15,
isTrue,
); // approximately preserved
});
testWidgets('Background removal and adjustments controls change state', (
tester,
) async {
await pumpWithOpenPdfAndSig(tester);
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// toggle bg removal
await tester.tap(find.byKey(const Key('swt_bg_removal')));
await tester.pump();
// move sliders
await tester.drag(
find.byKey(const Key('sld_contrast')),
const Offset(50, 0),
);
await tester.drag(
find.byKey(const Key('sld_brightness')),
const Offset(-50, 0),
);
await tester.pump();
// basic smoke: overlay still present
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
testWidgets('DrawCanvas exports non-empty bytes on confirm', (tester) async {
Uint8List? exported;
final sink = ValueNotifier<Uint8List?>(null);
final control = hand.HandSignatureControl();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DrawCanvas(
control: control,
debugBytesSink: sink,
onConfirm: (bytes) {
exported = bytes;
},
),
),
),
);
await tester.pumpAndSettle();
// Draw a simple stroke inside the pad
final pad = find.byKey(const Key('hand_signature_pad'));
expect(pad, findsOneWidget);
final rect = tester.getRect(pad);
final g = await tester.startGesture(
Offset(rect.left + 20, rect.center.dy),
kind: PointerDeviceKind.touch,
);
for (int i = 0; i < 10; i++) {
await g.moveBy(
const Offset(12, 0),
timeStamp: Duration(milliseconds: 16 * (i + 1)),
);
await tester.pump(const Duration(milliseconds: 16));
}
await g.up();
await tester.pump(const Duration(milliseconds: 50));
// Confirm export
await tester.tap(find.byKey(const Key('btn_canvas_confirm')));
// Wait until notifier receives bytes
await tester.pumpAndSettle(const Duration(milliseconds: 50));
await tester.runAsync(() async {
final end = DateTime.now().add(const Duration(seconds: 2));
while (sink.value == null && DateTime.now().isBefore(end)) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
});
exported ??= sink.value;
expect(exported, isNotNull);
expect(exported!.isNotEmpty, isTrue);
});
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(home: PdfSignatureHomePage()),
),
);
await tester.pump();
// Mark signing to set signedPage
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// Trigger save
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// With refactor, we no longer call boundary-based export here; still expect success UI.
expect(find.textContaining('Saved:'), findsOneWidget);
});
testWidgets('Only signed page shows overlay during export flow', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pdfProvider.overrideWith(
(ref) => PdfController()..openPicked(path: 'test.pdf'),
),
signatureProvider.overrideWith(
(ref) => SignatureController()..placeDefaultRect(),
),
useMockViewerProvider.overrideWith((ref) => true),
exportServiceProvider.overrideWith((_) => BasicExporter()),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output.pdf',
),
],
child: const MaterialApp(home: PdfSignatureHomePage()),
),
);
await tester.pump();
// Mark signing on page 1
await tester.tap(find.byKey(const Key('btn_mark_signing')));
await tester.pump();
// Save -> open dialog -> confirm
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// After export, overlay visible again
expect(find.byKey(const Key('signature_overlay')), findsOneWidget);
});
}