Compare commits

..

No commits in common. "b2bf489af09e367ee52ca0dbc34fa87a50c4c7e9" and "0a512919a54d5f416e0bc68a6bb2d067dee7a48d" have entirely different histories.

16 changed files with 169 additions and 401 deletions

View File

@ -22,9 +22,8 @@ flutter analyze
# > run unit tests and widget tests # > run unit tests and widget tests
flutter test flutter test
# > run integration tests # > run integration tests
# flutter test integration_test/ -d <device_id> flutter test integration_test/ -d <device_id>
# Examples: --device=windows | --device=linux | --device=macos | --device=chrome # dart run tool/run_integration_tests.dart --device=linux (necessary for linux)
dart run tool/run_integration_tests.dart --device=<device_id>
# dart run tool/gen_view_wireframe_md.dart # dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer # flutter pub run dead_code_analyzer

View File

@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "1.8.22" apply false
} }
include(":app") include(":app")

View File

@ -94,5 +94,3 @@ Some rule of thumb:
* whole app use its image object as image representation. * whole app use its image object as image representation.
* aware that minimize, encode/decode usage, because its has poor performance on web * aware that minimize, encode/decode usage, because its has poor performance on web
* `ColorFilterGenerator` can not replace `adjustColor` due to custom background removal algorithm need at last stage. It is GPU based, and offscreen-render then readback is not ideal. * `ColorFilterGenerator` can not replace `adjustColor` due to custom background removal algorithm need at last stage. It is GPU based, and offscreen-render then readback is not ideal.
* [responsive_framework]
* RWD support

View File

@ -1,6 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -63,7 +63,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -119,7 +119,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -177,7 +177,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -223,7 +223,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -272,7 +272,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -324,7 +324,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -390,7 +390,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),

View File

@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'dart:io'; import 'dart:io';
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
@ -47,7 +47,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -101,7 +101,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -155,7 +155,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -242,7 +242,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),
@ -308,7 +308,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile('test.pdf'), currentFile: fs.XFile('test.pdf'),
), ),
), ),
), ),

View File

@ -5,7 +5,6 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/routing/router.dart'; import 'package:pdf_signature/routing/router.dart';
import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart'; import 'package:pdf_signature/ui/features/preferences/widgets/settings_screen.dart';
import 'data/repositories/preferences_repository.dart'; import 'data/repositories/preferences_repository.dart';
import 'package:responsive_framework/responsive_framework.dart';
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
@ -57,7 +56,7 @@ class MyApp extends StatelessWidget {
routerConfig: ref.watch(routerProvider), routerConfig: ref.watch(routerProvider),
builder: (context, child) { builder: (context, child) {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
final content = Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(AppLocalizations.of(context).appTitle), title: Text(AppLocalizations.of(context).appTitle),
actions: [ actions: [
@ -79,17 +78,6 @@ class MyApp extends StatelessWidget {
), ),
body: child, body: child,
); );
// Apply Responsive Framework globally for layout and scrolling.
return ResponsiveBreakpoints.builder(
child: ClampingScrollWrapper.builder(context, content),
breakpoints: const [
Breakpoint(start: 0, end: 450, name: MOBILE),
Breakpoint(start: 451, end: 800, name: TABLET),
Breakpoint(start: 801, end: 1920, name: DESKTOP),
Breakpoint(start: 1921, end: double.infinity, name: '4K'),
],
);
}, },
); );
}, },

View File

