feat: partially update UI view to new design

This commit is contained in:
insleker 2025-09-02 15:19:36 +08:00
parent d3df15d695
commit db0912b12f
18 changed files with 697 additions and 271 deletions

View File

@ -44,17 +44,20 @@ Route: root --> opened
Design notes: Design notes:
- Top: A small toolbar sits at the top edge with file name text, open pdf file button, previous/next page widgets and zoom controls. - Top: A small toolbar sits at the top edge with file name text, open pdf file button, previous/next page widgets and zoom controls.
- Navigation: Previous page, Next page, and a page number input (e.g., “2 / 10”) with jump-on-Enter. - On the far left of the toolbar there is a button that can turn the document pages overview sidebar on and off.
- Zoom: Zoom out, Zoom level dropdown (percent), Zoom in, Fit width, Fit page, Reset zoom. - On the far right of the toolbar there is a button that can turn the signature cards overview sidebar on and off.
- Optional: Find/search within PDF (if supported by engine). - Navigation: Previous page, Next page, and a page number input (e.g., “2 / 10”) with jump-on-Enter.
- Zoom: Zoom out, Zoom level dropdown (percent), Zoom in, Fit width, Fit page, Reset zoom.
- Optional: Find/search within PDF (if supported by engine).
- Left pane: vertical strip of page thumbnails (e.g., page1, page2, page3). Clicking a thumbnail navigates to that page; the current page is visually indicated. - Left pane: vertical strip of page thumbnails (e.g., page1, page2, page3). Clicking a thumbnail navigates to that page; the current page is visually indicated.
- Center: main PDF viewer shows the active page. - Center: main PDF viewer shows the active page.
- wheel to scroll pages.
- Ctrl/Cmd + wheel to zoom. - Ctrl/Cmd + wheel to zoom.
- Right pane: signatures drawer displaying saved signatures as cards. - Right pane: signatures drawer displaying saved signatures as cards.
- able to drag and drop signature cards onto the PDF as placed signatures. - able to drag and drop signature cards onto the PDF as placed signatures.
- Each signature card shows a preview. - Each signature card shows a preview.
- long tap/right-click will show menu with options to delete, adjust graphic of image. - long tap/right-click will show menu with options to delete, adjust graphic of image.
- "adjust graphic" opens a simple image editor, which can remove backgrounds. - "adjust graphic" opens a simple image editor, which can remove backgrounds, Rotate (rotation handle).
- There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw". - There is an empty card with "new signature" prompt and 2 buttons: "from file" and "draw".
- "from file" opens a file picker to select an image as a signature card. - "from file" opens a file picker to select an image as a signature card.
- "draw" opens a simple drawing interface (draw canvas) to create a signature card. - "draw" opens a simple drawing interface (draw canvas) to create a signature card.
@ -62,7 +65,7 @@ Design notes:
Signature controls (after placing on page): Signature controls (after placing on page):
- Select to show bounding box with resize handles and a small inline action bar. - Select to show bounding box with resize handles and a small inline action bar.
- Actions: Move (drag), Resize (corner/side handles), Rotate (rotation handle), Duplicate, Delete (trash icon or Delete key). - Actions: Move (drag), Resize (corner/side handles), Delete (trash icon or Delete key).
- Lock: Lock/Unlock position. - Lock: Lock/Unlock position.
- Keyboard: Arrow keys to nudge (Shift for 10px); Shift-resize to keep aspect; Esc to cancel; Ctrl/Cmd+D to duplicate; Del/Backspace to delete. - Keyboard: Arrow keys to nudge (Shift for 10px); Shift-resize to keep aspect; Esc to cancel; Ctrl/Cmd+D to duplicate; Del/Backspace to delete.

View File

