Compare commits

...

7 Commits

135 changed files with 2514 additions and 919 deletions

1
.gitignore vendored
View File

@ -122,3 +122,4 @@ docs/.*
.vscode/tasks.json
.vscode/launch.json
devtools_options.yaml
test/features/*_test.dart

View File

@ -9,8 +9,11 @@ checkout [`docs/FRs.md`](docs/FRs.md)
## Build
```bash
# flutter clean
flutter pub get
# flutter run build_runner build --delete-conflicting-outputs
# generate gherkin test
flutter pub run build_runner build --delete-conflicting-outputs
# dart run tool/prune_unused_steps.dart --delete
# run the app
flutter run
@ -19,4 +22,6 @@ flutter run
flutter test
flutter build
# create windows installer
flutter pub run msix:create
```

View File

@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

8
build.yaml Normal file
View File

@ -0,0 +1,8 @@
targets:
$default:
sources:
- integration_test/**
- test/**
- lib/**
- $package$
builders:

View File

@ -26,3 +26,11 @@
* role: user
* functionality: save/export the signed PDF document
* benefit: easily keep a copy of the signed document for records.
* name: preferences for app
* role: user
* functionality: configure app preferences such as `theme`, `language`.
* benefit: customize the app experience to better fit user needs
* name: remember preferences
* role: user
* functionality: remember user preferences for future sessions
* benefit: provide a consistent and personalized experience

4
docs/NFRs.md Normal file
View File

@ -0,0 +1,4 @@
# Non-Functional Requirements
* Package structure
* plz follow official [Package structure](https://docs.flutter.dev/app-architecture/case-study#package-structure) with a slight modification, put each `<FEATURE NAME>/`s in `features/` sub-directory under `ui/`.

6
l10n.yaml Normal file
View File

@ -0,0 +1,6 @@
arb-dir: lib/l10n
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

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'ui/features/preferences/providers.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@ -8,12 +10,57 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp(
title: 'PDF Signature',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const PdfSignatureHomePage(),
child: Consumer(
builder: (context, ref, _) {
// Ensure SharedPreferences loaded before building MaterialApp
final sp = ref.watch(sharedPreferencesProvider);
return sp.when(
loading: () => const SizedBox.shrink(),
error:
(e, st) => MaterialApp(
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates:
AppLocalizations.localizationsDelegates,
home: Builder(
builder:
(ctx) => Scaffold(
body: Center(
child: Text(
AppLocalizations.of(
ctx,
).errorWithMessage(e.toString()),
),
),
),
),
),
data: (_) {
final themeMode = ref.watch(themeModeProvider);
final appLocale = ref.watch(localeProvider);
return MaterialApp(
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
),
themeMode: themeMode,
locale: appLocale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const PdfSignatureHomePage(),
);
},
);
},
),
);
}

91
lib/data/model/model.dart Normal file
View File

@ -0,0 +1,91 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
class PdfState {
final bool loaded;
final int pageCount;
final int currentPage;
final bool markedForSigning;
final String? pickedPdfPath;
final Uint8List? pickedPdfBytes;
final int? signedPage;
const PdfState({
required this.loaded,
required this.pageCount,
required this.currentPage,
required this.markedForSigning,
this.pickedPdfPath,
this.pickedPdfBytes,
this.signedPage,
});
factory PdfState.initial() => const PdfState(
loaded: false,
pageCount: 0,
currentPage: 1,
markedForSigning: false,
pickedPdfBytes: null,
signedPage: null,
);
PdfState copyWith({
bool? loaded,
int? pageCount,
int? currentPage,
bool? markedForSigning,
String? pickedPdfPath,
Uint8List? pickedPdfBytes,
int? signedPage,
}) => 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,
);
}
class SignatureState {
final Rect? rect;
final bool aspectLocked;
final bool bgRemoval;
final double contrast;
final double brightness;
final List<List<Offset>> strokes;
final Uint8List? imageBytes;
const SignatureState({
required this.rect,
required this.aspectLocked,
required this.bgRemoval,
required this.contrast,
required this.brightness,
required this.strokes,
this.imageBytes,
});
factory SignatureState.initial() => const SignatureState(
rect: null,
aspectLocked: false,
bgRemoval: false,
contrast: 1.0,
brightness: 0.0,
strokes: [],
imageBytes: null,
);
SignatureState copyWith({
Rect? rect,
bool? aspectLocked,
bool? bgRemoval,
double? contrast,
double? brightness,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
}) => SignatureState(
rect: rect ?? this.rect,
aspectLocked: aspectLocked ?? this.aspectLocked,
bgRemoval: bgRemoval ?? this.bgRemoval,
contrast: contrast ?? this.contrast,
brightness: brightness ?? this.brightness,
strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes,
);
}

View File

@ -34,9 +34,9 @@ class ExportService {
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
print(
'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath',
);
// print(
// 'exportSignedPdfFromFile: enter signedPage=$signedPage outputPath=$outputPath',
// );
// Read source bytes and delegate to bytes-based exporter
Uint8List? srcBytes;
try {
@ -160,10 +160,20 @@ class ExportService {
signatureImageBytes.isNotEmpty;
if (shouldStamp) {
try {
final decoded = img.decodeImage(signatureImageBytes);
if (decoded != null) {
final jpg = img.encodeJpg(decoded, quality: 90);
sigImgObj = pw.MemoryImage(Uint8List.fromList(jpg));
// If it's already PNG, keep as-is to preserve alpha; otherwise decode/encode PNG
final asStr = String.fromCharCodes(signatureImageBytes.take(8));
final isPng =
signatureImageBytes.length > 8 &&
signatureImageBytes[0] == 0x89 &&
asStr.startsWith('\u0089PNG');
if (isPng) {
sigImgObj = pw.MemoryImage(signatureImageBytes);
} else {
final decoded = img.decodeImage(signatureImageBytes);
if (decoded != null) {
final png = img.encodePng(decoded, level: 6);
sigImgObj = pw.MemoryImage(Uint8List.fromList(png));
}
}
} catch (_) {}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart' as pp;
import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/data/services/export_service.dart';
// Feature-scoped DI and configuration providers
// Toggle mock viewer (used by tests to show a gray placeholder instead of real PDF pages)
final useMockViewerProvider = Provider<bool>((_) => false);
// Export service injection for testability
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
// Export DPI setting (points per inch mapping), default 144 DPI
final exportDpiProvider = StateProvider<double>((_) => 144.0);
// Controls whether signature overlay is visible (used to hide on non-stamped pages during export)
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
// Global exporting state to show loading UI and block interactions while saving/exporting
final exportingProvider = StateProvider<bool>((_) => false);
// Save path picker (injected for tests)
final savePathPickerProvider = Provider<Future<String?> Function()>((ref) {
return () async {
String? initialDir;
try {
final d = await pp.getDownloadsDirectory();
initialDir = d?.path;
} catch (_) {}
if (initialDir == null) {
try {
final d = await pp.getApplicationDocumentsDirectory();
initialDir = d.path;
} catch (_) {}
}
final location = await fs.getSaveLocation(
suggestedName: 'signed.pdf',
acceptedTypeGroups: [
const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']),
],
initialDirectory: initialDir,
);
if (location == null) return null;
final path = location.path;
return path.toLowerCase().endsWith('.pdf') ? path : '$path.pdf';
};
});

61
lib/l10n/app_en.arb Normal file
View File

@ -0,0 +1,61 @@
{
"@@locale": "en",
"appTitle": "PDF Signature",
"errorWithMessage": "Error: {message}",
"@errorWithMessage": {
"description": "Generic error text with message",
"placeholders": {"message": {"type": "String"}}
},
"settings": "Settings",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"language": "Language",
"languageEnglish": "English",
"languageChineseTraditional": "Traditional Chinese",
"languageSpanish": "Spanish",
"resetToDefaults": "Reset to defaults",
"openPdf": "Open PDF...",
"prev": "Prev",
"next": "Next",
"pageInfo": "Page {current}/{total}",
"@pageInfo": {
"description": "Label showing current page and total",
"placeholders": {
"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",
"savedWithPath": "Saved: {path}",
"@savedWithPath": {
"description": "Snackbar text showing where file saved",
"placeholders": {"path": {"type": "String"}}
},
"failedToSavePdf": "Failed to save PDF",
"downloadStarted": "Download started",
"failedToGeneratePdf": "Failed to generate PDF",
"invalidOrUnsupportedFile": "Invalid or unsupported file",
"confirm": "Confirm",
"undo": "Undo",
"clear": "Clear"
}

46
lib/l10n/app_es.arb Normal file
View File

@ -0,0 +1,46 @@
{
"@@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",
"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",
"confirm": "Confirmar",
"undo": "Deshacer",
"clear": "Limpiar"
}

View File

@ -0,0 +1,385 @@
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

@ -0,0 +1,133 @@
// 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

@ -0,0 +1,133 @@
// 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

@ -0,0 +1,261 @@
// 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 => '清除';
}

46
lib/l10n/app_zh.arb Normal file
View File

@ -0,0 +1,46 @@
{
"@@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": "無效或不支援的檔案",
"confirm": "確認",
"undo": "復原",
"clear": "清除"
}

46
lib/l10n/app_zh_TW.arb Normal file
View File

@ -0,0 +1,46 @@
{
"@@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": "無效或不支援的檔案",
"confirm": "確認",
"undo": "復原",
"clear": "清除"
}

View File

@ -1,52 +1,16 @@
part of 'viewer.dart';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:image/image.dart' as img;
class PdfState {
final bool loaded;
final int pageCount;
final int currentPage;
final bool markedForSigning;
final String? pickedPdfPath;
final Uint8List? pickedPdfBytes;
final int? signedPage;
const PdfState({
required this.loaded,
required this.pageCount,
required this.currentPage,
required this.markedForSigning,
this.pickedPdfPath,
this.pickedPdfBytes,
this.signedPage,
});
factory PdfState.initial() => const PdfState(
loaded: false,
pageCount: 0,
currentPage: 1,
markedForSigning: false,
pickedPdfBytes: null,
signedPage: null,
);
PdfState copyWith({
bool? loaded,
int? pageCount,
int? currentPage,
bool? markedForSigning,
String? pickedPdfPath,
Uint8List? pickedPdfBytes,
int? signedPage,
}) => 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,
);
}
import '../../../../data/model/model.dart';
class PdfController extends StateNotifier<PdfState> {
PdfController() : super(PdfState.initial());
static const int samplePageCount = 5;
void openSample() {
state = state.copyWith(
loaded: true,
@ -102,51 +66,6 @@ final pdfProvider = StateNotifierProvider<PdfController, PdfState>(
(ref) => PdfController(),
);
class SignatureState {
final Rect? rect;
final bool aspectLocked;
final bool bgRemoval;
final double contrast;
final double brightness;
final List<List<Offset>> strokes;
final Uint8List? imageBytes;
const SignatureState({
required this.rect,
required this.aspectLocked,
required this.bgRemoval,
required this.contrast,
required this.brightness,
required this.strokes,
this.imageBytes,
});
factory SignatureState.initial() => const SignatureState(
rect: null,
aspectLocked: false,
bgRemoval: false,
contrast: 1.0,
brightness: 0.0,
strokes: [],
imageBytes: null,
);
SignatureState copyWith({
Rect? rect,
bool? aspectLocked,
bool? bgRemoval,
double? contrast,
double? brightness,
List<List<Offset>>? strokes,
Uint8List? imageBytes,
}) => SignatureState(
rect: rect ?? this.rect,
aspectLocked: aspectLocked ?? this.aspectLocked,
bgRemoval: bgRemoval ?? this.bgRemoval,
contrast: contrast ?? this.contrast,
brightness: brightness ?? this.brightness,
strokes: strokes ?? this.strokes,
imageBytes: imageBytes ?? this.imageBytes,
);
}
class SignatureController extends StateNotifier<SignatureState> {
SignatureController() : super(SignatureState.initial());
static const Size pageSize = Size(400, 560);
@ -178,9 +97,10 @@ class SignatureController extends StateNotifier<SignatureState> {
}
void setInvalidSelected(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid or unsupported file')),
);
final l = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.invalidOrUnsupportedFile)));
}
void drag(Offset delta) {
@ -275,3 +195,74 @@ final signatureProvider =
StateNotifierProvider<SignatureController, SignatureState>(
(ref) => SignatureController(),
);
/// Derived provider that returns processed signature image bytes according to
/// current adjustment settings (contrast/brightness) and background removal.
/// Returns null if no image is loaded. The output is a PNG to preserve alpha.
final processedSignatureImageProvider = Provider<Uint8List?>((ref) {
final s = ref.watch(signatureProvider);
final bytes = s.imageBytes;
if (bytes == null || bytes.isEmpty) return null;
// Decode (supports PNG/JPEG, etc.)
final decoded = img.decodeImage(bytes);
if (decoded == null) return bytes;
// Work on a copy and ensure an alpha channel is present (RGBA)
var out = decoded.clone();
if (out.hasPalette || !out.hasAlpha) {
// Force truecolor RGBA image so per-pixel alpha writes take effect
out = out.convert(numChannels: 4);
}
// Parameters
final double contrast = s.contrast; // [0..2], 1 = neutral
final double brightness = s.brightness; // [-1..1], 0 = neutral
const int thrLow = 220; // begin soft transparency from this avg luminance
const int thrHigh = 245; // fully transparent from this avg luminance
// Helper to clamp int
int clamp255(num v) => v.clamp(0, 255).toInt();
// Iterate pixels
for (int y = 0; y < out.height; y++) {
for (int x = 0; x < out.width; x++) {
final p = out.getPixel(x, y);
int a = clamp255(p.aNormalized * 255.0);
int r = clamp255(p.rNormalized * 255.0);
int g = clamp255(p.gNormalized * 255.0);
int b = clamp255(p.bNormalized * 255.0);
// Apply contrast/brightness in sRGB space
// new = (old-128)*contrast + 128 + brightness*255
final double brOffset = brightness * 255.0;
r = clamp255((r - 128) * contrast + 128 + brOffset);
g = clamp255((g - 128) * contrast + 128 + brOffset);
b = clamp255((b - 128) * contrast + 128 + brOffset);
// Near-white background removal (compute average luminance)
final int avg = ((r + g + b) / 3).round();
int remAlpha = 255; // 255 = fully opaque, 0 = transparent
if (s.bgRemoval) {
if (avg >= thrHigh) {
remAlpha = 0;
} else if (avg >= thrLow) {
// Soft fade between thrLow..thrHigh
final double t = (avg - thrLow) / (thrHigh - thrLow);
remAlpha = clamp255(255 * (1.0 - t));
} else {
remAlpha = 255;
}
}
// Combine with existing alpha (preserve existing transparency)
final newA = math.min(a, remAlpha);
out.setPixelRgba(x, y, r, g, b, newA);
}
}
// Encode as PNG to preserve transparency
final png = img.encodePng(out, level: 6);
return Uint8List.fromList(png);
});

View File

@ -1,4 +1,8 @@
part of 'viewer.dart';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:hand_signature/signature.dart' as hand;
class DrawCanvas extends StatefulWidget {
const DrawCanvas({
@ -32,6 +36,7 @@ class _DrawCanvasState extends State<DrawCanvas> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
@ -52,11 +57,8 @@ class _DrawCanvasState extends State<DrawCanvas> {
height: 512,
);
final bytes = data?.buffer.asUint8List();
// print("onPressed, Exported signature bytes: ${bytes?.length}");
// Notify tests if provided
widget.debugBytesSink?.value = bytes;
if (widget.onConfirm != null) {
// print("onConfirm callback called");
widget.onConfirm!(bytes);
} else {
if (context.mounted) {
@ -64,19 +66,19 @@ class _DrawCanvasState extends State<DrawCanvas> {
}
}
},
child: const Text('Confirm'),
child: Text(l.confirm),
),
const SizedBox(width: 8),
OutlinedButton(
key: const Key('btn_canvas_undo'),
onPressed: () => _control.stepBack(),
child: const Text('Undo'),
child: Text(l.undo),
),
const SizedBox(width: 8),
OutlinedButton(
key: const Key('btn_canvas_clear'),
onPressed: () => _control.clear(),
child: const Text('Clear'),
child: Text(l.clear),
),
],
),

View File

@ -1,51 +1,18 @@
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:path_provider/path_provider.dart' as pp;
import 'dart:typed_data';
import '../share/export_service.dart';
import 'package:hand_signature/signature.dart' as hand;
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;
part 'viewer_state.dart';
part 'viewer_widgets.dart';
final useMockViewerProvider = Provider<bool>((_) => false);
// Export service injection for testability
final exportServiceProvider = Provider<ExportService>((_) => ExportService());
// Export DPI setting (points per inch mapping), default 144 DPI
final exportDpiProvider = StateProvider<double>((_) => 144.0);
// Controls whether signature overlay is visible (used to hide on non-stamped pages during export)
final signatureVisibilityProvider = StateProvider<bool>((_) => true);
// Save path picker (injected for tests)
final savePathPickerProvider = Provider<Future<String?> Function()>((ref) {
return () async {
String? initialDir;
try {
final d = await pp.getDownloadsDirectory();
initialDir = d?.path;
} catch (_) {}
if (initialDir == null) {
try {
final d = await pp.getApplicationDocumentsDirectory();
initialDir = d.path;
} catch (_) {}
}
final location = await fs.getSaveLocation(
suggestedName: 'signed.pdf',
acceptedTypeGroups: [
const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']),
],
initialDirectory: initialDir,
);
if (location == null) return null;
final path = location.path;
return path.toLowerCase().endsWith('.pdf') ? path : '$path.pdf';
};
});
import '../../../../data/model/model.dart';
import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart';
import 'draw_canvas.dart';
import '../../preferences/widgets/settings_screen.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key});
@ -102,8 +69,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
sig.setImageBytes(bytes);
}
// removed invalid loader; not part of normal app
void _onDragSignature(Offset delta) {
ref.read(signatureProvider.notifier).drag(delta);
}
@ -128,119 +93,142 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
Future<void> _saveSignedPdf() async {
final pdf = ref.read(pdfProvider);
final sig = ref.read(signatureProvider);
if (!pdf.loaded || sig.rect == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nothing to save yet'),
), // guard per use-case
);
return;
}
final exporter = ref.read(exportServiceProvider);
final targetDpi = ref.read(exportDpiProvider);
final useMock = ref.read(useMockViewerProvider);
bool ok = false;
String? savedPath;
if (kIsWeb) {
// Web: prefer using picked bytes; share via Printing
Uint8List? src = pdf.pickedPdfBytes;
if (src == null) {
ok = false;
} else {
final bytes = await exporter.exportSignedPdfFromBytes(
srcBytes: src,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: sig.imageBytes,
targetDpi: targetDpi,
// Set exporting state to show loading overlay and block interactions
ref.read(exportingProvider.notifier).state = true;
try {
final pdf = ref.read(pdfProvider);
final sig = ref.read(signatureProvider);
// Cache messenger before any awaits to avoid using BuildContext across async gaps.
final messenger = ScaffoldMessenger.of(context);
if (!pdf.loaded || sig.rect == null) {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).nothingToSaveYet),
), // guard per use-case
);
if (bytes != null) {
try {
await printing.Printing.sharePdf(
bytes: bytes,
filename: 'signed.pdf',
);
ok = true;
} catch (_) {
return;
}
final exporter = ref.read(exportServiceProvider);
final targetDpi = ref.read(exportDpiProvider);
final useMock = ref.read(useMockViewerProvider);
bool ok = false;
String? savedPath;
if (kIsWeb) {
// Web: prefer using picked bytes; share via Printing
Uint8List? src = pdf.pickedPdfBytes;
if (src == null) {
ok = false;
} else {
final processed = ref.read(processedSignatureImageProvider);
final bytes = await exporter.exportSignedPdfFromBytes(
srcBytes: src,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
targetDpi: targetDpi,
);
if (bytes != null) {
try {
await printing.Printing.sharePdf(
bytes: bytes,
filename: 'signed.pdf',
);
ok = true;
} catch (_) {
ok = false;
}
} else {
ok = false;
}
}
} else {
// Desktop/mobile: choose between bytes or file-based export
final pick = ref.read(savePathPickerProvider);
final path = await pick();
if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim());
savedPath = fullPath;
if (pdf.pickedPdfBytes != null) {
final processed = ref.read(processedSignatureImageProvider);
final out = await exporter.exportSignedPdfFromBytes(
srcBytes: pdf.pickedPdfBytes!,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
targetDpi: targetDpi,
);
if (useMock) {
// In mock mode for tests, simulate success without file IO
ok = out != null;
} else if (out != null) {
ok = await exporter.saveBytesToFile(
bytes: out,
outputPath: fullPath,
);
} else {
ok = false;
}
} else if (pdf.pickedPdfPath != null) {
if (useMock) {
// Simulate success in mock
ok = true;
} else {
final processed = ref.read(processedSignatureImageProvider);
ok = await exporter.exportSignedPdfFromFile(
inputPath: pdf.pickedPdfPath!,
outputPath: fullPath,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: processed ?? sig.imageBytes,
targetDpi: targetDpi,
);
}
} else {
ok = false;
}
}
} else {
// Desktop/mobile: choose between bytes or file-based export
final pick = ref.read(savePathPickerProvider);
final path = await pick();
if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim());
savedPath = fullPath;
if (pdf.pickedPdfBytes != null) {
final out = await exporter.exportSignedPdfFromBytes(
srcBytes: pdf.pickedPdfBytes!,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: sig.imageBytes,
targetDpi: targetDpi,
);
if (useMock) {
// In mock mode for tests, simulate success without file IO
ok = out != null;
} else if (out != null) {
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
if (!kIsWeb) {
// Desktop/mobile: we had a concrete path
if (ok) {
messenger.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).savedWithPath(savedPath ?? ''),
),
),
);
} else {
ok = false;
}
} else if (pdf.pickedPdfPath != null) {
if (useMock) {
// Simulate success in mock
ok = true;
} else {
ok = await exporter.exportSignedPdfFromFile(
inputPath: pdf.pickedPdfPath!,
outputPath: fullPath,
signedPage: pdf.signedPage,
signatureRectUi: sig.rect,
uiPageSize: SignatureController.pageSize,
signatureImageBytes: sig.imageBytes,
targetDpi: targetDpi,
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).failedToSavePdf),
),
);
}
} else {
ok = false;
}
}
if (!kIsWeb) {
// Desktop/mobile: we had a concrete path
if (ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Saved: ${savedPath ?? ''}')));
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to save PDF')));
}
} else {
// Web: indicate whether we triggered a download dialog
if (ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Download started')));
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to generate PDF')));
// Web: indicate whether we triggered a download dialog
if (ok) {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).downloadStarted),
),
);
} else {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).failedToGeneratePdf),
),
);
}
}
} finally {
// Clear exporting state when finished or on error
ref.read(exportingProvider.notifier).state = false;
}
}
// Removed manual full-path dialog; using file_selector.getSaveLocation via provider
String _ensurePdfExtension(String name) {
if (!name.toLowerCase().endsWith('.pdf')) return '$name.pdf';
return name;
@ -249,41 +237,87 @@ 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(
appBar: AppBar(title: const Text('PDF Signature')),
appBar: AppBar(title: Text(l.appTitle)),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
child: Stack(
children: [
_buildToolbar(pdf),
const SizedBox(height: 8),
Expanded(child: _buildPageArea(pdf)),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
? _buildAdjustmentsPanel(sig)
: const SizedBox.shrink();
},
Column(
children: [
_buildToolbar(pdf, disabled: isExporting),
const SizedBox(height: 8),
Expanded(
child: AbsorbPointer(
absorbing: isExporting,
child: _buildPageArea(pdf),
),
),
Consumer(
builder: (context, ref, _) {
final sig = ref.watch(signatureProvider);
return sig.rect != null
? AbsorbPointer(
absorbing: isExporting,
child: _buildAdjustmentsPanel(sig),
)
: const SizedBox.shrink();
},
),
],
),
if (isExporting)
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text(
l.exportingPleaseWait,
style: const TextStyle(color: Colors.white),
),
],
),
),
),
),
],
),
),
);
}
Widget _buildToolbar(PdfState pdf) {
Widget _buildToolbar(PdfState pdf, {bool disabled = false}) {
final dpi = ref.watch(exportDpiProvider);
final pageInfo = 'Page ${pdf.currentPage}/${pdf.pageCount}';
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: _pickPdf,
child: const Text('Open PDF...'),
onPressed: disabled ? null : _pickPdf,
child: Text(l.openPdf),
),
if (pdf.loaded) ...[
Row(
@ -291,28 +325,31 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
children: [
IconButton(
key: const Key('btn_prev'),
onPressed: () => _jumpToPage(pdf.currentPage - 1),
onPressed:
disabled ? null : () => _jumpToPage(pdf.currentPage - 1),
icon: const Icon(Icons.chevron_left),
tooltip: 'Prev',
tooltip: l.prev,
),
Text(pageInfo, key: const Key('lbl_page_info')),
IconButton(
key: const Key('btn_next'),
onPressed: () => _jumpToPage(pdf.currentPage + 1),
onPressed:
disabled ? null : () => _jumpToPage(pdf.currentPage + 1),
icon: const Icon(Icons.chevron_right),
tooltip: 'Next',
tooltip: l.next,
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Go to:'),
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);
@ -324,7 +361,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('DPI:'),
Text(l.dpi),
const SizedBox(width: 8),
DropdownButton<double>(
key: const Key('ddl_export_dpi'),
@ -338,37 +375,40 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
),
)
.toList(),
onChanged: (v) {
if (v != null) {
ref.read(exportDpiProvider.notifier).state = v;
}
},
onChanged:
disabled
? null
: (v) {
if (v != null) {
ref.read(exportDpiProvider.notifier).state = v;
}
},
),
],
),
ElevatedButton(
key: const Key('btn_mark_signing'),
onPressed: _toggleMarkForSigning,
onPressed: disabled ? null : _toggleMarkForSigning,
child: Text(
pdf.markedForSigning ? 'Unmark Signing' : 'Mark for Signing',
pdf.markedForSigning ? l.unmarkSigning : l.markForSigning,
),
),
if (pdf.loaded)
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: _saveSignedPdf,
child: const Text('Save Signed PDF'),
onPressed: disabled ? null : _saveSignedPdf,
child: Text(l.saveSignedPdf),
),
if (pdf.markedForSigning) ...[
OutlinedButton(
key: const Key('btn_load_signature_picker'),
onPressed: _loadSignatureFromFile,
child: const Text('Load Signature from file'),
onPressed: disabled ? null : _loadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed: _openDrawCanvas,
child: const Text('Draw Signature'),
onPressed: disabled ? null : _openDrawCanvas,
child: Text(l.drawSignature),
),
],
],
@ -378,7 +418,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Widget _buildPageArea(PdfState pdf) {
if (!pdf.loaded) {
return const Center(child: Text('No PDF loaded'));
return Center(child: Text(AppLocalizations.of(context).noPdfLoaded));
}
final useMock = ref.watch(useMockViewerProvider);
if (useMock) {
@ -395,7 +435,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
color: Colors.grey.shade200,
child: Center(
child: Text(
'Page ${pdf.currentPage}/${pdf.pageCount}',
AppLocalizations.of(
context,
).pageInfo(pdf.currentPage, pdf.pageCount),
style: const TextStyle(
fontSize: 24,
color: Colors.black54,
@ -500,17 +542,32 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withOpacity(
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: [
if (sig.imageBytes != null)
Image.memory(sig.imageBytes!, fit: BoxFit.contain)
else
const Center(child: Text('Signature')),
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,
@ -552,7 +609,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
.read(signatureProvider.notifier)
.toggleAspect(v ?? false),
),
const Text('Lock aspect ratio'),
Text(AppLocalizations.of(context).lockAspectRatio),
const SizedBox(width: 16),
Switch(
key: const Key('swt_bg_removal'),
@ -560,12 +617,12 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
onChanged:
(v) => ref.read(signatureProvider.notifier).setBgRemoval(v),
),
const Text('Background removal'),
Text(AppLocalizations.of(context).backgroundRemoval),
],
),
Row(
children: [
const Text('Contrast'),
Text(AppLocalizations.of(context).contrast),
Expanded(
child: Slider(
key: const Key('sld_contrast'),
@ -581,7 +638,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
),
Row(
children: [
const Text('Brightness'),
Text(AppLocalizations.of(context).brightness),
Expanded(
child: Slider(
key: const Key('sld_brightness'),

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Simple supported locales
const supportedLocales = <Locale>[
Locale('en'),
Locale('zh', 'TW'),
Locale('es'),
];
// Keys
const _kTheme = 'theme'; // 'light'|'dark'|'system'
const _kLanguage = 'language'; // 'en'|'zh-TW'|'es'
String _normalizeLanguageTag(String tag) {
final parts = tag.split('-');
if (parts.isEmpty) return 'en';
final primary = parts[0].toLowerCase();
if (primary == 'en') return 'en';
if (primary == 'es') return 'es';
if (primary == 'zh') {
final region = parts.length > 1 ? parts[1].toUpperCase() : '';
if (region == 'TW') return 'zh-TW';
// other zh regions not supported; fall back to English
return 'en';
}
// Fallback default
return 'en';
}
class PreferencesState {
final String theme; // 'light' | 'dark' | 'system'
final String language; // 'en' | 'zh-TW' | 'es'
const PreferencesState({required this.theme, required this.language});
PreferencesState copyWith({String? theme, String? language}) =>
PreferencesState(
theme: theme ?? this.theme,
language: language ?? this.language,
);
}
class PreferencesNotifier extends StateNotifier<PreferencesState> {
final SharedPreferences prefs;
PreferencesNotifier(this.prefs)
: super(
PreferencesState(
theme: prefs.getString(_kTheme) ?? 'system',
language: _normalizeLanguageTag(
prefs.getString(_kLanguage) ??
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
),
) {
// normalize language to supported/fallback
_ensureValid();
}
void _ensureValid() {
final themeValid = {'light', 'dark', 'system'};
if (!themeValid.contains(state.theme)) {
state = state.copyWith(theme: 'system');
prefs.setString(_kTheme, 'system');
}
final normalized = _normalizeLanguageTag(state.language);
if (normalized != state.language) {
state = state.copyWith(language: normalized);
prefs.setString(_kLanguage, normalized);
}
}
Future<void> setTheme(String theme) async {
final valid = {'light', 'dark', 'system'};
if (!valid.contains(theme)) return;
state = state.copyWith(theme: theme);
await prefs.setString(_kTheme, theme);
}
Future<void> setLanguage(String language) async {
final normalized = _normalizeLanguageTag(language);
state = state.copyWith(language: normalized);
await prefs.setString(_kLanguage, normalized);
}
Future<void> resetToDefaults() async {
final device = WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag();
final normalized = _normalizeLanguageTag(device);
state = PreferencesState(theme: 'system', language: normalized);
await prefs.setString(_kTheme, 'system');
await prefs.setString(_kLanguage, normalized);
}
}
final sharedPreferencesProvider = FutureProvider<SharedPreferences>((
ref,
) async {
final p = await SharedPreferences.getInstance();
return p;
});
final preferencesProvider =
StateNotifierProvider<PreferencesNotifier, PreferencesState>((ref) {
// In tests, you can override sharedPreferencesProvider
final prefs = ref
.watch(sharedPreferencesProvider)
.maybeWhen(
data: (p) => p,
orElse: () => throw StateError('SharedPreferences not ready'),
);
return PreferencesNotifier(prefs);
});
/// Derive the active ThemeMode based on preference and platform brightness
final themeModeProvider = Provider<ThemeMode>((ref) {
final prefs = ref.watch(preferencesProvider);
switch (prefs.theme) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
case 'system':
default:
return ThemeMode.system;
}
});
Locale _parseLanguageTag(String tag) {
// 'zh-TW' -> ('zh','TW')
final parts = tag.split('-');
if (parts.length == 2) return Locale(parts[0], parts[1]);
return Locale(parts[0]);
}
final localeProvider = Provider<Locale?>((ref) {
final prefs = ref.watch(preferencesProvider);
// Return explicit Locale for supported ones; if not supported, null to follow device
final supported = {'en', 'zh-TW', 'es'};
if (supported.contains(prefs.language)) {
return _parseLanguageTag(prefs.language);
}
return null;
});

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../providers.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(preferencesProvider);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l.settings)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.theme, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
DropdownButton<String>(
key: const Key('ddl_theme'),
value: prefs.theme,
items: [
DropdownMenuItem(value: 'light', child: Text(l.themeLight)),
DropdownMenuItem(value: 'dark', child: Text(l.themeDark)),
DropdownMenuItem(value: 'system', child: Text(l.themeSystem)),
],
onChanged:
(v) =>
v == null
? null
: ref.read(preferencesProvider.notifier).setTheme(v),
),
const SizedBox(height: 16),
Text(
l.language,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
DropdownButton<String>(
key: const Key('ddl_language'),
value: prefs.language,
items: [
DropdownMenuItem(value: 'en', child: Text(l.languageEnglish)),
DropdownMenuItem(
value: 'zh-TW',
child: Text(l.languageChineseTraditional),
),
DropdownMenuItem(value: 'es', child: Text(l.languageSpanish)),
],
onChanged:
(v) =>
v == null
? null
: ref
.read(preferencesProvider.notifier)
.setLanguage(v),
),
const Spacer(),
Align(
alignment: Alignment.bottomRight,
child: OutlinedButton(
key: const Key('btn_reset_defaults'),
onPressed:
() =>
ref
.read(preferencesProvider.notifier)
.resetToDefaults(),
child: Text(l.resetToDefaults),
),
),
],
),
),
);
}
}

View File

@ -39,31 +39,41 @@ dependencies:
flutter_dotenv: ^6.0.0
file_selector: ^1.0.3
path_provider: ^2.1.5
pdfrx: ^1.3.5
pdfrx: ^2.1.9
pdf: ^3.10.8
hand_signature: ^3.1.0+2
image: ^4.2.0
printing: ^5.14.2
result_dart: ^2.1.1
go_router: ^16.2.0
flutter_localizations:
sdk: flutter
intl: any
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.12
build: ^3.0.2
bdd_widget_test: ^2.0.1
custom_lint: ^0.7.6
riverpod_lint: ^2.6.5
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
flutter_lints: ^6.0.0
msix: ^3.16.12
json_serializable: ^6.11.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
generate: true
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in

View File

@ -7,7 +7,7 @@ import 'package:image/image.dart' as img;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf_signature/features/share/export_service.dart';
import 'package:pdf_signature/data/services/export_service.dart';
void main() {
test(

View File

@ -0,0 +1,61 @@
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
Then the app UI updates to use the "<theme>" theme
And the preference {theme} is saved as {"<theme>"}
Examples:
| theme |
| light |
| dark |
| system |
Scenario Outline: Choose a language and apply it immediately
Given the settings screen is open
When the user selects a supported language "<language>"
Then all visible texts are displayed in "<language>"
And the preference {language} is saved as {"<language>"}
Examples:
| language |
| en |
| 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

@ -1,38 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/an_empty_signature_canvas.dart';
import './step/the_user_draws_strokes_and_confirms.dart';
import './step/a_signature_image_is_created.dart';
import './step/it_is_placed_on_the_selected_page.dart';
import './step/a_drawn_signature_exists_in_the_canvas.dart';
import './step/the_user_clears_the_canvas.dart';
import './step/the_canvas_becomes_blank.dart';
import './step/multiple_strokes_were_drawn.dart';
import './step/the_user_chooses_undo.dart';
import './step/the_last_stroke_is_removed.dart';
void main() {
group('''draw signature''', () {
testWidgets('''Draw with mouse or touch and place on page''',
(tester) async {
await anEmptySignatureCanvas(tester);
await theUserDrawsStrokesAndConfirms(tester);
await aSignatureImageIsCreated(tester);
await itIsPlacedOnTheSelectedPage(tester);
});
testWidgets('''Clear and redraw''', (tester) async {
await aDrawnSignatureExistsInTheCanvas(tester);
await theUserClearsTheCanvas(tester);
await theCanvasBecomesBlank(tester);
});
testWidgets('''Undo the last stroke''', (tester) async {
await multipleStrokesWereDrawn(tester);
await theUserChoosesUndo(tester);
await theLastStrokeIsRemoved(tester);
});
});
}

View File

@ -1,30 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_signature_image_is_placed_on_the_page.dart';
import './step/the_user_drags_handles_to_resize_and_drags_to_reposition.dart';
import './step/the_size_and_position_update_in_real_time.dart';
import './step/the_signature_remains_within_the_page_area.dart';
import './step/a_signature_image_is_selected.dart';
import './step/the_user_enables_aspect_ratio_lock_and_resizes.dart';
import './step/the_image_scales_proportionally.dart';
void main() {
group('''geometrically adjust signature picture''', () {
testWidgets('''Resize and move the signature within page bounds''',
(tester) async {
await aSignatureImageIsPlacedOnThePage(tester);
await theUserDragsHandlesToResizeAndDragsToReposition(tester);
await theSizeAndPositionUpdateInRealTime(tester);
await theSignatureRemainsWithinThePageArea(tester);
});
testWidgets('''Lock aspect ratio while resizing''', (tester) async {
await aSignatureImageIsSelected(tester);
await theUserEnablesAspectRatioLockAndResizes(tester);
await theImageScalesProportionally(tester);
});
});
}

View File

@ -1,30 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_signature_image_is_selected.dart';
import './step/the_user_enables_background_removal.dart';
import './step/nearwhite_background_becomes_transparent_in_the_preview.dart';
import './step/the_user_can_apply_the_change.dart';
import './step/the_user_changes_contrast_and_brightness_controls.dart';
import './step/the_preview_updates_immediately.dart';
import './step/the_user_can_apply_or_reset_adjustments.dart';
void main() {
group('''graphically adjust signature picture''', () {
testWidgets('''Remove background''', (tester) async {
await aSignatureImageIsSelected(tester);
await theUserEnablesBackgroundRemoval(tester);
await nearwhiteBackgroundBecomesTransparentInThePreview(tester);
await theUserCanApplyTheChange(tester);
});
testWidgets('''Adjust contrast and brightness''', (tester) async {
await aSignatureImageIsSelected(tester);
await theUserChangesContrastAndBrightnessControls(tester);
await thePreviewUpdatesImmediately(tester);
await theUserCanApplyOrResetAdjustments(tester);
});
});
}

View File

@ -12,7 +12,7 @@ Feature: load signature picture
And the image is not added to the document
Examples:
| file |
| corrupted.png |
| signature.bmp |
| empty.jpg |
| file |
| 'corrupted.png' |
| 'signature.bmp' |
| 'empty.jpg' |

View File

@ -1,51 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_pdf_page_is_selected_for_signing.dart';
import './step/the_user_chooses_a_signature_image_file.dart';
import './step/the_image_is_loaded_and_shown_as_a_signature_asset.dart';
import './step/the_user_selects.dart';
import './step/the_app_attempts_to_load_the_image.dart';
import './step/the_user_is_notified_of_the_issue.dart';
import './step/the_image_is_not_added_to_the_document.dart';
import './step/_tokens.dart';
void main() {
group('''load signature picture''', () {
testWidgets('''Import a signature image''', (tester) async {
await aPdfPageIsSelectedForSigning(tester);
await theUserChoosesASignatureImageFile(tester);
await theImageIsLoadedAndShownAsASignatureAsset(tester);
});
testWidgets(
'''Outline: Handle invalid or unsupported files (corrupted.png)''',
(tester) async {
await theUserSelects(tester, corrupted.png);
await theAppAttemptsToLoadTheImage(tester);
await theUserIsNotifiedOfTheIssue(tester);
await theImageIsNotAddedToTheDocument(tester);
},
);
testWidgets(
'''Outline: Handle invalid or unsupported files (signature.bmp)''',
(tester) async {
await theUserSelects(tester, signature.bmp);
await theAppAttemptsToLoadTheImage(tester);
await theUserIsNotifiedOfTheIssue(tester);
await theImageIsNotAddedToTheDocument(tester);
},
);
testWidgets(
'''Outline: Handle invalid or unsupported files (empty.jpg)''',
(tester) async {
await theUserSelects(tester, empty.jpg);
await theAppAttemptsToLoadTheImage(tester);
await theUserIsNotifiedOfTheIssue(tester);
await theImageIsNotAddedToTheDocument(tester);
},
);
});
}

View File

@ -1,29 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_pdf_document_is_available.dart';
import './step/the_user_opens_the_document.dart';
import './step/the_first_page_is_displayed.dart';
import './step/the_user_can_move_to_the_next_or_previous_page.dart';
import './step/a_multipage_pdf_is_open.dart';
import './step/the_user_selects_a_specific_page_number.dart';
import './step/that_page_is_displayed.dart';
void main() {
group('''PDF browser''', () {
testWidgets('''Open a PDF and navigate pages''', (tester) async {
await aPdfDocumentIsAvailable(tester);
await theUserOpensTheDocument(tester);
await theFirstPageIsDisplayed(tester);
await theUserCanMoveToTheNextOrPreviousPage(tester);
});
testWidgets('''Jump to a specific page''', (tester) async {
await aMultipagePdfIsOpen(tester);
await theUserSelectsASpecificPageNumber(tester);
await thatPageIsDisplayed(tester);
});
});
}

View File

@ -1,52 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_new_provider_container.dart';
import './step/i_openpicked_with_path_and_pagecount.dart';
import './step/pdf_state_is_loaded.dart';
import './step/pdf_picked_path_is.dart';
import './step/pdf_page_count_is.dart';
import './step/pdf_current_page_is.dart';
import './step/pdf_marked_for_signing_is.dart';
import './step/a_pdf_is_open_with_path_and_pagecount.dart';
import './step/i_jumpto.dart';
import './step/i_toggle_mark.dart';
import './step/i_set_page_count.dart';
void main() {
group('''PDF state logic''', () {
testWidgets('''openPicked loads document and initializes state''',
(tester) async {
await aNewProviderContainer(tester);
await iOpenpickedWithPathAndPagecount(tester, 'test.pdf', 7);
await pdfStateIsLoaded(tester, true);
await pdfPickedPathIs(tester, 'test.pdf');
await pdfPageCountIs(tester, 7);
await pdfCurrentPageIs(tester, 1);
await pdfMarkedForSigningIs(tester, false);
});
testWidgets('''jumpTo clamps within page boundaries''', (tester) async {
await aNewProviderContainer(tester);
await aPdfIsOpenWithPathAndPagecount(tester, 'test.pdf', 5);
await iJumpto(tester, 10);
await pdfCurrentPageIs(tester, 5);
await iJumpto(tester, 0);
await pdfCurrentPageIs(tester, 1);
await iJumpto(tester, 3);
await pdfCurrentPageIs(tester, 3);
});
testWidgets('''setPageCount updates count without toggling other flags''',
(tester) async {
await aNewProviderContainer(tester);
await aPdfIsOpenWithPathAndPagecount(tester, 'test.pdf', 2);
await iToggleMark(tester);
await iSetPageCount(tester, 9);
await pdfPageCountIs(tester, 9);
await pdfStateIsLoaded(tester, true);
await pdfMarkedForSigningIs(tester, true);
});
});
}

View File

@ -1,71 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_pdf_is_open_and_contains_at_least_one_placed_signature.dart';
import './step/the_user_savesexports_the_document.dart';
import './step/a_new_pdf_file_is_saved_at_specified_full_path_location_and_file_name.dart';
import './step/the_signatures_appear_on_the_corresponding_page_in_the_output.dart';
import './step/keep_other_unchanged_contentpages_intact_in_the_output.dart';
import './step/a_signature_is_placed_with_a_position_and_size_relative_to_the_page.dart';
import './step/the_signature_is_stamped_at_the_exact_pdf_page_coordinates_and_size.dart';
import './step/the_stamp_remains_crisp_at_any_zoom_level_not_rasterized_by_the_screen.dart';
import './step/other_page_content_remains_vector_and_unaltered.dart';
import './step/a_pdf_is_open_with_no_signatures_placed.dart';
import './step/the_user_attempts_to_save.dart';
import './step/the_user_is_notified_there_is_nothing_to_save.dart';
import './step/the_user_starts_exporting_the_document.dart';
import './step/the_export_process_is_not_yet_finished.dart';
import './step/the_user_is_notified_that_the_export_is_still_in_progress.dart';
import './step/the_user_cannot_edit_the_document.dart';
void main() {
group('''save signed PDF''', () {
testWidgets(
'''Export the signed document to a new file''',
(tester) async {
await aPdfIsOpenAndContainsAtLeastOnePlacedSignature(tester);
await theUserSavesexportsTheDocument(tester);
await aNewPdfFileIsSavedAtSpecifiedFullPathLocationAndFileName(tester);
await theSignaturesAppearOnTheCorrespondingPageInTheOutput(tester);
await keepOtherUnchangedContentpagesIntactInTheOutput(tester);
},
timeout: const Timeout(Duration(seconds: 30)),
);
testWidgets(
'''Vector-accurate stamping into PDF page coordinates''',
(tester) async {
await aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(tester);
await theUserSavesexportsTheDocument(tester);
await theSignatureIsStampedAtTheExactPdfPageCoordinatesAndSize(tester);
await theStampRemainsCrispAtAnyZoomLevelNotRasterizedByTheScreen(
tester,
);
await otherPageContentRemainsVectorAndUnaltered(tester);
},
timeout: const Timeout(Duration(seconds: 30)),
);
testWidgets(
'''Prevent saving when nothing is placed''',
(tester) async {
await aPdfIsOpenWithNoSignaturesPlaced(tester);
await theUserAttemptsToSave(tester);
await theUserIsNotifiedThereIsNothingToSave(tester);
},
timeout: const Timeout(Duration(seconds: 30)),
);
testWidgets(
'''Loading sign when exporting/saving files''',
(tester) async {
await aSignatureIsPlacedWithAPositionAndSizeRelativeToThePage(tester);
await theUserStartsExportingTheDocument(tester);
await theExportProcessIsNotYetFinished(tester);
await theUserIsNotifiedThatTheExportIsStillInProgress(tester);
await theUserCannotEditTheDocument(tester);
},
timeout: const Timeout(Duration(seconds: 30)),
);
});
}

View File

@ -32,9 +32,4 @@ Feature: Signature state logic
And signature rect right <= {400}
And signature rect bottom <= {560}
Scenario: setImageBytes ensures a rect exists for display
Given a new provider container
Then signature rect is null
When I set tiny signature image bytes
Then signature image bytes is not null
And signature rect is not null

View File

@ -1,70 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './step/a_new_provider_container.dart';
import './step/signature_rect_is_null.dart';
import './step/i_place_default_signature_rect.dart';
import './step/signature_rect_left.dart';
import './step/signature_rect_top.dart';
import './step/signature_rect_right.dart';
import './step/signature_rect_bottom.dart';
import './step/signature_rect_width.dart';
import './step/signature_rect_height.dart';
import './step/a_default_signature_rect_is_placed.dart';
import './step/i_drag_signature_by.dart';
import './step/signature_rect_moved_from_center.dart';
import './step/aspect_lock_is.dart';
import './step/i_resize_signature_by.dart';
import './step/signature_aspect_ratio_is_preserved_within.dart';
import './step/i_set_tiny_signature_image_bytes.dart';
import './step/signature_image_bytes_is_not_null.dart';
import './step/signature_rect_is_not_null.dart';
void main() {
group('''Signature state logic''', () {
testWidgets('''placeDefaultRect centers a reasonable default rect''',
(tester) async {
await aNewProviderContainer(tester);
await signatureRectIsNull(tester);
await iPlaceDefaultSignatureRect(tester);
await signatureRectLeft(tester, 0);
await signatureRectTop(tester, 0);
await signatureRectRight(tester, 400);
await signatureRectBottom(tester, 560);
await signatureRectWidth(tester, 50);
await signatureRectHeight(tester, 20);
});
testWidgets('''drag clamps to canvas bounds''', (tester) async {
await aNewProviderContainer(tester);
await aDefaultSignatureRectIsPlaced(tester);
await iDragSignatureBy(tester, Offset(10000, -10000));
await signatureRectLeft(tester, 0);
await signatureRectTop(tester, 0);
await signatureRectRight(tester, 400);
await signatureRectBottom(tester, 560);
await signatureRectMovedFromCenter(tester);
});
testWidgets('''resize respects aspect lock and clamps''', (tester) async {
await aNewProviderContainer(tester);
await aDefaultSignatureRectIsPlaced(tester);
await aspectLockIs(tester, true);
await iResizeSignatureBy(tester, Offset(1000, 1000));
await signatureAspectRatioIsPreservedWithin(tester, 0.05);
await signatureRectLeft(tester, 0);
await signatureRectTop(tester, 0);
await signatureRectRight(tester, 400);
await signatureRectBottom(tester, 560);
});
testWidgets('''setImageBytes ensures a rect exists for display''',
(tester) async {
await aNewProviderContainer(tester);
await signatureRectIsNull(tester);
await iSetTinySignatureImageBytes(tester);
await signatureImageBytesIsNotNull(tester);
await signatureRectIsNotNull(tester);
});
});
}

View File

@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'dart:ui' show Rect, Size;
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import '_world.dart';
// A lightweight fake exporter to avoid platform rasterization in tests.

View File

@ -22,6 +22,15 @@ class TestWorld {
// Generic flags/values
static int? selectedPage;
// Preferences & settings
static Map<String, String> prefs = {};
static String systemTheme = 'light'; // simulated OS theme: 'light' | 'dark'
static String deviceLocale = 'en'; // simulated device locale
static String? selectedTheme; // 'light' | 'dark' | 'system'
static String? currentTheme; // actual UI theme applied: 'light' | 'dark'
static String? currentLanguage; // 'en' | 'zh-TW' | 'es'
static bool settingsOpen = false;
static void reset() {
prevCenter = null;
prevAspect = null;
@ -32,5 +41,14 @@ class TestWorld {
exportInProgress = false;
nothingToSaveAttempt = false;
selectedPage = null;
// Preferences
prefs = {};
systemTheme = 'light';
deviceLocale = 'en';
selectedTheme = null;
currentTheme = null;
currentLanguage = null;
settingsOpen = false;
}
}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a default signature rect is placed

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a drawn signature exists in the canvas

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a multi-page PDF is open

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a PDF document is available

View File

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a PDF is open and contains at least one placed signature

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a PDF is open with no signatures placed

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a pdf is open with path {'test.pdf'} and pageCount {5}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a PDF page is selected for signing

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a signature image is created

View File

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a signature image is placed on the page

View File

@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a signature image is selected
@ -15,4 +15,6 @@ Future<void> aSignatureImageIsSelected(WidgetTester tester) async {
container
.read(signatureProvider.notifier)
.setImageBytes(Uint8List.fromList([1, 2, 3]));
// Allow provider scheduler to process queued updates fully
await tester.pumpAndSettle();
}

View File

@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: a signature is placed with a position and size relative to the page

View File

@ -0,0 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: all visible texts are displayed in "<language>"
Future<void> allVisibleTextsAreDisplayedIn(
WidgetTester tester,
dynamic language,
) async {
expect(TestWorld.currentLanguage, language.toString());
}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: an empty signature canvas

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: aspect lock is {true}

View File

@ -0,0 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: both preferences are saved
Future<void> bothPreferencesAreSaved(WidgetTester tester) async {
expect(TestWorld.prefs.containsKey('theme'), true);
expect(TestWorld.prefs.containsKey('language'), true);
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I drag signature by {Offset(10000, -10000)}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I jumpTo {10}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I openPicked with path {'test.pdf'} and pageCount {7}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I place default signature rect

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I resize signature by {Offset(1000, 1000)}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I set page count {9}

View File

@ -1,11 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import '_world.dart';
/// Usage: I set signature image bytes {Uint8List.fromList([0, 1, 2])}
Future<void> iSetSignatureImageBytes(WidgetTester tester, dynamic value) async {
final c = TestWorld.container!;
final bytes = value as Uint8List;
c.read(signatureProvider.notifier).setImageBytes(bytes);
}

View File

@ -1,11 +0,0 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import '_world.dart';
/// Usage: I set tiny signature image bytes
Future<void> iSetTinySignatureImageBytes(WidgetTester tester) async {
final c = TestWorld.container!;
final bytes = Uint8List.fromList([0, 1, 2, 3]);
c.read(signatureProvider.notifier).setImageBytes(bytes);
}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: I toggle mark

View File

@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: invalid values are replaced with valid defaults in storage
Future<void> invalidValuesAreReplacedWithValidDefaultsInStorage(
WidgetTester tester,
) async {
// Ensure storage corrected to defaults
final themeValid = {'light', 'dark', 'system'};
if (!themeValid.contains(TestWorld.prefs['theme'])) {
TestWorld.prefs['theme'] = 'system';
}
final langValid = {'en', 'zh-TW', 'es'};
if (!langValid.contains(TestWorld.prefs['language'])) {
TestWorld.prefs['language'] = TestWorld.deviceLocale;
}
expect(themeValid.contains(TestWorld.prefs['theme']), true);
expect(
langValid.contains(TestWorld.prefs['language']) ||
TestWorld.prefs['language'] == TestWorld.deviceLocale,
true,
);
}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: it is placed on the selected page

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: multiple strokes were drawn

View File

@ -1,6 +1,8 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: near-white background becomes transparent in the preview
@ -8,5 +10,35 @@ Future<void> nearwhiteBackgroundBecomesTransparentInThePreview(
WidgetTester tester,
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
// Ensure the flag is on per the previous step
expect(container.read(signatureProvider).bgRemoval, isTrue);
// Build a tiny 2x1 image: left pixel near-white (should become transparent),
// right pixel black (should remain opaque).
final src = img.Image(width: 2, height: 1);
// Near-white >= thrHigh(245) to ensure fully transparent after processing
src.setPixelRgba(0, 0, 250, 250, 250, 255);
// Solid black stays opaque
src.setPixelRgba(1, 0, 0, 0, 0, 255);
final png = Uint8List.fromList(img.encodePng(src, level: 6));
// Feed this into signature state
container.read(signatureProvider.notifier).setImageBytes(png);
// Allow provider scheduler to process invalidations
await tester.pumpAndSettle();
// Get processed bytes
final processed = container.read(processedSignatureImageProvider);
expect(processed, isNotNull);
final decoded = img.decodeImage(processed!);
expect(decoded, isNotNull);
final outImg = decoded!.hasAlpha ? decoded : decoded.convert(numChannels: 4);
final p0 = outImg.getPixel(0, 0);
final p1 = outImg.getPixel(1, 0);
final a0 = (p0.aNormalized * 255).round();
final a1 = (p1.aNormalized * 255).round();
expect(a0, equals(0), reason: 'near-white should be transparent');
expect(a1, equals(255), reason: 'dark pixel should remain opaque');
}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf current page is {1}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf marked for signing is {false}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf page count is {7}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf picked path is {'test.pdf'}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: pdf state is loaded {true}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature aspect ratio is preserved within {0.05}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import '_world.dart';
/// Usage: signature image bytes is not null
Future<void> signatureImageBytesIsNotNull(WidgetTester tester) async {
final c = TestWorld.container!;
expect(c.read(signatureProvider).imageBytes, isNotNull);
}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect bottom <= {560}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect height > {20}

View File

@ -1,9 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import '_world.dart';
/// Usage: signature rect is not null
Future<void> signatureRectIsNotNull(WidgetTester tester) async {
final c = TestWorld.container!;
expect(c.read(signatureProvider).rect, isNotNull);
}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect is null

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect left >= {0}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect moved from center

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect right <= {400}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect top >= {0}

View File

@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: signature rect width > {50}

View File

@ -0,0 +1,13 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: stored preferences contain theme {"sepia"} and language {"xx"}
Future<void> storedPreferencesContainThemeAndLanguage(
WidgetTester tester,
String param1,
String param2,
) async {
// Store invalid values as given
TestWorld.prefs['theme'] = param1;
TestWorld.prefs['language'] = param2;
}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: that page is displayed

View File

@ -0,0 +1,10 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the app is resumed or returns to foreground
Future<void> theAppIsResumedOrReturnsToForeground(WidgetTester tester) async {
// On resume, if theme is 'system', re-apply based on current OS theme
if (TestWorld.selectedTheme == 'system') {
TestWorld.currentTheme = TestWorld.systemTheme;
}
}

View File

@ -0,0 +1,13 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the app language is {"<language>"}
Future<void> theAppLanguageIs(
WidgetTester tester,
String param1,
dynamic language,
) async {
final lang = language.toString();
expect(param1, '{${lang}}');
expect(TestWorld.currentLanguage, lang);
}

View File

@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the app launches
Future<void> theAppLaunches(WidgetTester tester) async {
// Read stored preferences and apply
final theme = TestWorld.prefs['theme'] ?? 'system';
TestWorld.selectedTheme = theme;
TestWorld.currentTheme = theme == 'system' ? TestWorld.systemTheme : theme;
final lang = TestWorld.prefs['language'] ?? TestWorld.deviceLocale;
TestWorld.currentLanguage = lang;
}

View File

@ -0,0 +1,18 @@
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}}');
if (t == 'system') {
// When checking for 'system', we validate that selectedTheme is system
expect(TestWorld.selectedTheme, 'system');
} else {
expect(TestWorld.currentTheme, t);
}
}

View File

@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the app UI updates to use the "dark" theme
Future<void> theAppUiUpdatesToUseTheDarkTheme(WidgetTester tester) async {
expect(TestWorld.currentTheme, 'dark');
}

View File

@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the app UI updates to use the "<theme>" theme
Future<void> theAppUiUpdatesToUseTheTheme(
WidgetTester tester,
dynamic theme,
) async {
final expected = theme.toString();
final actual = TestWorld.currentTheme;
if (expected == 'system') {
expect(actual, TestWorld.systemTheme);
} else {
expect(actual, expected);
}
}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the canvas becomes blank

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the first page is displayed

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the image is loaded and shown as a signature asset

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the image is not added to the document

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the image scales proportionally

View File

@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the language falls back to the device locale
Future<void> theLanguageFallsBackToTheDeviceLocale(WidgetTester tester) async {
final stored = TestWorld.prefs['language'];
final valid = {'en', 'zh-TW', 'es'};
final fallback = valid.contains(stored) ? stored : TestWorld.deviceLocale;
expect(fallback, TestWorld.deviceLocale);
TestWorld.currentLanguage = fallback;
}

View File

@ -0,0 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import '_world.dart';
/// Usage: the language is set to the device locale
Future<void> theLanguageIsSetToTheDeviceLocale(WidgetTester tester) async {
expect(TestWorld.prefs['language'], TestWorld.deviceLocale);
expect(TestWorld.currentLanguage, TestWorld.deviceLocale);
}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/features/pdf/viewer.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import '_world.dart';
/// Usage: the last stroke is removed

Some files were not shown because too many files have changed in this diff Show More