@ -1,6 +1,4 @@
import 'dart:io' show Platform; import 'package:file_selector/file_selector.dart' as fs;
import 'package:file_picker/file_picker.dart' as fp;
import 'package:path_provider/path_provider.dart' as pp;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -74,51 +72,13 @@ class PdfExportViewModel extends ChangeNotifier {
static Future<String?> _defaultSavePathPickerWithSuggestedName( static Future<String?> _defaultSavePathPickerWithSuggestedName(
String suggestedName, String suggestedName,
) async { ) async {
// Prefer native save dialog via file_picker on all non-web platforms. final group = fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
// If the user cancels (null) simply bubble up null. If an exception occurs final location = await fs.getSaveLocation(
// (unsupported platform or plugin issue), fall back to an app documents path. acceptedTypeGroups: [group],
try { suggestedName: suggestedName,
final result = await fp.FilePicker.platform.saveFile( confirmButtonText: 'Save',
dialogTitle: 'Please select an output file:',
fileName: suggestedName,
type: fp.FileType.custom,
allowedExtensions: const ['pdf'],
// lockParentWindow is ignored on mobile; useful on desktop.
lockParentWindow: true,
);
return result; // null if canceled
} catch (_) {
// Fall through to app documents fallback below.
}
debugPrint(
'Fallback: select a folder and build path with suggested name (mobile platform)',
); );
return location?.path; // null if user cancels
// On some mobile providers, saveFile may not present a picker or returns null.
// Offer a folder picker and compose the final path.
if (Platform.isAndroid || Platform.isIOS) {
final dir = await fp.FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select folder to save',
lockParentWindow: true,
);
if (dir != null && dir.trim().isNotEmpty) {
final d = dir.trim();
final needsSep = !(d.endsWith('/') || d.endsWith('\\'));
return (needsSep ? (d + '/') : d) + suggestedName;
}
// User canceled directory selection; bubble up null.
return null;
}
debugPrint('Fallback: build a default path (web platform)');
try {
final dir = await pp.getApplicationDocumentsDirectory();
return '${dir.path}/$suggestedName';
} catch (_) {
// Last resort: let the caller handle a null path
return null;
}
} }
} }

View File