@ -6,6 +6,7 @@ import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/view_model.dart';
import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart'; import 'package:pdf_signature/ui/features/welcome/widgets/welcome_screen.dart';
import 'ui/features/preferences/providers.dart'; import 'ui/features/preferences/providers.dart';
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
@ -62,7 +63,27 @@ class MyApp extends StatelessWidget {
...AppLocalizations.localizationsDelegates, ...AppLocalizations.localizationsDelegates,
LocaleNamesLocalizationsDelegate(), LocaleNamesLocalizationsDelegate(),
], ],
home: const _RootHomeSwitcher(), home: Builder(
builder:
(ctx) => Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(ctx).appTitle),
actions: [
OutlinedButton.icon(
key: const Key('btn_appbar_settings'),
icon: const Icon(Icons.settings),
label: Text(AppLocalizations.of(ctx).settings),
onPressed:
() => showDialog<bool>(
context: ctx,
builder: (_) => const SettingsDialog(),
),
),
],
),
body: const _RootHomeSwitcher(),
),
),
); );
}, },
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -251,6 +251,16 @@ class SignatureController extends StateNotifier<SignatureState> {
state = state.copyWith(editingEnabled: true); state = state.copyWith(editingEnabled: true);
} }
void clearImage() {
state = state.copyWith(imageBytes: null, rect: null, editingEnabled: false);
}
void placeAtCenter(Offset center, {double width = 120, double height = 60}) {
Rect r = Rect.fromCenter(center: center, width: width, height: height);
r = _clampRectToPage(r);
state = state.copyWith(rect: r, editingEnabled: true);
}
// Confirm current signature: freeze editing and place it on the PDF as an immutable overlay. // Confirm current signature: freeze editing and place it on the PDF as an immutable overlay.
// Returns the Rect placed, or null if no rect to confirm. // Returns the Rect placed, or null if no rect to confirm.
Rect? confirmCurrentSignature(WidgetRef ref) { Rect? confirmCurrentSignature(WidgetRef ref) {

View File

@ -9,21 +9,22 @@ import '../../../../data/services/providers.dart';
import '../../../../data/model/model.dart'; import '../../../../data/model/model.dart';
import '../view_model/view_model.dart'; import '../view_model/view_model.dart';
import '../../preferences/providers.dart'; import '../../preferences/providers.dart';
import 'signature_drawer.dart';
class PdfPageArea extends ConsumerStatefulWidget { class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({ const PdfPageArea({
super.key, super.key,
required this.pageSize, required this.pageSize,
this.controller,
required this.onDragSignature, required this.onDragSignature,
required this.onResizeSignature, required this.onResizeSignature,
required this.onConfirmSignature, required this.onConfirmSignature,
required this.onClearActiveOverlay, required this.onClearActiveOverlay,
required this.onSelectPlaced, required this.onSelectPlaced,
this.viewerController,
}); });
final Size pageSize; final Size pageSize;
final TransformationController? controller; final PdfViewerController? viewerController;
final ValueChanged<Offset> onDragSignature; final ValueChanged<Offset> onDragSignature;
final ValueChanged<Offset> onResizeSignature; final ValueChanged<Offset> onResizeSignature;
final VoidCallback onConfirmSignature; final VoidCallback onConfirmSignature;
@ -35,7 +36,8 @@ class PdfPageArea extends ConsumerStatefulWidget {
class _PdfPageAreaState extends ConsumerState<PdfPageArea> { class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
final Map<int, GlobalKey> _pageKeys = {}; final Map<int, GlobalKey> _pageKeys = {};
final PdfViewerController _viewerController = PdfViewerController(); late final PdfViewerController _viewerController =
widget.viewerController ?? PdfViewerController();
// Guards to avoid scroll feedback between provider and viewer // Guards to avoid scroll feedback between provider and viewer
int? _programmaticTargetPage; int? _programmaticTargetPage;
bool _suppressProviderListen = false; bool _suppressProviderListen = false;
@ -58,6 +60,8 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}); });
} }
// No dispose required for PdfViewerController (managed by owner if any)
GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent( GlobalKey _pageKey(int page) => _pageKeys.putIfAbsent(
page, page,
() => GlobalKey(debugLabel: 'cont_page_$page'), () => GlobalKey(debugLabel: 'cont_page_$page'),
@ -216,7 +220,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
} }
}); });
} }
return SingleChildScrollView( final content = SingleChildScrollView(
key: const Key('pdf_continuous_mock_list'), key: const Key('pdf_continuous_mock_list'),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Column( child: Column(
@ -270,6 +274,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}), }),
), ),
); );
return content;
}, },
); );
} }
@ -277,14 +282,14 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Real continuous mode (pdfrx): copy example patterns // Real continuous mode (pdfrx): copy example patterns
// https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart // https://github.com/espresso3389/pdfrx/blob/2cc32c1e2aa2a054602d20a5e7cf60bcc2d6a889/packages/pdfrx/example/viewer/lib/main.dart
if (pdf.pickedPdfPath != null && isContinuous) { if (pdf.pickedPdfPath != null && isContinuous) {
return PdfViewer.file( final viewer = PdfViewer.file(
pdf.pickedPdfPath!, pdf.pickedPdfPath!,
controller: _viewerController, controller: _viewerController,
params: PdfViewerParams( params: PdfViewerParams(
pageAnchor: PdfPageAnchor.top, pageAnchor: PdfPageAnchor.top,
keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true), keyHandlerParams: PdfViewerKeyHandlerParams(autofocus: true),
maxScale: 8, maxScale: 8,
// scrollByMouseWheel: 0.6, scrollByMouseWheel: 0.6,
// Add overlay scroll thumbs (vertical on right, horizontal on bottom) // Add overlay scroll thumbs (vertical on right, horizontal on bottom)
viewerOverlayBuilder: viewerOverlayBuilder:
(context, size, handleLinkTap) => [ (context, size, handleLinkTap) => [
@ -294,7 +299,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
thumbSize: const Size(40, 24), thumbSize: const Size(40, 24),
thumbBuilder: thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container( (context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withOpacity(0.7), color: Colors.black.withValues(alpha: 0.7),
child: Center( child: Center(
child: Text( child: Text(
pageNumber.toString(), pageNumber.toString(),
@ -309,7 +314,7 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
thumbSize: const Size(40, 24), thumbSize: const Size(40, 24),
thumbBuilder: thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container( (context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withOpacity(0.7), color: Colors.black.withValues(alpha: 0.7),
child: Center( child: Center(
child: Text( child: Text(
pageNumber.toString(), pageNumber.toString(),
@ -375,6 +380,34 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
}, },
), ),
); );
// Accept drops of signature card over the viewer
final drop = DragTarget<Object>(
onWillAcceptWithDetails: (details) => details.data is SignatureDragData,
onAcceptWithDetails: (details) {
// Map the local position to UI page coordinates of the visible page
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final local = box.globalToLocal(details.offset);
final size = box.size;
// Assume drop targets the current visible page; compute relative center
final cx = (local.dx / size.width) * widget.pageSize.width;
final cy = (local.dy / size.height) * widget.pageSize.height;
ref.read(signatureProvider.notifier).placeAtCenter(Offset(cx, cy));
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
},
builder:
(context, candidateData, rejected) => Stack(
fit: StackFit.expand,
children: [
viewer,
if (candidateData.isNotEmpty)
Container(color: Colors.blue.withValues(alpha: 0.08)),
],
),
);
return drop;
} }
return const SizedBox.shrink(); return const SizedBox.shrink();

View File

@ -5,15 +5,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:printing/printing.dart' as printing; import 'package:printing/printing.dart' as printing;
import 'package:pdfrx/pdfrx.dart';
import '../../../../data/services/providers.dart'; import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart'; import '../view_model/view_model.dart';
import 'draw_canvas.dart'; import 'draw_canvas.dart';
import 'pdf_toolbar.dart'; import 'pdf_toolbar.dart';
import 'pdf_page_area.dart'; import 'pdf_page_area.dart';
import 'adjustments_panel.dart';
import 'pdf_pages_overview.dart'; import 'pdf_pages_overview.dart';
import '../../preferences/widgets/settings_screen.dart'; import 'signature_drawer.dart';
import 'adjustments_panel.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget { class PdfSignatureHomePage extends ConsumerStatefulWidget {
const PdfSignatureHomePage({super.key}); const PdfSignatureHomePage({super.key});
@ -25,7 +26,9 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> { class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
static const Size _pageSize = SignatureController.pageSize; static const Size _pageSize = SignatureController.pageSize;
final TransformationController _ivController = TransformationController(); final PdfViewerController _viewerController = PdfViewerController();
bool _showPagesSidebar = true;
bool _showSignaturesSidebar = true;
// Exposed for tests to trigger the invalid-file SnackBar without UI. // Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting @visibleForTesting
@ -52,6 +55,8 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
ref.read(pdfProvider.notifier).jumpTo(page); ref.read(pdfProvider.notifier).jumpTo(page);
} }
// Zoom is managed by pdfrx viewer (Ctrl +/- etc.). No custom zoom here.
Future<void> _loadSignatureFromFile() async { Future<void> _loadSignatureFromFile() async {
final typeGroup = const fs.XTypeGroup( final typeGroup = const fs.XTypeGroup(
label: 'Image', label: 'Image',
@ -68,25 +73,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
} }
} }
void _createNewSignature() { // _createNewSignature was removed as the toolbar no longer exposes this action.
final sig = ref.read(signatureProvider.notifier);
if (ref.read(pdfProvider).loaded) {
sig.placeDefaultRect();
ref
.read(pdfProvider.notifier)
.setSignedPage(ref.read(pdfProvider).currentPage);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).longPressOrRightClickTheSignatureToConfirmOrDelete,
),
duration: const Duration(seconds: 3),
),
);
}
}
void _confirmSignature() { void _confirmSignature() {
ref.read(signatureProvider.notifier).confirmCurrentSignature(ref); ref.read(signatureProvider.notifier).confirmCurrentSignature(ref);
@ -250,7 +237,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
@override @override
void dispose() { void dispose() {
_ivController.dispose();
super.dispose(); super.dispose();
} }
@ -259,64 +245,62 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final isExporting = ref.watch(exportingProvider); final isExporting = ref.watch(exportingProvider);
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(
title: Text(l.appTitle),
actions: [
IconButton(
key: const Key('btn_appbar_settings'),
tooltip: l.settings,
onPressed:
() => showDialog<bool>(
context: context,
builder: (_) => const SettingsDialog(),
),
icon: const Icon(Icons.settings),
),
],
),
body: Padding( body: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Stack( child: Stack(
children: [ children: [
Row( Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Left: pages overview (thumbnails + navigation) // Full-width toolbar row
ConstrainedBox( PdfToolbar(
constraints: const BoxConstraints( disabled: isExporting,
minWidth: 140, onPickPdf: _pickPdf,
maxWidth: 180, onJumpToPage: _jumpToPage,
), onZoomOut: () {
child: Card( if (_viewerController.isReady) {
margin: EdgeInsets.zero, _viewerController.zoomDown();
child: const PdfPagesOverview(), }
), },
onZoomIn: () {
if (_viewerController.isReady) {
_viewerController.zoomUp();
}
},
fileName: ref.watch(pdfProvider).pickedPdfPath,
showPagesSidebar: _showPagesSidebar,
showSignaturesSidebar: _showSignaturesSidebar,
onTogglePagesSidebar:
() => setState(() {
_showPagesSidebar = !_showPagesSidebar;
}),
onToggleSignaturesSidebar:
() => setState(() {
_showSignaturesSidebar = !_showSignaturesSidebar;
}),
), ),
const SizedBox(width: 12), const SizedBox(height: 8),
Expanded( Expanded(
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
PdfToolbar( if (_showPagesSidebar)
disabled: isExporting, ConstrainedBox(
onOpenSettings: constraints: const BoxConstraints(
() => showDialog<bool>( minWidth: 140,
context: context, maxWidth: 180,
builder: (_) => const SettingsDialog(), ),
), child: Card(
onPickPdf: _pickPdf, margin: EdgeInsets.zero,
onJumpToPage: _jumpToPage, child: const PdfPagesOverview(),
onSave: _saveSignedPdf, ),
onLoadSignatureFromFile: _loadSignatureFromFile, ),
onCreateSignature: _createNewSignature, if (_showPagesSidebar) const SizedBox(width: 12),
onOpenDrawCanvas: _openDrawCanvas,
),
const SizedBox(height: 8),
Expanded( Expanded(
child: AbsorbPointer( child: AbsorbPointer(
absorbing: isExporting, absorbing: isExporting,
child: PdfPageArea( child: PdfPageArea(
pageSize: _pageSize, pageSize: _pageSize,
controller: _ivController, viewerController: _viewerController,
onDragSignature: _onDragSignature, onDragSignature: _onDragSignature,
onResizeSignature: _onResizeSignature, onResizeSignature: _onResizeSignature,
onConfirmSignature: _confirmSignature, onConfirmSignature: _confirmSignature,
@ -329,108 +313,243 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
), ),
), ),
), ),
], if (_showSignaturesSidebar) const SizedBox(width: 12),
), if (_showSignaturesSidebar)
), ConstrainedBox(
const SizedBox(width: 12), constraints: const BoxConstraints(
ConstrainedBox( minWidth: 280,
constraints: const BoxConstraints( maxWidth: 360,
minWidth: 280, ),
maxWidth: 360, child: Consumer(
), builder: (context, ref, _) {
child: Consumer( final sig = ref.watch(signatureProvider);
builder: (context, ref, _) { final bytes =
final sig = ref.watch(signatureProvider); ref.watch(processedSignatureImageProvider) ??
if (sig.rect != null) { sig.imageBytes;
return AbsorbPointer( return AbsorbPointer(
absorbing: isExporting, absorbing: isExporting,
child: Card( child: Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Column( child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.stretch, builder: (context, cons) {
children: [ return SingleChildScrollView(
// Signature preview padding: EdgeInsets.zero,
Padding( child: ConstrainedBox(
padding: const EdgeInsets.all(12), constraints: BoxConstraints(
child: Column( minHeight: cons.maxHeight,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).signature,
style:
Theme.of(
context,
).textTheme.titleSmall,
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color:
Theme.of(context).dividerColor,
), ),
borderRadius: BorderRadius.circular( child: Column(
8, crossAxisAlignment:
), CrossAxisAlignment.stretch,
), children: [
child: AspectRatio( Padding(
aspectRatio: 3 / 1, padding: const EdgeInsets.all(
child: Padding( 12,
padding: const EdgeInsets.all(8.0), ),
child: Consumer( child: Column(
builder: (context, ref, _) { crossAxisAlignment:
final bytes = CrossAxisAlignment.start,
ref.watch( children: [
processedSignatureImageProvider, Text(
) ??
sig.imageBytes;
if (bytes == null) {
return Center(
child: Text(
AppLocalizations.of( AppLocalizations.of(
context, context,
).noPdfLoaded, ).signature,
style:
Theme.of(context)
.textTheme
.titleSmall,
), ),
); const SizedBox(height: 8),
} DecoratedBox(
return Image.memory( decoration: BoxDecoration(
bytes, border: Border.all(
fit: BoxFit.contain, color:
); Theme.of(
}, context,
), ).dividerColor,
),
borderRadius:
BorderRadius.circular(
8,
),
),
child: AspectRatio(
aspectRatio: 3 / 1,
child: Padding(
padding:
const EdgeInsets.all(
8.0,
),
child: Builder(
builder: (context) {
final placeholder = Center(
child: Text(
AppLocalizations.of(
context,
).noSignatureLoaded,
),
);
if (bytes ==
null ||
bytes
.isEmpty) {
return placeholder;
}
final img =
Image.memory(
bytes,
fit:
BoxFit
.contain,
);
return Draggable<
Object
>(
data:
const SignatureDragData(),
feedback: Opacity(
opacity: 0.85,
child: ConstrainedBox(
constraints:
const BoxConstraints.tightFor(
width:
160,
height:
80,
),
child: DecoratedBox(
decoration: BoxDecoration(
color:
Colors.white,
borderRadius:
BorderRadius.circular(
6,
),
boxShadow: const [
BoxShadow(
blurRadius:
8,
color:
Colors.black26,
),
],
),
child: Padding(
padding:
const EdgeInsets.all(
6.0,
),
child: Image.memory(
bytes,
fit:
BoxFit.contain,
),
),
),
),
),
childWhenDragging:
Opacity(
opacity:
0.5,
child:
img,
),
child: img,
);
},
),
),
),
),
],
),
),
const SizedBox(height: 4),
Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 12,
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
key: const Key(
'btn_load_signature_picker',
),
onPressed:
!ref
.read(
pdfProvider,
)
.loaded
? null
: _loadSignatureFromFile,
icon: const Icon(
Icons.image_outlined,
),
label: Text(
AppLocalizations.of(
context,
).loadSignatureFromFile,
),
),
OutlinedButton.icon(
key: const Key(
'btn_draw_signature',
),
onPressed:
!ref
.read(
pdfProvider,
)
.loaded
? null
: _openDrawCanvas,
icon: const Icon(
Icons.gesture,
),
label: Text(
AppLocalizations.of(
context,
).drawSignature,
),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(
12,
),
child: AdjustmentsPanel(
sig: sig,
),
),
const Divider(height: 1),
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed:
isExporting
? null
: _saveSignedPdf,
child: Text(l.saveSignedPdf),
),
],
), ),
), ),
), );
], },
), ),
), ),
const Divider(height: 1), );
Expanded( },
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: AdjustmentsPanel(sig: sig),
),
),
],
),
),
);
}
return Card(
margin: EdgeInsets.zero,
child: Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
AppLocalizations.of(context).signature,
style: Theme.of(context).textTheme.bodyMedium,
),
), ),
), ),
); ],
},
), ),
), ),
], ],

