feat: integrate responsive framework for improved layout handling

This commit is contained in:
insleker 2025-09-22 13:34:49 +08:00
parent 0a512919a5
commit 353aa883d7
6 changed files with 194 additions and 97 deletions

View File

@ -94,3 +94,5 @@ 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

@ -5,6 +5,7 @@ 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});
@ -56,7 +57,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);
return Scaffold( final content = Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(AppLocalizations.of(context).appTitle), title: Text(AppLocalizations.of(context).appTitle),
actions: [ actions: [
@ -78,6 +79,17 @@ 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

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

View File

@ -17,6 +17,7 @@ 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;
@ -58,6 +59,7 @@ 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
@ -306,7 +308,9 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
max: _pagesMax, max: _pagesMax,
builder: builder:
(context, area) => Offstage( (context, area) => Offstage(
offstage: !_showPagesSidebar, offstage:
!(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);
@ -360,6 +364,24 @@ 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);
@ -368,29 +390,65 @@ 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];
if (_showPagesSidebar) { final wantPagesVisible = _showPagesSidebar && canShowPagesSidebar;
left.max = _pagesMax; final isPagesHidden =
left.min = _pagesMin; (left.max == 1 && left.min == 0 && (left.size ?? 1) == 1);
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax); if (wantPagesVisible) {
// 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 {
_lastPagesWidth = left.size ?? _lastPagesWidth; // Only collapse if currently visible; remember current size for restore.
left.min = 0; if (!isPagesHidden) {
left.max = 1; _lastPagesWidth = left.size ?? _lastPagesWidth;
left.size = 1; // effectively hidden left.min = 0;
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) {
right.max = _signaturesMax; if (isSignaturesHidden) {
right.min = _signaturesMin; right.max = _signaturesMax;
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax); right.min = _signaturesMin;
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
} else {
right.max = _signaturesMax;
right.min = _signaturesMin;
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
}
} else { } else {
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth; if (!isSignaturesHidden) {
right.min = 0; _lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
right.max = 1; right.min = 0;
right.size = 1; right.max = 1;
right.size = 1;
}
} }
} }

View File

@ -2,6 +2,7 @@ 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';
@ -67,6 +68,16 @@ 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(
@ -82,14 +93,15 @@ 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),
ConstrainedBox( Flexible(
constraints: const BoxConstraints(maxWidth: 220), child: ConstrainedBox(
child: Text( constraints: const BoxConstraints(maxWidth: 220),
// if filePath not null child: Text(
widget.filePath != null fileDisplay,
? widget.filePath! maxLines: 1,
: 'No file selected', softWrap: false,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
),
), ),
), ),
], ],
@ -130,62 +142,68 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
), ),
], ],
), ),
Wrap( if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
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, ],
decoration: InputDecoration( enabled: !widget.disabled,
isDense: true, decoration: InputDecoration(
hintText: '1..${pdf.pageCount}', isDense: true,
hintText: '1..${pdf.pageCount}',
),
onSubmitted: (_) => _submitGoTo(),
), ),
onSubmitted: (_) => _submitGoTo(),
), ),
), if (!compact)
if (!compact) IconButton(
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_goto_apply'), key: const Key('btn_zoom_out'),
tooltip: l.goTo, tooltip: 'Zoom out',
icon: const Icon(Icons.arrow_forward), onPressed: widget.disabled ? null : widget.onZoomOut,
onPressed: widget.disabled ? null : _submitGoTo, icon: const Icon(Icons.zoom_out),
), ),
], Text(
), //if not null
const SizedBox(width: 8), widget.zoomLevel != null
Wrap( ? '${widget.zoomLevel}%'
crossAxisAlignment: WrapCrossAlignment.center, : '',
children: [ style: const TextStyle(fontSize: 12),
IconButton( ),
key: const Key('btn_zoom_out'), IconButton(
tooltip: 'Zoom out', key: const Key('btn_zoom_in'),
onPressed: widget.disabled ? null : widget.onZoomOut, tooltip: 'Zoom in',
icon: const Icon(Icons.zoom_out), onPressed: widget.disabled ? null : widget.onZoomIn,
), icon: const Icon(Icons.zoom_in),
Text( ),
//if not null ],
widget.zoomLevel != null ? '${widget.zoomLevel}%' : '', ),
style: const TextStyle(fontSize: 12), SizedBox(width: 6),
), ],
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),
], ],
), ),
], ],
@ -194,19 +212,21 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return Row( return Row(
children: [ children: [
IconButton( if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) ...[
key: const Key('btn_toggle_pages_sidebar'), IconButton(
tooltip: 'Toggle pages overview', key: const Key('btn_toggle_pages_sidebar'),
onPressed: widget.disabled ? null : widget.onTogglePagesSidebar, tooltip: 'Toggle pages overview',
icon: Icon( onPressed: widget.disabled ? null : widget.onTogglePagesSidebar,
Icons.view_sidebar, icon: Icon(
color: Icons.view_sidebar,
widget.showPagesSidebar color:
? Theme.of(context).colorScheme.primary widget.showPagesSidebar
: null, ? Theme.of(context).colorScheme.primary
: 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

@ -59,6 +59,7 @@ 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
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