@ -1,12 +1,12 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart'; import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/model.dart'; import 'package:pdf_signature/domain/models/model.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:file_picker/file_picker.dart' as fp; import 'package:file_selector/file_selector.dart' as fs;
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class PdfViewModel extends ChangeNotifier { class PdfViewModel extends ChangeNotifier {
@ -247,7 +247,7 @@ final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
class PdfSessionViewModel extends ChangeNotifier { class PdfSessionViewModel extends ChangeNotifier {
final Ref ref; final Ref ref;
final GoRouter router; final GoRouter router;
XFile _currentFile = XFile(''); fs.XFile _currentFile = fs.XFile('');
// Keep a human display name in addition to XFile, because on Linux via // Keep a human display name in addition to XFile, because on Linux via
// xdg-desktop-portal the path can look like /run/user/.../doc/<UUID>, and // xdg-desktop-portal the path can look like /run/user/.../doc/<UUID>, and
// XFile.name derives from that basename, yielding a random UUID instead of // XFile.name derives from that basename, yielding a random UUID instead of
@ -257,29 +257,21 @@ class PdfSessionViewModel extends ChangeNotifier {
PdfSessionViewModel({required this.ref, required this.router}); PdfSessionViewModel({required this.ref, required this.router});
XFile get currentFile => _currentFile; fs.XFile get currentFile => _currentFile;
String get displayFileName => _displayFileName; String get displayFileName => _displayFileName;
Future<void> pickAndOpenPdf() async { Future<void> pickAndOpenPdf() async {
final result = await fp.FilePicker.platform.pickFiles( final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
type: fp.FileType.custom, final XFile? file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
allowedExtensions: const ['pdf'], if (file != null) {
withData: true, Uint8List? bytes;
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.single;
final String name = picked.name;
final String? path = picked.path;
final Uint8List? bytes = picked.bytes;
Uint8List? effectiveBytes = bytes;
if (effectiveBytes == null && path != null && path.isNotEmpty) {
try { try {
effectiveBytes = await XFile(path).readAsBytes(); bytes = await file.readAsBytes();
} catch (_) { } catch (_) {
effectiveBytes = null; bytes = null;
} }
await openPdf(path: file.path, bytes: bytes, fileName: file.name);
} }
await openPdf(path: path, bytes: effectiveBytes, fileName: name);
} }
Future<void> openPdf({ Future<void> openPdf({
@ -297,20 +289,20 @@ class PdfSessionViewModel extends ChangeNotifier {
} }
} }
if (path != null && path.isNotEmpty) { if (path != null && path.isNotEmpty) {
_currentFile = XFile(path); _currentFile = fs.XFile(path);
} else if (bytes != null && (fileName != null && fileName.isNotEmpty)) { } else if (bytes != null && (fileName != null && fileName.isNotEmpty)) {
// Keep in-memory XFile so .name is available for suggestion // Keep in-memory XFile so .name is available for suggestion
try { try {
_currentFile = XFile.fromData( _currentFile = fs.XFile.fromData(
bytes, bytes,
name: fileName, name: fileName,
mimeType: 'application/pdf', mimeType: 'application/pdf',
); );
} catch (_) { } catch (_) {
_currentFile = XFile(fileName); _currentFile = fs.XFile(fileName);
} }
} else { } else {
_currentFile = XFile(''); _currentFile = fs.XFile('');
} }
// Update display name: prefer explicit fileName (from picker/drop), // Update display name: prefer explicit fileName (from picker/drop),
@ -333,7 +325,7 @@ class PdfSessionViewModel extends ChangeNotifier {
void closePdf() { void closePdf() {
ref.read(documentRepositoryProvider.notifier).close(); ref.read(documentRepositoryProvider.notifier).close();
ref.read(signatureCardRepositoryProvider.notifier).clearAll(); ref.read(signatureCardRepositoryProvider.notifier).clearAll();
_currentFile = XFile(''); _currentFile = fs.XFile('');
_displayFileName = ''; _displayFileName = '';
router.go('/'); router.go('/');
notifyListeners(); notifyListeners();

View File

@ -93,18 +93,14 @@ class ThumbnailsView extends ConsumerWidget {
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: Column( child: Column(
children: [ children: [
ConstrainedBox( SizedBox(
constraints: const BoxConstraints(maxHeight: 180), height: 180,
child: AspectRatio( child: ClipRRect(
// A4 portrait aspect: width:height 1:1.4142 borderRadius: BorderRadius.circular(4),
aspectRatio: 1 / 1.4142, child: PdfPageView(
child: ClipRRect( document: document,
borderRadius: BorderRadius.circular(4), pageNumber: pageNumber,
child: PdfPageView( alignment: Alignment.center,
document: document,
pageNumber: pageNumber,
alignment: Alignment.center,
),
), ),
), ),
), ),

View File

@ -1,5 +1,4 @@
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart' as fs;
import 'package:file_picker/file_picker.dart' as fp;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -18,12 +17,11 @@ import 'package:pdf_signature/utils/download.dart';
import '../view_model/pdf_view_model.dart'; import '../view_model/pdf_view_model.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:pdf_signature/data/repositories/document_repository.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:responsive_framework/responsive_framework.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget { class PdfSignatureHomePage extends ConsumerStatefulWidget {
final Future<void> Function() onPickPdf; final Future<void> Function() onPickPdf;
final VoidCallback onClosePdf; final VoidCallback onClosePdf;
final XFile currentFile; final fs.XFile currentFile;
// Optional display name for the currently opened file. On Linux // Optional display name for the currently opened file. On Linux
// xdg-desktop-portal, XFile.name/path can be a UUID-like value. When // xdg-desktop-portal, XFile.name/path can be a UUID-like value. When
// available, this name preserves the user-selected filename so we can // available, this name preserves the user-selected filename so we can
@ -60,7 +58,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final double _signaturesMin = 140; final double _signaturesMin = 140;
final double _signaturesMax = 250; final double _signaturesMax = 250;
late PdfViewModel _viewModel; late PdfViewModel _viewModel;
bool? _lastCanShowPagesSidebar;
// Exposed for tests to trigger the invalid-file SnackBar without UI. // Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting @visibleForTesting
@ -114,18 +111,15 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
} }
Future<img.Image?> _loadSignatureFromFile() async { Future<img.Image?> _loadSignatureFromFile() async {
final result = await fp.FilePicker.platform.pickFiles( final typeGroup = fs.XTypeGroup(
type: fp.FileType.custom, label:
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'], Localizations.of<AppLocalizations>(context, AppLocalizations)?.image,
withData: true, extensions: ['png', 'jpg', 'jpeg', 'webp'],
); );
if (result == null || result.files.isEmpty) return null; final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
final picked = result.files.single; if (file == null) return null;
final Uint8List? bytes = final bytes = await file.readAsBytes();
picked.bytes ??
(picked.path != null ? await XFile(picked.path!).readAsBytes() : null);
try { try {
if (bytes == null) return null;
var sigImage = img.decodeImage(bytes); var sigImage = img.decodeImage(bytes);
return _toStdSignatureImage(sigImage); return _toStdSignatureImage(sigImage);
} catch (_) { } catch (_) {
@ -312,9 +306,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
max: _pagesMax, max: _pagesMax,
builder: builder:
(context, area) => Offstage( (context, area) => Offstage(
offstage: offstage: !_showPagesSidebar,
!(ResponsiveBreakpoints.of(context).largerThan(MOBILE) &&
_showPagesSidebar),
child: Consumer( child: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final pdfViewModel = ref.watch(pdfViewModelProvider); final pdfViewModel = ref.watch(pdfViewModelProvider);
@ -368,24 +360,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
_applySidebarVisibility(); _applySidebarVisibility();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Detect breakpoint changes from Responsive Framework and update areas once.
bool canShowPagesSidebar = true;
try {
canShowPagesSidebar = ResponsiveBreakpoints.of(
context,
).largerThan(MOBILE);
} catch (_) {
canShowPagesSidebar = true;
}
if (_lastCanShowPagesSidebar != canShowPagesSidebar) {
_lastCanShowPagesSidebar = canShowPagesSidebar;
_applySidebarVisibility();
}
}
@override @override
void dispose() { void dispose() {
_viewModel.controller.removeListener(_onControllerChanged); _viewModel.controller.removeListener(_onControllerChanged);
@ -394,65 +368,29 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
} }
void _applySidebarVisibility() { void _applySidebarVisibility() {
// Respect responsive layout: disable Pages sidebar on MOBILE.
bool canShowPagesSidebar = true;
try {
canShowPagesSidebar = ResponsiveBreakpoints.of(
context,
).largerThan(MOBILE);
} catch (_) {
// If ResponsiveBreakpoints isn't available yet (e.g., during early init),
// fall back to allowing sidebars to avoid crashes; builders also guard.
canShowPagesSidebar = true;
}
// Left pages sidebar // Left pages sidebar
final left = _splitController.areas[0]; final left = _splitController.areas[0];
final wantPagesVisible = _showPagesSidebar && canShowPagesSidebar; if (_showPagesSidebar) {
final isPagesHidden = left.max = _pagesMax;
(left.max == 1 && left.min == 0 && (left.size ?? 1) == 1); left.min = _pagesMin;
if (wantPagesVisible) { left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
// Only expand if currently hidden; otherwise keep user's size.
if (isPagesHidden) {
left.max = _pagesMax;
left.min = _pagesMin;
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
} else {
left.max = _pagesMax;
left.min = _pagesMin;
// Preserve current size (user may have adjusted it).
_lastPagesWidth = left.size ?? _lastPagesWidth;
}
} else { } else {
// Only collapse if currently visible; remember current size for restore. _lastPagesWidth = left.size ?? _lastPagesWidth;
if (!isPagesHidden) { left.min = 0;
_lastPagesWidth = left.size ?? _lastPagesWidth; left.max = 1;
left.min = 0; left.size = 1; // effectively hidden
left.max = 1;
left.size = 1; // effectively hidden
}
} }
// Right signatures sidebar // Right signatures sidebar
final right = _splitController.areas[2]; final right = _splitController.areas[2];
final isSignaturesHidden =
(right.max == 1 && right.min == 0 && (right.size ?? 1) == 1);
if (_showSignaturesSidebar) { if (_showSignaturesSidebar) {
if (isSignaturesHidden) { right.max = _signaturesMax;
right.max = _signaturesMax; right.min = _signaturesMin;
right.min = _signaturesMin; right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
} else {
right.max = _signaturesMax;
right.min = _signaturesMin;
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
}
} else { } else {
if (!isSignaturesHidden) { _lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth; right.min = 0;
right.min = 0; right.max = 1;
right.max = 1; right.size = 1;
right.size = 1;
}
} }
} }

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.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:responsive_framework/responsive_framework.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart'; import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
@ -68,16 +67,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
builder: (context, constraints) { builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260; final bool compact = constraints.maxWidth < 260;
final double gotoWidth = 50; final double gotoWidth = 50;
final bool isLargerThanMobile = ResponsiveBreakpoints.of(
context,
).largerThan(MOBILE);
final String fileDisplay = () {
final path = widget.filePath;
if (path == null || path.isEmpty) return 'No file selected';
if (isLargerThanMobile) return path;
// Extract file name for mobile (supports both / and \ separators)
return path.split('/').last.split('\\').last;
}();
// Center content of the toolbar // Center content of the toolbar
final center = Wrap( final center = Wrap(
@ -93,15 +82,14 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
children: [ children: [
const Icon(Icons.insert_drive_file, size: 18), const Icon(Icons.insert_drive_file, size: 18),
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( ConstrainedBox(
child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 220),
constraints: const BoxConstraints(maxWidth: 220), child: Text(
child: Text( // if filePath not null
fileDisplay, widget.filePath != null
maxLines: 1, ? widget.filePath!
softWrap: false, : 'No file selected',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
),
), ),
), ),
], ],
@ -142,68 +130,62 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
), ),
], ],
), ),
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) Wrap(
Wrap( spacing: 6,
spacing: 6, runSpacing: 4,
runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center, children: [
children: [ Text(l.goTo),
Text(l.goTo), SizedBox(
SizedBox( width: gotoWidth,
width: gotoWidth, child: TextField(
child: TextField( key: const Key('txt_goto'),
key: const Key('txt_goto'), controller: _goToController,
controller: _goToController, keyboardType: TextInputType.number,
keyboardType: TextInputType.number, inputFormatters: [
inputFormatters: [ FilteringTextInputFormatter.digitsOnly,
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( if (!compact)
key: const Key('btn_goto_apply'),
tooltip: l.goTo,
icon: const Icon(Icons.arrow_forward),
onPressed: widget.disabled ? null : _submitGoTo,
),
],
),
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[
const SizedBox(width: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton( IconButton(
key: const Key('btn_zoom_out'), key: const Key('btn_goto_apply'),
tooltip: 'Zoom out', tooltip: l.goTo,
onPressed: widget.disabled ? null : widget.onZoomOut, icon: const Icon(Icons.arrow_forward),
icon: const Icon(Icons.zoom_out), onPressed: widget.disabled ? null : _submitGoTo,
), ),
Text( ],
//if not null ),
widget.zoomLevel != null const SizedBox(width: 8),
? '${widget.zoomLevel}%' Wrap(
: '', crossAxisAlignment: WrapCrossAlignment.center,
style: const TextStyle(fontSize: 12), children: [
), IconButton(
IconButton( key: const Key('btn_zoom_out'),
key: const Key('btn_zoom_in'), tooltip: 'Zoom out',
tooltip: 'Zoom in', onPressed: widget.disabled ? null : widget.onZoomOut,
onPressed: widget.disabled ? null : widget.onZoomIn, icon: const Icon(Icons.zoom_out),
icon: const Icon(Icons.zoom_in), ),
), Text(
], //if not null
), widget.zoomLevel != null ? '${widget.zoomLevel}%' : '',
SizedBox(width: 6), style: const TextStyle(fontSize: 12),
], ),
IconButton(
key: const Key('btn_zoom_in'),
tooltip: 'Zoom in',
onPressed: widget.disabled ? null : widget.onZoomIn,
icon: const Icon(Icons.zoom_in),
),
],
),
SizedBox(width: 6),
], ],
), ),
], ],
@ -212,21 +194,19 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return Row( return Row(
children: [ children: [
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[ IconButton(
IconButton( key: const Key('btn_toggle_pages_sidebar'),
key: const Key('btn_toggle_pages_sidebar'), tooltip: 'Toggle pages overview',
tooltip: 'Toggle pages overview', onPressed: widget.disabled ? null : widget.onTogglePagesSidebar,
onPressed: widget.disabled ? null : widget.onTogglePagesSidebar, icon: Icon(
icon: Icon( Icons.view_sidebar,
Icons.view_sidebar, color:
color: widget.showPagesSidebar
widget.showPagesSidebar ? Theme.of(context).colorScheme.primary
? Theme.of(context).colorScheme.primary : null,
: null,
),
), ),
const SizedBox(width: 8), ),
], const SizedBox(width: 8),
Expanded(child: center), Expanded(child: center),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(

View File

@ -37,6 +37,7 @@ dependencies:
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
flutter_dotenv: ^6.0.0 flutter_dotenv: ^6.0.0
file_selector: ^1.0.3
path_provider: ^2.1.5 path_provider: ^2.1.5
pdfrx: ^2.1.9 pdfrx: ^2.1.9
pdf: ^3.10.8 pdf: ^3.10.8
@ -58,8 +59,6 @@ dependencies:
riverpod_annotation: ^2.6.1 riverpod_annotation: ^2.6.1
colorfilter_generator: ^0.0.8 colorfilter_generator: ^0.0.8
flutter_box_transform: ^0.4.7 flutter_box_transform: ^0.4.7
file_picker: ^10.3.3
responsive_framework: ^1.5.1
# disable_web_context_menu: ^1.1.0 # disable_web_context_menu: ^1.1.0
# ml_linalg: ^13.12.6 # ml_linalg: ^13.12.6

View File

@ -1,5 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -14,26 +14,6 @@ import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart'; import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/l10n/app_localizations.dart'; import 'package:pdf_signature/l10n/app_localizations.dart';
// A fake export VM that always reports success, so this widget test doesn't
// depend on PDF validity or platform specifics.
bool exported = false;
class _FakePdfExportViewModel extends PdfExportViewModel {
_FakePdfExportViewModel(Ref ref)
: super(ref, savePathPicker: () async => 'C:/tmp/output.pdf');
@override
Future<bool> exportToPath({
required String outputPath,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
exported = true;
return true;
}
}
void main() { void main() {
testWidgets('Save uses file selector (via provider) and injected exporter', ( testWidgets('Save uses file selector (via provider) and injected exporter', (
tester, tester,
@ -55,7 +35,10 @@ void main() {
(ref) => PdfViewModel(ref, useMockViewer: true), (ref) => PdfViewModel(ref, useMockViewer: true),
), ),
pdfExportViewModelProvider.overrideWith( pdfExportViewModelProvider.overrideWith(
(ref) => _FakePdfExportViewModel(ref), (ref) => PdfExportViewModel(
ref,
savePathPicker: () async => 'C:/tmp/output.pdf',
),
), ),
], ],
child: MaterialApp( child: MaterialApp(
@ -64,7 +47,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile(''), currentFile: fs.XFile(''),
), ),
), ),
), ),
@ -74,10 +57,10 @@ void main() {
// Trigger save directly (mark toggle no longer required) // Trigger save directly (mark toggle no longer required)
await tester.tap(find.byKey(const Key('btn_save_pdf'))); await tester.tap(find.byKey(const Key('btn_save_pdf')));
// Pump a bit to allow async export flow to run. await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 200)); // Expect success UI (localized)
// Basic assertion: export was invoked expect(find.textContaining('Saved:'), findsOneWidget);
expect(exported, isTrue); // Basic assertion: a save flow completed and snackbar showed
}); });
} }

View File

@ -1,5 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -36,7 +36,7 @@ Future<void> pumpWithOpenPdf(WidgetTester tester) async {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile(''), currentFile: fs.XFile(''),
), ),
), ),
), ),
@ -413,7 +413,7 @@ Future<void> pumpWithOpenPdfAndSig(WidgetTester tester) async {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile(''), currentFile: fs.XFile(''),
), ),
), ),
), ),

View File

@ -1,4 +1,4 @@
import 'package:cross_file/cross_file.dart'; import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -38,7 +38,7 @@ void main() {
home: PdfSignatureHomePage( home: PdfSignatureHomePage(
onPickPdf: () async {}, onPickPdf: () async {},
onClosePdf: () {}, onClosePdf: () {},
currentFile: XFile(''), currentFile: fs.XFile(''),
), ),
), ),
), ),