View File

@ -10,23 +10,27 @@ class PdfToolbar extends ConsumerStatefulWidget {
const PdfToolbar({ const PdfToolbar({
super.key, super.key,
required this.disabled, required this.disabled,
required this.onOpenSettings,
required this.onPickPdf, required this.onPickPdf,
required this.onJumpToPage, required this.onJumpToPage,
required this.onSave, required this.onZoomOut,
required this.onLoadSignatureFromFile, required this.onZoomIn,
required this.onCreateSignature, this.fileName,
required this.onOpenDrawCanvas, required this.showPagesSidebar,
required this.showSignaturesSidebar,
required this.onTogglePagesSidebar,
required this.onToggleSignaturesSidebar,
}); });
final bool disabled; final bool disabled;
final VoidCallback onOpenSettings;
final VoidCallback onPickPdf; final VoidCallback onPickPdf;
final ValueChanged<int> onJumpToPage; final ValueChanged<int> onJumpToPage;
final VoidCallback onSave; final String? fileName;
final VoidCallback onLoadSignatureFromFile; final VoidCallback onZoomOut;
final VoidCallback onCreateSignature; final VoidCallback onZoomIn;
final VoidCallback onOpenDrawCanvas; final bool showPagesSidebar;
final bool showSignaturesSidebar;
final VoidCallback onTogglePagesSidebar;
final VoidCallback onToggleSignaturesSidebar;
@override @override
ConsumerState<PdfToolbar> createState() => _PdfToolbarState(); ConsumerState<PdfToolbar> createState() => _PdfToolbarState();
@ -57,21 +61,34 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260; final bool compact = constraints.maxWidth < 260;
final double gotoWidth = compact ? 60 : 100; final double gotoWidth = 50;
return Wrap(
// Center content of the toolbar
final center = Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
OutlinedButton(
key: const Key('btn_open_settings'),
onPressed: widget.disabled ? null : widget.onOpenSettings,
child: Text(l.settings),
),
OutlinedButton( OutlinedButton(
key: const Key('btn_open_pdf_picker'), key: const Key('btn_open_pdf_picker'),
onPressed: widget.disabled ? null : widget.onPickPdf, onPressed: widget.disabled ? null : widget.onPickPdf,
child: Text(l.openPdf), child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.insert_drive_file, size: 18),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
// if filename not null
widget.fileName != null
? widget.fileName!
: 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
],
),
), ),
if (pdf.loaded) ...[ if (pdf.loaded) ...[
Row( Row(
@ -86,6 +103,7 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
icon: const Icon(Icons.chevron_left), icon: const Icon(Icons.chevron_left),
tooltip: l.prev, tooltip: l.prev,
), ),
// Current page label
Text(pageInfo, key: const Key('lbl_page_info')), Text(pageInfo, key: const Key('lbl_page_info')),
IconButton( IconButton(
key: const Key('btn_next'), key: const Key('btn_next'),
@ -96,36 +114,51 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
tooltip: l.next, tooltip: l.next,
), ),
], Wrap(
), spacing: 6,
Wrap( runSpacing: 4,
spacing: 6, crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4, children: [
crossAxisAlignment: WrapCrossAlignment.center, Text(l.goTo),
children: [ SizedBox(
Text(l.goTo), width: gotoWidth,
SizedBox( child: TextField(
width: gotoWidth, key: const Key('txt_goto'),
child: TextField( controller: _goToController,
key: const Key('txt_goto'), keyboardType: TextInputType.number,
controller: _goToController, inputFormatters: [
keyboardType: TextInputType.number, FilteringTextInputFormatter.digitsOnly,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], ],
enabled: !widget.disabled, enabled: !widget.disabled,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
hintText: '1..${pdf.pageCount}', hintText: '1..${pdf.pageCount}',
),
onSubmitted: (_) => _submitGoTo(),
),
), ),
onSubmitted: (_) => _submitGoTo(), if (!compact)
), IconButton(
key: const Key('btn_goto_apply'),
tooltip: l.goTo,
icon: const Icon(Icons.arrow_forward),
onPressed: widget.disabled ? null : _submitGoTo,
),
],
),
const SizedBox(width: 8),
IconButton(
key: const Key('btn_zoom_out'),
tooltip: 'Zoom out',
onPressed: widget.disabled ? null : widget.onZoomOut,
icon: const Icon(Icons.zoom_out),
),
IconButton(
key: const Key('btn_zoom_in'),
tooltip: 'Zoom in',
onPressed: widget.disabled ? null : widget.onZoomIn,
icon: const Icon(Icons.zoom_in),
), ),
if (!compact)
IconButton(
key: const Key('btn_goto_apply'),
tooltip: l.goTo,
icon: const Icon(Icons.arrow_forward),
onPressed: widget.disabled ? null : _submitGoTo,
),
], ],
), ),
Row( Row(
@ -156,38 +189,42 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
), ),
], ],
), ),
ElevatedButton(
key: const Key('btn_save_pdf'),
onPressed: widget.disabled ? null : widget.onSave,
child: Text(l.saveSignedPdf),
),
OutlinedButton(
key: const Key('btn_load_signature_picker'),
onPressed:
widget.disabled || !pdf.loaded
? null
: widget.onLoadSignatureFromFile,
child: Text(l.loadSignatureFromFile),
),
OutlinedButton(
key: const Key('btn_create_signature'),
onPressed:
widget.disabled || !pdf.loaded
? null
: widget.onCreateSignature,
child: Text(l.createNewSignature),
),
ElevatedButton(
key: const Key('btn_draw_signature'),
onPressed:
widget.disabled || !pdf.loaded
? null
: widget.onOpenDrawCanvas,
child: Text(l.drawSignature),
),
], ],
], ],
); );
return Row(
children: [
IconButton(
key: const Key('btn_toggle_pages_sidebar'),
tooltip: 'Toggle pages overview',
onPressed: widget.disabled ? null : widget.onTogglePagesSidebar,
icon: Icon(
Icons.view_sidebar,
color:
widget.showPagesSidebar
? Theme.of(context).colorScheme.primary
: null,
),
),
const SizedBox(width: 8),
Expanded(child: center),
const SizedBox(width: 8),
IconButton(
key: const Key('btn_toggle_signatures_sidebar'),
tooltip: 'Toggle signatures drawer',
onPressed:
widget.disabled ? null : widget.onToggleSignaturesSidebar,
icon: Icon(
Icons.view_sidebar,
color:
widget.showSignaturesSidebar
? Theme.of(context).colorScheme.primary
: null,
),
),
],
);
}, },
); );
} }