View File

@ -12,15 +12,7 @@ import 'dart:io';
/// --reporter=compact /// --reporter=compact
/// --pattern=*.dart (all files in integration_test/) /// --pattern=*.dart (all files in integration_test/)
Future<int> main(List<String> args) async { Future<int> main(List<String> args) async {
// Default device depends on host OS for a better out-of-the-box experience. String device = 'linux';
String device =
Platform.isWindows
? 'windows'
: Platform.isMacOS
? 'macos'
: Platform.isLinux
? 'linux'
: 'chrome';
String reporter = 'compact'; String reporter = 'compact';
String pattern = '*.dart'; String pattern = '*.dart';
@ -92,34 +84,16 @@ Future<int> main(List<String> args) async {
return 3; return 3;
} }
// Normalize and map device aliases (helpful on Windows/macOS)
device = _normalizedDeviceId(device);
// Preflight: ensure `flutter` is invokable in this environment.
final flutterOk = await _checkFlutterAvailable();
if (!flutterOk) {
stderr.writeln(
'Could not execute `flutter`. Ensure Flutter is installed and on PATH.',
);
return 4;
}
stdout.writeln( stdout.writeln(
'Running ${selected.length} integration test file(s) sequentially on device: $device...', 'Running ${selected.length} integration test file(s) sequentially...',
); );
final results = <String, int>{}; final results = <String, int>{};
for (final f in selected) { for (final f in selected) {
// Convert to forward slashes for tool compatibility across platforms. final rel = f.path;
final rel = f.path.replaceAll('\\', '/');
stdout.writeln('\n=== Running: $rel ==='); stdout.writeln('\n=== Running: $rel ===');
final args = <String>['test', rel, '-d', device, '-r', reporter]; final args = <String>['test', rel, '-d', device, '-r', reporter];
stdout.writeln('> flutter ${args.join(' ')}'); final proc = await Process.start('flutter', args);
final proc = await Process.start(
'flutter',
args,
runInShell: Platform.isWindows, // ensures flutter.bat resolves on Windows
);
// Pipe output live // Pipe output live
unawaited(proc.stdout.transform(utf8.decoder).forEach(stdout.write)); unawaited(proc.stdout.transform(utf8.decoder).forEach(stdout.write));
unawaited(proc.stderr.transform(utf8.decoder).forEach(stderr.write)); unawaited(proc.stderr.transform(utf8.decoder).forEach(stderr.write));
@ -130,12 +104,8 @@ Future<int> main(List<String> args) async {
} else { } else {
stderr.writeln('=== FAILED (exit $code): $rel ==='); stderr.writeln('=== FAILED (exit $code): $rel ===');
} }
// Small pause between launches to let desktop/device settle (slightly longer for desktop) // Small pause between launches to let desktop/device settle
await Future<void>.delayed( await Future<void>.delayed(const Duration(milliseconds: 300));
Platform.isWindows || Platform.isMacOS || Platform.isLinux
? const Duration(milliseconds: 1200)
: const Duration(milliseconds: 300),
);
} }
stdout.writeln('\nSummary:'); stdout.writeln('\nSummary:');
@ -148,38 +118,3 @@ Future<int> main(List<String> args) async {
return failures == 0 ? 0 : 1; return failures == 0 ? 0 : 1;
} }
String _normalizedDeviceId(String input) {
final lower = input.toLowerCase();
switch (lower) {
case 'win':
case 'windows':
case 'windows-desktop':
return 'windows';
case 'mac':
case 'macos':
case 'darwin':
return 'macos';
case 'linux':
case 'gnu/linux':
return 'linux';
case 'web':
case 'chrome':
case 'browser':
return 'chrome';
default:
return input; // assume caller provided a concrete device id
}
}
Future<bool> _checkFlutterAvailable() async {
try {
final result = await Process.run('flutter', const [
'--version',
'--suppress-analytics',
], runInShell: Platform.isWindows);
return result.exitCode == 0;
} catch (_) {
return false;
}
}