View File

@ -0,0 +1,206 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import '../../../../data/services/providers.dart';
import '../view_model/view_model.dart';
import 'adjustments_panel.dart';
/// Data passed when dragging a signature card.
class SignatureDragData {
const SignatureDragData();
}
class SignatureDrawer extends ConsumerStatefulWidget {
const SignatureDrawer({
super.key,
required this.disabled,
required this.onLoadSignatureFromFile,
required this.onOpenDrawCanvas,
});
final bool disabled;
final VoidCallback onLoadSignatureFromFile;
final VoidCallback onOpenDrawCanvas;
@override
ConsumerState<SignatureDrawer> createState() => _SignatureDrawerState();
}
class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final sig = ref.watch(signatureProvider);
final processed = ref.watch(processedSignatureImageProvider);
final bytes = processed ?? sig.imageBytes;
final isExporting = ref.watch(exportingProvider);
final disabled = widget.disabled || isExporting;
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Text(
l.signature,
style: Theme.of(context).textTheme.titleSmall,
),
),
// Existing signature card (draggable when bytes available)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: SizedBox(
height: 120,
child:
bytes == null
? Center(
child: Text(
l.noPdfLoaded,
textAlign: TextAlign.center,
),
)
: _DraggableSignaturePreview(
bytes: bytes,
disabled: disabled,
),
),
),
),
// Actions under the card
if (bytes != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
PopupMenuButton<String>(
key: const Key('popup_signature_card'),
tooltip: l.settings,
onSelected: (v) {
switch (v) {
case 'delete':
ref
.read(signatureProvider.notifier)
.clearActiveOverlay();
ref.read(signatureProvider.notifier).clearImage();
break;
default:
break;
}
},
itemBuilder:
(ctx) => [
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(l.delete),
),
],
child: IconButton(
icon: const Icon(Icons.more_horiz),
onPressed: disabled ? null : () {},
),
),
const SizedBox(width: 4),
Text(AppLocalizations.of(context).createNewSignature),
],
),
),
const SizedBox(height: 12),
const Divider(height: 1),
// New signature card
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l.createNewSignature,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
key: const Key('btn_drawer_load_signature'),
onPressed:
disabled ? null : widget.onLoadSignatureFromFile,
icon: const Icon(Icons.image_outlined),
label: Text(l.loadSignatureFromFile),
),
OutlinedButton.icon(
key: const Key('btn_drawer_draw_signature'),
onPressed: disabled ? null : widget.onOpenDrawCanvas,
icon: const Icon(Icons.gesture),
label: Text(l.drawSignature),
),
],
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: AdjustmentsPanel(sig: sig),
),
),
],
),
);
}
}
class _DraggableSignaturePreview extends StatelessWidget {
const _DraggableSignaturePreview({
required this.bytes,
required this.disabled,
});
final Uint8List bytes;
final bool disabled;
@override
Widget build(BuildContext context) {
final child = Padding(
padding: const EdgeInsets.all(8.0),
child: Image.memory(bytes, fit: BoxFit.contain),
);
if (disabled) return child;
return Draggable<SignatureDragData>(
data: const SignatureDragData(),
feedback: Opacity(
opacity: 0.8,
child: ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 160, height: 80),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(blurRadius: 8, color: Colors.black26),
],
),
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Image.memory(bytes, fit: BoxFit.contain),
),
),
),
),
childWhenDragging: Opacity(opacity: 0.5, child: child),
child: child,
);
}
}

View File

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