Compare commits

..

No commits in common. "main" and "v1.1.0" have entirely different histories.
main ... v1.1.0

137 changed files with 1500 additions and 4286 deletions

View File

@ -2,8 +2,6 @@
.dockerignore
.git
.gitignore
.env
.secrets
.idea
.vscode
**/.DS_Store

View File

@ -1,25 +0,0 @@
version: 2
updates:
# Dart/Flutter dependencies (pubspec.yaml)
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "cron"
cronjob: "0 0 * * 2,5"
timezone: "Etc/UTC"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "dart"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "cron"
cronjob: "0 0 * * 2,5"
timezone: "Etc/UTC"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"

View File

@ -24,16 +24,16 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up QEMU (for multi-arch builds)
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', 'pubspec.lock') }}
@ -42,7 +42,7 @@ jobs:
# Docker Hub login (active)
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -50,7 +50,7 @@ jobs:
# GHCR login (commented out for future usage)
# - name: Log in to GitHub Container Registry
# uses: docker/login-action@v4
# uses: docker/login-action@v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
@ -58,7 +58,7 @@ jobs:
- name: Extract Docker metadata (tags, labels)
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@ -71,7 +71,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile

View File

@ -1,51 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Run build_runner
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Run tests
run: flutter test
- name: Build web
run: flutter build web --base-href /pdf_signature/ --release -O4 --wasm
- name: Deploy to gh-pages branch
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build/web
publish_branch: gh-pages
exclude_assets: 'pr-preview/**'

View File

@ -1,58 +0,0 @@
name: Deploy PR Preview
on:
pull_request:
types:
- opened
- reopened
- synchronize
- closed
concurrency: preview-${{ github.ref }}
permissions:
contents: write
pull-requests: write
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
repository: ${{ github.repository }}
ref: refs/pull/${{ github.event.pull_request.number }}/merge
fetch-depth: 1
persist-credentials: false
- name: Setup Flutter
if: github.event.action != 'closed'
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install dependencies
if: github.event.action != 'closed'
run: flutter pub get
- name: Run build_runner
if: github.event.action != 'closed'
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Run tests
if: github.event.action != 'closed'
run: flutter test
- name: Build web
if: github.event.action != 'closed'
run: flutter build web --base-href /pdf_signature/pr-preview/pr-${{ github.event.number }}/ --release -O4 --wasm
- name: Deploy preview
uses: rossjrw/pr-preview-action@v1
with:
source-dir: ./build/web/
preview-branch: gh-pages
umbrella-dir: pr-preview
action: auto

View File

@ -1,72 +0,0 @@
name: Run unit tests on Pull Request
on:
pull_request:
branches:
- main
types: [opened, synchronize, reopened]
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: "pr-unit-tests"
cancel-in-progress: false
jobs:
unit-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- name: Checkout repository
uses: actions/checkout@v6
continue-on-error: true
id: checkout_https
with:
repository: ${{ github.repository }}
ref: refs/pull/${{ github.event.pull_request.number }}/merge
fetch-depth: 1
persist-credentials: false
- name: Checkout repository via SSH (fallback)
if: steps.checkout_https.outcome == 'failure'
uses: actions/checkout@v6
with:
repository: ${{ github.repository }}
ref: refs/pull/${{ github.event.pull_request.number }}/merge
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh-strict: false
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Run build_runner (if needed)
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Run static analysis (for logs)
run: flutter analyze || true
- name: Setup reviewdog
uses: reviewdog/action-setup@v1
with:
reviewdog_version: latest
- name: Annotate analyzer results with reviewdog
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# run analyzer and pipe into reviewdog so PRs get inline annotations
flutter analyze 2>&1 | reviewdog -efm="%f:%l:%c: %m" -name="flutter analyze" -reporter=github-pr-check -filter-mode=added -level=warning || true
# - name: Run tests
# run: flutter test

1
.gitignore vendored
View File

@ -139,4 +139,3 @@ appimage-build/
*.patch
*.freezed.dart
*.g.dart
/output/

View File

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "ac4e799d237041cf905519190471f657b657155a"
revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1"
channel: "stable"
project_type: app
@ -13,23 +13,26 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: ac4e799d237041cf905519190471f657b657155a
base_revision: ac4e799d237041cf905519190471f657b657155a
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: android
create_revision: ac4e799d237041cf905519190471f657b657155a
base_revision: ac4e799d237041cf905519190471f657b657155a
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: ios
create_revision: ac4e799d237041cf905519190471f657b657155a
base_revision: ac4e799d237041cf905519190471f657b657155a
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: linux
create_revision: ac4e799d237041cf905519190471f657b657155a
base_revision: ac4e799d237041cf905519190471f657b657155a
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: macos
create_revision: ac4e799d237041cf905519190471f657b657155a
base_revision: ac4e799d237041cf905519190471f657b657155a
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: web
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: windows
create_revision: ac4e799d237041cf905519190471f657b657155a
base_revision: ac4e799d237041cf905519190471f657b657155a
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
# User provided section

View File

@ -1,10 +1,7 @@
## 1.1.1
## 1.1.0
* refactor to clear domain models
* follow MVVM
## 1.0.0

View File

@ -1,123 +0,0 @@
# Multi-arch Dockerfile for building Flutter Linux app images
# - Uses BuildKit platform args to avoid hardcoding platforms in FROM
# - Builds on the target architecture (via qemu when needed)
# - Fixes: https://docs.docker.com/reference/build-checks/from-platform-flag-const-disallowed/
# BuildKit-provided args available before FROM. Used to parameterize platforms.
ARG TARGETPLATFORM
ARG TARGETARCH
ARG BUILDPLATFORM
# Build with (single arch, locally loadable):
# docker buildx build --platform linux/amd64 -f Dockerfile.arm64 -t pdf_signature:amd64 --load .
# docker buildx build --platform linux/arm64 -f Dockerfile.arm64 -t pdf_signature:arm64 --load .
# Build multi-arch (requires a registry):
# docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.arm64 -t <your-registry>/pdf_signature:latest --push .
# ----------------------------------------------------------------------------
# Build stage (Ubuntu 18.04) — glibc 2.27 to maximize compatibility (<= 2.30)
# ----------------------------------------------------------------------------
FROM ubuntu:18.04 AS build
# Re-declare BuildKit args within the stage for RUN/CMD/ENV usage
ARG TARGETPLATFORM
ARG TARGETARCH
ARG BUILDPLATFORM
ENV DEBIAN_FRONTEND=noninteractive \
TZ=UTC \
FLUTTER_CHANNEL=stable
# Install build dependencies and tooling
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
git \
unzip \
xz-utils \
clang \
ninja-build \
pkg-config \
libgtk-3-dev \
libblkid-dev \
liblzma-dev \
&& rm -rf /var/lib/apt/lists/*
# Install a newer CMake (>= 3.13 required by Flutter linux build) per-arch
ARG CMAKE_VERSION=3.27.9
RUN set -eux; \
case "$TARGETARCH" in \
arm64) CMAKE_ARCH="aarch64" ;; \
amd64) CMAKE_ARCH="x86_64" ;; \
*) echo "Unsupported TARGETARCH for CMake: $TARGETARCH" >&2; exit 1 ;; \
esac; \
curl -fsSL -o /tmp/cmake.tar.gz \
https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-${CMAKE_ARCH}.tar.gz; \
tar -C /opt -xzf /tmp/cmake.tar.gz; \
ln -sf /opt/cmake-${CMAKE_VERSION}-linux-${CMAKE_ARCH}/bin/cmake /usr/local/bin/cmake; \
ln -sf /opt/cmake-${CMAKE_VERSION}-linux-${CMAKE_ARCH}/bin/ctest /usr/local/bin/ctest; \
ln -sf /opt/cmake-${CMAKE_VERSION}-linux-${CMAKE_ARCH}/bin/cpack /usr/local/bin/cpack; \
rm -f /tmp/cmake.tar.gz
# Install Flutter SDK (ARM64) from source repo (channel: stable)
RUN git clone -b ${FLUTTER_CHANNEL} https://github.com/flutter/flutter.git /opt/flutter
ENV PATH="/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:${PATH}"
# Precache Linux desktop artifacts and enable linux desktop
RUN flutter config --enable-linux-desktop && \
flutter precache --linux
# Set working directory
WORKDIR /app
# Copy pubspec files first for better caching
COPY pubspec.* ./
# Get dependencies
RUN flutter pub get
# Copy the rest of the project
COPY . .
# Ensure Linux desktop project files are present (do not overwrite existing files/pubspec)
RUN [ -d linux ] || flutter create . --platforms=linux
# Refresh dependencies after full context is copied
RUN flutter pub get
# Generate build_runner outputs if codegen files are referenced
RUN bash -lc "if grep -R \"part '.*\\.g\\.dart'\|part '.*\\.freezed\\.dart'\" -n lib >/dev/null; then dart --disable-analytics >/dev/null 2>&1 || true; dart run build_runner build --delete-conflicting-outputs; fi"
# Build the Linux app for the target architecture (x64 on amd64; arm64 on arm64)
RUN set -eux; \
case "$TARGETARCH" in \
amd64) FLUTTER_LINUX_TARGET="linux-x64" ;; \
arm64) FLUTTER_LINUX_TARGET="linux-arm64" ;; \
*) echo "Unsupported TARGETARCH for Flutter: $TARGETARCH" >&2; exit 1 ;; \
esac; \
flutter build linux --target-platform "${FLUTTER_LINUX_TARGET}" --release
# ----------------------------------------------------------------------------
# Runtime stage (Ubuntu 18.04) — matches glibc from builder (2.27)
# ----------------------------------------------------------------------------
FROM ubuntu:18.04 AS runtime
ENV DEBIAN_FRONTEND=noninteractive TZ=UTC
# Install runtime dependencies only (do not ship libc from builder)
RUN apt-get update && apt-get install -y \
libgtk-3-0 \
libblkid1 \
liblzma5 \
libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
# Copy the built app
# Use wildcard to accommodate both x64 and arm64 bundle directories
COPY --from=build /app/build/linux/*/release/bundle /app
# Set working directory
WORKDIR /app
# Run the app with bundled libraries
CMD ["./pdf_signature"]

View File

@ -22,9 +22,8 @@ flutter analyze
# > run unit tests and widget tests
flutter test
# > run integration tests
# flutter test integration_test/ -d <device_id>
# Examples: --device=windows | --device=linux | --device=macos | --device=chrome
dart run tool/run_integration_tests.dart --device=<device_id>
flutter test integration_test/ -d <device_id>
# dart run tool/run_integration_tests.dart --device=linux
# dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer
@ -38,7 +37,6 @@ flutter run -d <device_id>
#### Windows
```bash
dart run pdfrx:remove_wasm_modules
flutter build windows
# create windows installer
flutter pub run msix:create
@ -72,7 +70,6 @@ Access your app at [http://localhost:8080](http://localhost:8080)
For Linux
```bash
dart run pdfrx:remove_wasm_modules
flutter build linux
cp -r build/linux/x64/release/bundle/ AppDir
appimagetool-x86_64.AppImage AppDir

View File

@ -2,7 +2,7 @@
<application
android:label="pdf_signature"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
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")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,44 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Cropped viewBox from 0 0 128 128 to 8 8 112 112 so icon artwork occupies full box -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="8 8 112 112" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.0509804;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface5" clip-path="url(#clip1)">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 40 32 L 76 32 L 96 52 L 96 54 L 88 54 C 79.199219 54 72 46.800781 72 38 L 72 32 L 40 32 C 37.800781 32 36 33.800781 36 36 C 36 33.800781 37.800781 32 40 32 Z M 40 32 "/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.898039;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface8" clip-path="url(#clip2)">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(89.803922%,22.352941%,20.784314%);fill-opacity:1;" d="M 50 56 C 50 54.894531 49.105469 54 48 54 C 46.894531 54 46 54.894531 46 56 C 46 57.105469 46.894531 58 48 58 C 49.105469 58 50 57.105469 50 56 Z M 50 56 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(89.803922%,22.352941%,20.784314%);fill-opacity:1;" d="M 58 56 C 58 54.894531 57.105469 54 56 54 C 54.894531 54 54 54.894531 54 56 C 54 57.105469 54.894531 58 56 58 C 57.105469 58 58 57.105469 58 56 Z M 58 56 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(89.803922%,22.352941%,20.784314%);fill-opacity:1;" d="M 66 56 C 66 54.894531 65.105469 54 64 54 C 62.894531 54 62 54.894531 62 56 C 62 57.105469 62.894531 58 64 58 C 65.105469 58 66 57.105469 66 56 Z M 66 56 "/>
</g>
</defs>
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(14.509805%,38.82353%,92.156863%);fill-opacity:1;" d="M 32 8 L 96 8 C 109.253906 8 120 18.746094 120 32 L 120 96 C 120 109.253906 109.253906 120 96 120 L 32 120 C 18.746094 120 8 109.253906 8 96 L 8 32 C 8 18.746094 18.746094 8 32 8 Z M 32 8 "/>
<!-- Slight enlargement (1.05x) of document artwork -->
<g id="doc" transform="matrix(1.05 0 0 1.05 -4.8 -3.4)">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 40 32 L 76 32 L 96 52 L 96 96 C 96 100.398438 92.398438 104 88 104 L 40 104 C 35.601562 104 32 100.398438 32 96 L 32 40 C 32 35.601562 35.601562 32 40 32 Z M 40 32 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.294118%,95.686275%,96.470588%);fill-opacity:1;" d="M 76 32 L 76 48 C 76 54.601562 81.398438 60 88 60 L 104 60 Z M 76 32 "/>
<path style="fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(12.156863%,16.078432%,21.568628%);stroke-opacity:1;stroke-miterlimit:4;" d="M 18 42 C 21 40 24 39.800781 26.5 42 C 29 44.199219 31.300781 43.800781 34.699219 40.800781 C 38.099609 37.800781 41.599609 35.5 44.099609 38 C 45.300781 39.199219 44.599609 41.199219 42.900391 41.599609 C 39.400391 42.5 46.199219 34.800781 49.300781 37 C 51.300781 38.400391 47.800781 43.699219 44.5 44.800781 C 39.900391 46.400391 33.599609 44.199219 30.699219 44.199219 C 26.300781 44.199219 23.199219 46.599609 18.699219 47 " transform="matrix(2,0,0,2,0,0)"/>
<use xlink:href="#surface5" mask="url(#mask0)"/>
<use xlink:href="#surface8" mask="url(#mask1)"/>
</g>
</g>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 64 64"
width="64"
height="64"
role="img"
aria-labelledby="title desc"
>
<title id="title">PDF Signature</title>
<desc id="desc">An app icon showing a PDF page with a folded corner and a handwritten signature.</desc>
<!-- Background tile -->
<rect x="4" y="4" width="56" height="56" rx="12" fill="#2563EB" />
<!-- Paper with folded corner -->
<g>
<path
d="M20 16h18l10 10v22c0 2.2-1.8 4-4 4H20c-2.2 0-4-1.8-4-4V20c0-2.2 1.8-4 4-4z"
fill="#FFFFFF"
/>
<path
d="M38 16v8c0 3.3 2.7 6 6 6h8l-14-14z"
fill="#F3F4F6"
/>
</g>
<!-- Signature stroke -->
<path
d="M18 42c3-2 6-2.2 8.5 0 2.5 2.2 4.8 1.8 8.2-1.2 3.4-3 6.9-5.3 9.4-2.8 1.2 1.2 0.5 3.2-1.2 3.6-3.5 0.9 3.3-6.8 6.4-4.6 2 1.4-1.5 6.7-4.8 7.8-4.6 1.6-10.9-0.6-13.8-0.6-4.4 0-7.5 2.4-12 2.8"
fill="none"
stroke="#1F2937"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Subtle page shadow for depth (kept minimal for clarity) -->
<path
d="M20 16h18l10 10v1H44c-4.4 0-8-3.6-8-8v-3H20c-1.1 0-2 .9-2 2v0c0-1.1.9-2 2-2z"
fill="#000"
opacity=".05"
/>
<!-- Optional PDF label dots (very subtle) -->
<g fill="#E53935" opacity=".9">
<circle cx="24" cy="28" r="1" />
<circle cx="28" cy="28" r="1" />
<circle cx="32" cy="28" r="1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,48 +0,0 @@
one time
[Manage Docker as a non-root user](https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user)
```bash
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --use --name multiarch
```
build (single-arch, locally loadable)
```bash
# amd64
docker buildx build --platform linux/amd64 -f Dockerfile.arm64 -t pdf_signature:amd64 --load .
# arm64
docker buildx build --platform linux/arm64 -f Dockerfile.arm64 -t pdf_signature:arm64 --load .
```
build (multi-arch manifest, push to registry)
```bash
# requires a registry you can push to, e.g. ghcr.io/<org>/pdf_signature:latest
docker buildx build \
--platform linux/amd64,linux/arm64 \
-f Dockerfile.arm64 \
-t <your-registry>/pdf_signature:latest \
--push .
```
Extract the Built App (from a single-arch image)
```bash
mkdir output
# select the architecture tag you built above
# e.g. pdf_signature:arm64 or pdf_signature:amd64
docker run --rm -v $(pwd)/output:/output pdf_signature:arm64 cp -r /app /output
mkdir output/lib
# docker run --rm -v $(pwd)/output:/output pdf_signature:arm64 ldd /app/pdf_signature
docker run --rm -v $(pwd)/output:/output pdf_signature:arm64 sh -c "ldd /app/pdf_signature | grep '=>' | awk '{print \$3}' | grep -v libc | xargs -I {} cp -L {} /output/lib"
# docker run --rm -v $(pwd)/output:/output pdf_signature:arm64 ls /app/lib
# tree output/
```
Notes
- The Dockerfile uses Ubuntu 18.04 to keep glibc at 2.27 for broad compatibility. If 18.04 mirrors are unavailable in your environment, consider switching to 20.04 (glibc 2.31) and testing on your target distros, or point apt to old-releases.
- CMake binaries are fetched per-arch; Flutter target-platform is selected automatically (linux-x64 on amd64, linux-arm64 on arm64).

View File

@ -94,5 +94,3 @@ Some rule of thumb:
* whole app use its image object as image representation.
* 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.
* [responsive_framework]
* RWD support

File diff suppressed because it is too large Load Diff

View File

@ -78,23 +78,6 @@ Illustration:
---
### Mobile PDF screen (phone)
Purpose: compact PDF viewing and signature placement on small screens.
Route: root --> opened (mobile)
Design notes:
- Top app bar with: menu (page thumbnails), title with current page and total pages (prev/next).
- Center viewport supports scroll and pinch-zoom.
- Bottom sheet button to show Signatures drawers.
- bottom drawer to add/drag a signature onto the page.
Illustration:
![](wireframe.assets/mobile_pdf_phone.excalidraw)
---
## How to view and export
We keep links in this file pointing to `.excalidraw`. To preview the SVGs and generate `docs/.wireframe.md` with `.svg` links, run from repo root:

View File

@ -1,91 +1,101 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'dart:io';
import 'package:file_selector/file_selector.dart' as fs;
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdf_signature/domain/models/document.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/data/repositories/signature_asset_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/domain/models/model.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_screen.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pdf_viewer_widget.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
// Note: We use the real ExportService via the repository; no mocks here.
class RecordingExporter extends ExportService {
bool called = false;
@override
Future<bool> saveBytesToFile({required bytes, required outputPath}) async {
called = true;
return true;
}
}
class _PreloadedDocumentStateNotifier extends DocumentStateNotifier {
_PreloadedDocumentStateNotifier({
required this.bytes,
required this.pageCount,
ExportService? service,
}) : super(service: service);
final Uint8List bytes;
final int pageCount;
// Lightweight fake exporter to avoid invoking heavy rasterization during tests
class LightweightExporter extends ExportService {
@override
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, img.Image>? libraryImages,
double targetDpi = 144.0,
}) async {
// Return minimal non-empty bytes; content isn't used further in tests
return Uint8List.fromList([1, 2, 3]);
}
@override
Document build() {
super.build();
return Document(loaded: true, pageCount: pageCount, pickedPdfBytes: bytes);
Future<bool> saveBytesToFile({
required Uint8List bytes,
required String outputPath,
}) async {
return true;
}
}
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Save uses file selector (via provider) and injected exporter', (
tester,
) async {
final fake = RecordingExporter();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
// For this test, we don't need the PDF bytes since it's not loaded
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(),
),
(ref) => DocumentStateNotifier()..openPicked(pageCount: 3),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
// Disable overlays to avoid long-lived overlay animations in CI
viewerOverlaysEnabledProvider.overrideWith((ref) => false),
pdfExportViewModelProvider.overrideWith(
() => PdfExportViewModel(
(ref) => PdfExportViewModel(
ref,
exporter: fake,
savePathPicker: () async {
final dir = Directory.systemTemp.createTempSync('pdfsig_');
return '${dir.path}/output.pdf';
},
savePathPickerWithSuggestedName: (_) async {
final dir = Directory.systemTemp.createTempSync('pdfsig_');
return '${dir.path}/output.pdf';
},
),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -100,77 +110,112 @@ void main() {
expect(find.textContaining('Saved:'), findsOneWidget);
});
testWidgets('Export completes successfully (FOSS path)', (tester) async {
// Verify the exporter completes and shows SnackBar using the single
// FOSS path (pdfrx render + pdf compose) on all platforms.
// Helper to build a simple in-memory PNG as a signature image
Uint8List _makeSig() {
final canvas = img.Image(width: 80, height: 40);
img.fill(canvas, color: img.ColorUint8.rgb(255, 255, 255));
img.drawLine(
canvas,
x1: 6,
y1: 20,
x2: 74,
y2: 20,
color: img.ColorUint8.rgb(0, 0, 0),
);
return Uint8List.fromList(img.encodePng(canvas));
}
testWidgets('E2E (integration): place and confirm keeps size', (
tester,
) async {
final sigBytes = _makeSig();
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(),
),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
pdfExportViewModelProvider.overrideWith(
() => PdfExportViewModel(
savePathPicker: () async {
final dir = Directory.systemTemp.createTempSync(
'pdfsig_linux_',
);
return '${dir.path}/out.pdf';
},
savePathPickerWithSuggestedName: (_) async {
final dir = Directory.systemTemp.createTempSync(
'pdfsig_linux_',
);
return '${dir.path}/out.pdf';
},
),
signatureAssetRepositoryProvider.overrideWith((ref) {
final c = SignatureAssetRepository();
c.addImage(img.decodeImage(sigBytes)!, name: 'image');
return c;
}),
signatureCardRepositoryProvider.overrideWith((ref) {
final cardRepo = SignatureCardStateNotifier();
final asset = SignatureAsset(
sigImage: img.decodeImage(sigBytes)!,
name: 'image',
);
cardRepo.addWithAsset(asset, 0.0);
return cardRepo;
}),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
);
await tester.pump();
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
expect(find.textContaining('Saved:'), findsOneWidget);
});
final card = find.byKey(const Key('gd_signature_card_area')).first;
await tester.tap(card);
await tester.pump();
testWidgets('E2E (integration): place and confirm keeps size', (
tester,
) async {
// Skip in integration environment: overlay interaction was refactored
// and this check is covered by widget tests.
}, skip: true);
final active = find.byKey(const Key('signature_overlay'));
expect(active, findsOneWidget);
final sizeBefore = tester.getSize(active);
testWidgets('E2E (integration): programmatic placement size matches', (
tester,
) async {
// Skip in integration run; covered by lower-level widget tests.
return;
await tester.ensureVisible(active);
await tester.pumpAndSettle();
// Programmatically simulate confirm: add placement with current rect and bound image, then clear active overlay.
final ctx = tester.element(find.byType(PdfSignatureHomePage));
final container = ProviderScope.containerOf(ctx);
final r = container.read(pdfViewModelProvider).activeRect!;
final lib = container.read(signatureAssetRepositoryProvider);
final asset = lib.isNotEmpty ? lib.first : null;
final currentPage = container.read(pdfViewModelProvider).currentPage;
container
.read(documentRepositoryProvider.notifier)
.addPlacement(page: currentPage, rect: r, asset: asset);
// Clear active overlay by hiding signatures temporarily
// Note: signatureVisibilityProvider was removed in migration
// container.read(signatureVisibilityProvider.notifier).state = false;
await tester.pump();
// container.read(signatureVisibilityProvider.notifier).state = true;
await tester.pumpAndSettle();
final placed = find.byKey(const Key('placed_signature_0'));
expect(placed, findsOneWidget);
final sizeAfter = tester.getSize(placed);
expect(
(sizeAfter.width - sizeBefore.width).abs() < sizeBefore.width * 0.15,
isTrue,
);
expect(
(sizeAfter.height - sizeBefore.height).abs() < sizeBefore.height * 0.15,
isTrue,
);
});
// ---- PDF view interaction tests (merged from pdf_view_test.dart) ----
@ -186,25 +231,25 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(enableRaster: false),
),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -232,25 +277,25 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(enableRaster: false),
),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -281,25 +326,25 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(enableRaster: false),
),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -313,16 +358,12 @@ void main() {
expect(pagesSidebar, findsOneWidget);
// Scroll to make page 3 thumbnail visible
final page3Thumb = find.descendant(
of: pagesSidebar,
matching: find.text('3'),
);
// Scroll more aggressively to ensure page 3 is visible
await tester.drag(pagesSidebar, const Offset(0, -500));
await tester.drag(pagesSidebar, const Offset(0, -300));
await tester.pumpAndSettle();
final page3Thumb = find.text('3');
expect(page3Thumb, findsOneWidget);
await tester.tap(page3Thumb, warnIfMissed: false);
await tester.tap(page3Thumb);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
@ -337,25 +378,25 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(),
),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
locale: Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -375,103 +416,66 @@ void main() {
expect(container.read(pdfViewModelProvider).currentPage, 2);
});
testWidgets(
'PDF View: tap viewer after export does not crash',
(tester) async {
final pdfBytes =
await File(
'integration_test/data/sample-local-pdf.pdf',
).readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
testWidgets('PDF View: tap viewer after export does not crash', (
tester,
) async {
final pdfBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: pdfBytes,
pageCount: 3,
service: ExportService(),
),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
// Disable overlays to reduce post-export timers/animations.
viewerOverlaysEnabledProvider.overrideWith((ref) => false),
// Override only save path picker to avoid native dialogs; use real exporter
pdfExportViewModelProvider.overrideWith(
() => PdfExportViewModel(
savePathPicker: () async {
final dir = Directory.systemTemp.createTempSync(
'pdfsig_after_',
);
return '${dir.path}/output-after-export.pdf';
},
savePathPickerWithSuggestedName: (_) async {
final dir = Directory.systemTemp.createTempSync(
'pdfsig_after_',
);
return '${dir.path}/output-after-export.pdf';
},
),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfExportViewModelProvider.overrideWith(
(ref) => PdfExportViewModel(
ref,
exporter: LightweightExporter(),
savePathPicker: () async {
final dir = Directory.systemTemp.createTempSync(
'pdfsig_after_',
);
return '${dir.path}/output-after-export.pdf';
},
),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: fs.XFile('test.pdf'),
),
),
);
await tester.pumpAndSettle();
),
);
await tester.pumpAndSettle();
// Trigger export
debugPrint('[AFTER_EXPORT] Tap save to start export');
await tester.tap(find.byKey(const Key('btn_save_pdf')));
// Wait for export to complete using a real async wait so the test harness
// doesn't expect frame settling.
await tester.runAsync(() async {
final deadline = DateTime.now().add(const Duration(seconds: 6));
while (DateTime.now().isBefore(deadline)) {
try {
final container = ProviderScope.containerOf(
tester.element(find.byType(PdfSignatureHomePage)),
);
final exporting =
container.read(pdfExportViewModelProvider).exporting;
if (!exporting) break;
} catch (_) {
// If widget unmounted, just stop waiting.
break;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
});
// Tap the viewer after export finished to ensure no crash
final viewer = find.byKey(const ValueKey('pdf_page_area'));
expect(viewer, findsOneWidget);
await tester.tap(viewer);
await tester.pump(const Duration(milliseconds: 150));
// Hard-unmount the app to stop any viewer timers/animations
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 250));
await tester.pump(const Duration(milliseconds: 250));
// Give async zone a brief chance to flush background timers
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 250));
});
debugPrint('[AFTER_EXPORT] Test end reached (no crash)');
// Ensure the test registers a completed assertion.
expect(true, isTrue);
},
timeout: const Timeout(Duration(minutes: 2)),
);
// Trigger export
await tester.tap(find.byKey(const Key('btn_save_pdf')));
await tester.pumpAndSettle();
// Tap on the page area; should not crash
final pageArea = find.byKey(const ValueKey('pdf_page_area'));
expect(pageArea, findsOneWidget);
await tester.tap(pageArea);
await tester.pumpAndSettle();
// Still present and responsive
expect(pageArea, findsOneWidget);
});
}

View File

@ -1,37 +1,19 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/domain/models/document.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:pdf_signature/ui/features/pdf/widgets/pages_sidebar.dart';
import 'dart:io';
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/pages_sidebar.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pdf_signature/data/repositories/preferences_repository.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/l10n/app_localizations.dart';
/// It has known that sample-local-pdf.pdf has 3 pages.
class _PreloadedDocumentStateNotifier extends DocumentStateNotifier {
_PreloadedDocumentStateNotifier({
required this.bytes,
required this.pageCount,
});
final Uint8List bytes;
final int pageCount;
@override
Document build() {
super.build();
return Document(loaded: true, pageCount: pageCount, pickedPdfBytes: bytes);
}
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -47,13 +29,16 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() =>
_PreloadedDocumentStateNotifier(bytes: pdfBytes, pageCount: 3),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@ -62,7 +47,7 @@ void main() {
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -77,45 +62,14 @@ void main() {
final vm = container.read(pdfViewModelProvider);
expect(vm.currentPage, 1);
// Wait for document to be fully loaded in viewer by waiting for page count
final loadedStart = DateTime.now();
while (container.read(documentRepositoryProvider).pageCount == 0) {
await tester.pump(const Duration(milliseconds: 40));
if (DateTime.now().difference(loadedStart) >
const Duration(seconds: 10)) {
fail('Document never loaded (pageCount still 0)');
}
}
// Wait a bit more for controller to become ready after document loads
await tester.pump(const Duration(milliseconds: 300));
container.read(pdfViewModelProvider.notifier).jumpToPage(2);
await tester.pumpAndSettle();
final controller = container.read(pdfViewModelProvider).controller;
// Wait until the underlying viewer controller reports ready.
final readyStart = DateTime.now();
while (controller != null && !controller.isReady) {
await tester.pump(const Duration(milliseconds: 50));
if (DateTime.now().difference(readyStart) > const Duration(seconds: 15)) {
fail('PdfViewerController never became ready');
}
}
Future<void> goAndAwait(int target) async {
controller.goToPage(pageNumber: target);
final start = DateTime.now();
while (container.read(pdfViewModelProvider).currentPage != target) {
await tester.pump(const Duration(milliseconds: 40));
if (DateTime.now().difference(start) > const Duration(seconds: 3)) {
fail(
'Timeout waiting to reach page $target (current=${container.read(pdfViewModelProvider).currentPage})',
);
}
}
}
await goAndAwait(2);
await tester.pump(const Duration(milliseconds: 120));
expect(container.read(pdfViewModelProvider).currentPage, 2);
await goAndAwait(3);
container.read(pdfViewModelProvider.notifier).jumpToPage(3);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 120));
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
@ -129,13 +83,16 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() =>
_PreloadedDocumentStateNotifier(bytes: pdfBytes, pageCount: 3),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@ -144,7 +101,7 @@ void main() {
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -180,13 +137,16 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() =>
_PreloadedDocumentStateNotifier(bytes: pdfBytes, pageCount: 3),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@ -195,7 +155,7 @@ void main() {
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -210,15 +170,48 @@ void main() {
final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget);
// Helper to read the background color of a thumbnail tile by page label
Color? tileBgForPage(int page) {
final pageLabel = find.descendant(
of: pagesSidebar,
matching: find.text('$page'),
);
if (pageLabel.evaluate().isEmpty) return null; // not visible yet
final decoratedAncestors = find.ancestor(
of: pageLabel,
matching: find.byType(DecoratedBox),
);
final decoratedBoxes =
decoratedAncestors
.evaluate()
.map((e) => e.widget)
.whereType<DecoratedBox>()
.toList();
for (final d in decoratedBoxes) {
final dec = d.decoration;
if (dec is BoxDecoration && dec.color != null) {
return dec.color;
}
}
return null;
}
final theme = Theme.of(tester.element(pagesSidebar));
// Initially, page 1 should be highlighted
expect(tileBgForPage(1), theme.colorScheme.primaryContainer);
// Scroll to make page 3 thumbnail visible
final page3Thumbnail = find.text('3');
await tester.ensureVisible(page3Thumbnail);
await tester.drag(pagesSidebar, const Offset(0, -300));
await tester.pumpAndSettle();
final page3Thumbnail = find.text('3');
expect(page3Thumbnail, findsOneWidget);
await tester.tap(page3Thumbnail);
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
// After navigation completes, page 3 should be highlighted
expect(tileBgForPage(3), theme.colorScheme.primaryContainer);
});
testWidgets('PDF View: thumbnails scroll and select', (tester) async {
@ -231,13 +224,16 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() =>
_PreloadedDocumentStateNotifier(bytes: pdfBytes, pageCount: 3),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@ -246,7 +242,7 @@ void main() {
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -294,13 +290,16 @@ void main() {
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() =>
_PreloadedDocumentStateNotifier(bytes: pdfBytes, pageCount: 3),
(ref) =>
DocumentStateNotifier()
..openPicked(pageCount: 3, bytes: pdfBytes),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: false),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@ -309,7 +308,7 @@ void main() {
home: PdfSignatureHomePage(
onPickPdf: () async {},
onClosePdf: () {},
currentFile: XFile('test.pdf'),
currentFile: fs.XFile('test.pdf'),
),
),
),
@ -324,9 +323,12 @@ void main() {
final pagesSidebar = find.byType(PagesSidebar);
expect(pagesSidebar, findsOneWidget);
final page3 = find.descendant(of: pagesSidebar, matching: find.text('3'));
await tester.ensureVisible(page3);
// Ensure page 3 not initially in view by trying to find it and allowing that it might be offstage.
// Perform a scroll/drag to bring page 3 into view.
await tester.drag(pagesSidebar, const Offset(0, -400));
await tester.pumpAndSettle();
final page3 = find.descendant(of: pagesSidebar, matching: find.text('3'));
expect(page3, findsOneWidget);
await tester.tap(page3);
await tester.pumpAndSettle();
@ -337,119 +339,4 @@ void main() {
await tester.pumpAndSettle();
expect(container.read(pdfViewModelProvider).currentPage, 3);
});
testWidgets('PDF View: reopen another PDF via toolbar picker updates viewer', (
tester,
) async {
final initialBytes =
await File('integration_test/data/sample-local-pdf.pdf').readAsBytes();
// 3 pages
final newBytes =
await File(
'integration_test/data/PPFZ-Local-Purchase-Form.pdf',
).readAsBytes();
// 10 pages
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
// We'll override onPickPdf to simulate opening a new file with a different page count
// TODO: Replace PPFZ-Local-Purchase-Form.pdf with a 10-page PDF to test page count change
late ProviderContainer container; // capture to use inside callback
Future<void> simulatePick() async {
container
.read(documentRepositoryProvider.notifier)
.openDocument(bytes: newBytes, pageCount: 10, knownPageCount: true);
// Reset the current page explicitly to 1 as openPicked establishes new doc
container.read(pdfViewModelProvider.notifier).jumpToPage(1);
}
int? lastDocPageCount; // capture page count from callback
await tester.pumpWidget(
ProviderScope(
overrides: [
preferencesRepositoryProvider.overrideWith(
() => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
() => _PreloadedDocumentStateNotifier(
bytes: initialBytes,
pageCount: 3,
),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
],
child: Builder(
builder: (context) {
container = ProviderScope.containerOf(context);
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: PdfSignatureHomePage(
onPickPdf: simulatePick,
onClosePdf: () {},
currentFile: XFile('initial.pdf'),
// The only reliable way to detect the new document load correctly
onDocumentChanged: (doc) {
if (doc != null) {
lastDocPageCount = doc.pages.length;
}
},
),
);
},
),
),
);
await tester.pumpAndSettle();
// Verify initial state Page 1/3
expect(find.byKey(const Key('lbl_page_info')), findsOneWidget);
final initialLabel =
tester.widget<Text>(find.byKey(const Key('lbl_page_info'))).data;
expect(initialLabel, contains('/3'));
// Tap open picker button to simulate opening new PDF
await tester.tap(find.byKey(const Key('btn_open_pdf_picker')));
// Allow frame to process state changes from simulatePick
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
// Wait for async page count detection to complete in repository
await tester.runAsync(() async {
final start = DateTime.now();
while (lastDocPageCount == null ||
container.read(documentRepositoryProvider).pageCount == 0) {
await Future<void>.delayed(const Duration(milliseconds: 50));
await tester.pump();
if (DateTime.now().difference(start) > const Duration(seconds: 8)) {
final pageCount =
container.read(documentRepositoryProvider).pageCount;
fail(
'Timeout waiting for repository page count to update (repo=$pageCount viewer=$lastDocPageCount)',
);
}
}
// Wait for restoration mechanism to complete
await Future<void>.delayed(const Duration(milliseconds: 500));
await tester.pump();
});
final expectedPageCount =
container.read(documentRepositoryProvider).pageCount;
final updatedLabel =
tester.widget<Text>(find.byKey(const Key('lbl_page_info'))).data;
expect(updatedLabel, contains('/$expectedPageCount'));
// Verify that repository correctly analyzed PDF bytes and updated page count
expect(
container.read(documentRepositoryProvider).pageCount,
expectedPageCount,
);
expect(expectedPageCount, greaterThan(0));
expect(lastDocPageCount, isNotNull);
expect(container.read(pdfViewModelProvider).currentPage, 1);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 B

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

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/ui/features/preferences/widgets/settings_screen.dart';
import 'data/repositories/preferences_repository.dart';
import 'package:responsive_framework/responsive_framework.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@ -57,7 +56,7 @@ class MyApp extends StatelessWidget {
routerConfig: ref.watch(routerProvider),
builder: (context, child) {
final router = ref.watch(routerProvider);
final content = Scaffold(
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).appTitle),
actions: [
@ -79,17 +78,6 @@ class MyApp extends StatelessWidget {
),
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,27 +1,15 @@
import 'dart:isolate';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/services/export_service.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../domain/models/model.dart';
class DocumentStateNotifier extends Notifier<Document> {
DocumentStateNotifier({ExportService? service}) : _serviceOverride = service;
class DocumentStateNotifier extends StateNotifier<Document> {
DocumentStateNotifier() : super(Document.initial());
final ExportService? _serviceOverride;
late final ExportService _service;
int _maxPageCount = 0;
@override
Document build() {
_service = _serviceOverride ?? ExportService();
_maxPageCount = 0;
return Document.initial();
}
final ExportService _service = ExportService();
@visibleForTesting
void openSample() {
@ -33,102 +21,22 @@ class DocumentStateNotifier extends Notifier<Document> {
);
}
/// Unified open API replacing multiple legacy variants.
///
/// Usage patterns:
/// openDocument(bytes: data) -> derive page count asynchronously.
/// openDocument(bytes: data, pageCount: 203, knownPageCount: true) -> fast path.
/// openDocument(pageCount: 5) -> open empty placeholder document (tests).
void openDocument({
Uint8List? bytes,
int? pageCount,
bool knownPageCount = false,
}) {
debugPrint(
'[DocumentRepository] openDocument called (bytes=${bytes?.length} pageCount=$pageCount known=$knownPageCount)',
);
if (bytes == null) {
// No bytes: treat as synthetic document (tests) using provided pageCount or default 1
// Reset max page count for new document
final pc = pageCount ?? 1;
_maxPageCount = pc;
state = state.copyWith(
loaded: true,
pageCount: _maxPageCount,
pickedPdfBytes: null,
placementsByPage: <int, List<SignaturePlacement>>{},
);
return;
}
// Bytes provided - reset max page count for new document
if ((knownPageCount || pageCount != null) && pageCount != null) {
// Fast path: caller already determined count
final pc = pageCount.clamp(1, 9999);
_maxPageCount = pc;
state = state.copyWith(
loaded: true,
pageCount: _maxPageCount,
pickedPdfBytes: bytes,
placementsByPage: <int, List<SignaturePlacement>>{},
);
return;
}
// Derive asynchronously
_openPickedAsync(bytes);
}
// --- Deprecated wrappers for backward compatibility (can be removed later) ---
@Deprecated('Use openDocument(bytes: ...) instead')
void openPicked({Uint8List? bytes}) => openDocument(bytes: bytes);
@Deprecated(
'Use openDocument(bytes: ..., pageCount: x, knownPageCount: true) instead',
)
void openPickedKnown({required int pageCount, required Uint8List bytes}) =>
openDocument(bytes: bytes, pageCount: pageCount, knownPageCount: true);
Future<void> _openPickedAsync(Uint8List bytes) async {
int pageCount = 1; // default fallback
try {
// Determine actual page count from PDF bytes
final doc = await PdfDocument.openData(bytes);
pageCount = doc.pages.length;
debugPrint('[DocumentRepository] PDF has $pageCount pages');
} catch (e) {
debugPrint('[DocumentRepository] Failed to read PDF page count: $e');
// Keep default pageCount = 1 on error
}
_maxPageCount = math.max(_maxPageCount, pageCount).toInt();
void openPicked({required int pageCount, Uint8List? bytes}) {
state = state.copyWith(
loaded: true,
pageCount: _maxPageCount,
pageCount: pageCount,
pickedPdfBytes: bytes,
placementsByPage: <int, List<SignaturePlacement>>{},
);
}
// For tests that need to specify page count explicitly
@visibleForTesting
@Deprecated(
'Use openDocument(pageCount: x) for synthetic docs or with bytes+knownPageCount',
)
void openPickedWithPageCount({required int pageCount, Uint8List? bytes}) =>
openDocument(bytes: bytes, pageCount: pageCount, knownPageCount: true);
void close() {
state = Document.initial();
}
void setPageCount(int count) {
if (!state.loaded) return;
debugPrint(
'[DocumentRepository] setPageCount called: $count (current: ${state.pageCount})',
);
final clamped = count.clamp(1, 9999);
_maxPageCount = math.max(_maxPageCount, clamped).toInt();
state = state.copyWith(pageCount: _maxPageCount);
state = state.copyWith(pageCount: count.clamp(1, 9999));
}
void jumpTo(int page) {
@ -164,12 +72,21 @@ class DocumentStateNotifier extends Notifier<Document> {
// signature bytes were provided.
static final img.Image _singleTransparentPng = img.Image(width: 1, height: 1);
@Deprecated('Use modifyPlacement')
void updatePlacementRotation({
required int page,
required int index,
required double rotationDeg,
}) => modifyPlacement(page: page, index: index, rotationDeg: rotationDeg);
}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
final list = List<SignaturePlacement>.from(map[p] ?? const []);
if (index >= 0 && index < list.length) {
list[index] = list[index].copyWith(rotationDeg: rotationDeg);
map[p] = list;
state = state.copyWith(placementsByPage: map);
}
}
void removePlacement({required int page, required int index}) {
if (!state.loaded) return;
@ -188,36 +105,21 @@ class DocumentStateNotifier extends Notifier<Document> {
}
// Update the rect of an existing placement on a page.
@Deprecated('Use modifyPlacement')
void updatePlacementRect({
required int page,
required int index,
required Rect rect,
}) => modifyPlacement(page: page, index: index, rect: rect);
/// Generic partial update for a placement. Any non-null field is applied.
void modifyPlacement({
required int page,
required int index,
Rect? rect,
double? rotationDeg,
SignatureAsset? asset,
GraphicAdjust? graphicAdjust,
}) {
if (!state.loaded) return;
final p = page.clamp(1, state.pageCount);
final map = Map<int, List<SignaturePlacement>>.from(state.placementsByPage);
final list = List<SignaturePlacement>.from(map[p] ?? const []);
if (index < 0 || index >= list.length) return;
final current = list[index];
list[index] = current.copyWith(
rect: rect ?? current.rect,
rotationDeg: rotationDeg ?? current.rotationDeg,
asset: asset ?? current.asset,
graphicAdjust: graphicAdjust ?? current.graphicAdjust,
);
map[p] = list;
state = state.copyWith(placementsByPage: map);
if (index >= 0 && index < list.length) {
final existing = list[index];
list[index] = existing.copyWith(rect: rect);
map[p] = list;
state = state.copyWith(placementsByPage: map);
}
}
List<SignaturePlacement> placementsOn(int page) {
@ -233,192 +135,25 @@ class DocumentStateNotifier extends Notifier<Document> {
return list[index].asset;
}
Future<bool> exportDocument({
Future<void> exportDocument({
required String outputPath,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
final bytes = await exportDocumentToBytes(
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
targetDpi: targetDpi,
);
Future<void> _ = Future<void>.delayed(Duration.zero);
if (bytes == null) return false;
final ok = await _service.saveBytesToFile(
bytes: bytes,
outputPath: outputPath,
);
return ok;
}
Future<Uint8List?> exportDocumentToBytes({
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
if (!state.loaded || state.pickedPdfBytes == null) return null;
// Experimental: run export in a background isolate using `compute`.
// We serialize placements and signature assets to isolate-safe data.
try {
final args = _buildIsolateArgs(
srcBytes: state.pickedPdfBytes!,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: state.placementsByPage,
targetDpi: targetDpi,
);
final result = await compute<_ExportIsolateArgs, Uint8List?>(
_exportInIsolate,
args,
);
if (result != null) return result;
} catch (_) {
debugPrint('Warning: export in isolate failed');
// Fall back to main-isolate export if isolate fails (e.g., engine limitations).
}
// Fallback on main isolate
return await _service.exportSignedPdfFromBytes(
if (!state.loaded || state.pickedPdfBytes == null) return;
final bytes = await _service.exportSignedPdfFromBytes(
srcBytes: state.pickedPdfBytes!,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
placementsByPage: state.placementsByPage,
targetDpi: targetDpi,
);
if (bytes == null) return;
_service.saveBytesToFile(bytes: bytes, outputPath: outputPath);
// await
}
}
final documentRepositoryProvider =
NotifierProvider<DocumentStateNotifier, Document>(
DocumentStateNotifier.new,
StateNotifierProvider<DocumentStateNotifier, Document>(
(ref) => DocumentStateNotifier(),
);
/// --- Isolate helpers of DocumentRepository ---
/// Following are helpers to transfer data to/from an isolate for export.
class _ExportIsolateArgs {
final TransferableTypedData src;
final double pageW;
final double pageH;
final double targetDpi;
final List<_IsoPagePlacements> pages;
final TransferableTypedData? signatureImageBytes; // not used currently
_ExportIsolateArgs({
required this.src,
required this.pageW,
required this.pageH,
required this.targetDpi,
required this.pages,
required this.signatureImageBytes,
});
}
class _IsoPagePlacements {
final int page;
final List<_IsoPlacement> items;
_IsoPagePlacements(this.page, this.items);
}
class _IsoPlacement {
final double l, t, w, h;
final double rot;
final double contrast, brightness;
final bool bgRemoval;
final TransferableTypedData assetPng;
_IsoPlacement({
required this.l,
required this.t,
required this.w,
required this.h,
required this.rot,
required this.contrast,
required this.brightness,
required this.bgRemoval,
required this.assetPng,
});
}
_ExportIsolateArgs _buildIsolateArgs({
required Uint8List srcBytes,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
required Map<int, List<SignaturePlacement>> placementsByPage,
required double targetDpi,
}) {
final pages = <_IsoPagePlacements>[];
placementsByPage.forEach((page, items) {
final isoItems = <_IsoPlacement>[];
for (final p in items) {
// Encode the asset image to PNG for transfer; small count expected.
final png = Uint8List.fromList(img.encodePng(p.asset.sigImage, level: 3));
isoItems.add(
_IsoPlacement(
l: p.rect.left,
t: p.rect.top,
w: p.rect.width,
h: p.rect.height,
rot: p.rotationDeg,
contrast: p.graphicAdjust.contrast,
brightness: p.graphicAdjust.brightness,
bgRemoval: p.graphicAdjust.bgRemoval,
assetPng: TransferableTypedData.fromList([png]),
),
);
}
pages.add(_IsoPagePlacements(page, isoItems));
});
return _ExportIsolateArgs(
src: TransferableTypedData.fromList([srcBytes]),
pageW: uiPageSize.width,
pageH: uiPageSize.height,
targetDpi: targetDpi,
pages: pages,
signatureImageBytes:
signatureImageBytes == null
? null
: TransferableTypedData.fromList([signatureImageBytes]),
);
}
Future<Uint8List?> _exportInIsolate(_ExportIsolateArgs args) async {
// Rebuild placements
final placementsByPage = <int, List<SignaturePlacement>>{};
for (final page in args.pages) {
final list = <SignaturePlacement>[];
for (final it in page.items) {
final bytes = it.assetPng.materialize().asUint8List();
final decoded = img.decodePng(bytes);
if (decoded == null) continue;
final asset = SignatureAsset(sigImage: decoded);
list.add(
SignaturePlacement(
rect: Rect.fromLTWH(it.l, it.t, it.w, it.h),
asset: asset,
rotationDeg: it.rot,
graphicAdjust: GraphicAdjust(
contrast: it.contrast,
brightness: it.brightness,
bgRemoval: it.bgRemoval,
),
),
);
}
if (list.isNotEmpty) {
placementsByPage[page.page] = list;
}
}
final src = args.src.materialize().asUint8List();
final service = ExportService();
return await service.exportSignedPdfFromBytes(
srcBytes: src,
uiPageSize: Size(args.pageW, args.pageH),
signatureImageBytes: args.signatureImageBytes?.materialize().asUint8List(),
placementsByPage: placementsByPage,
targetDpi: args.targetDpi,
);
}

View File

@ -89,61 +89,9 @@ String _normalizeLanguageTag(String tag) {
return tags.contains('en') ? 'en' : tags.first;
}
class PreferencesStateNotifier extends Notifier<PreferencesState> {
PreferencesStateNotifier([SharedPreferences? prefs]) {
_prefs = prefs;
}
SharedPreferences? _prefs;
class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
late final SharedPreferences _prefs;
final Completer<void> _ready = Completer<void>();
@override
PreferencesState build() {
// If _prefs was already set by initWithPrefs (for testing), use it
if (_prefs != null) {
return _buildFromPrefs(_prefs!);
}
// Initialize with defaults for production
final defaultState = PreferencesState(
theme: 'system',
language: _normalizeLanguageTag(
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
exportDpi: 144.0,
theme_color: '#FF2196F3', // blue
);
// Start async initialization
_initAsync();
return defaultState;
}
void _initAsync() async {
_prefs = await SharedPreferences.getInstance();
await _load();
_ready.complete();
}
// For testing - can be called with mock SharedPreferences BEFORE build
void initWithPrefs(SharedPreferences prefs) {
_prefs = prefs;
}
PreferencesState _buildFromPrefs(SharedPreferences prefs) {
final loaded = PreferencesState(
theme: prefs.getString(_kTheme) ?? 'system',
language: _normalizeLanguageTag(
prefs.getString(_kLanguage) ??
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
exportDpi: _readDpi(prefs),
theme_color: prefs.getString(_kThemeColor) ?? '#FF2196F3',
);
// Complete ready immediately for tests
if (!_ready.isCompleted) _ready.complete();
return loaded;
}
static Color? _tryParseColor(String? s) {
if (s == null || s.isEmpty) return null;
final v = s.trim();
@ -214,17 +162,31 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
return '#$a$r$g$b';
}
Future<void> _load() async {
if (_prefs == null) return;
PreferencesStateNotifier([SharedPreferences? prefs])
: super(
PreferencesState(
theme: 'system',
language: _normalizeLanguageTag(
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
exportDpi: 144.0,
theme_color: '#FF2196F3', // blue
),
) {
_init(prefs);
}
Future<void> _init(SharedPreferences? injected) async {
_prefs = injected ?? await SharedPreferences.getInstance();
// Load persisted values (with sane defaults)
final loaded = PreferencesState(
theme: _prefs!.getString(_kTheme) ?? 'system',
theme: _prefs.getString(_kTheme) ?? 'system',
language: _normalizeLanguageTag(
_prefs!.getString(_kLanguage) ??
_prefs.getString(_kLanguage) ??
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
),
exportDpi: _readDpi(_prefs!),
theme_color: _prefs!.getString(_kThemeColor) ?? '#FF2196F3',
exportDpi: _readDpi(_prefs),
theme_color: _prefs.getString(_kThemeColor) ?? '#FF2196F3',
);
state = loaded;
_ensureValid();
@ -245,22 +207,21 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
}
void _ensureValid() {
if (_prefs == null) return;
final themeValid = {'light', 'dark', 'system'};
if (!themeValid.contains(state.theme)) {
state = state.copyWith(theme: 'system');
_prefs!.setString(_kTheme, 'system');
_prefs.setString(_kTheme, 'system');
}
final normalized = _normalizeLanguageTag(state.language);
if (normalized != state.language) {
state = state.copyWith(language: normalized);
_prefs!.setString(_kLanguage, normalized);
_prefs.setString(_kLanguage, normalized);
}
// Ensure DPI is one of allowed values
const allowed = [96.0, 144.0, 200.0, 300.0];
if (!allowed.contains(state.exportDpi)) {
state = state.copyWith(exportDpi: 144.0);
_prefs!.setDouble(_kExportDpi, 144.0);
_prefs.setDouble(_kExportDpi, 144.0);
}
// Ensure theme color is a valid hex or known name; normalize to hex
final parsed = _tryParseColor(state.theme_color);
@ -268,12 +229,12 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
final fallback = Colors.blue;
final hex = _toHex(fallback);
state = state.copyWith(theme_color: hex);
_prefs!.setString(_kThemeColor, hex);
_prefs.setString(_kThemeColor, hex);
} else {
final hex = _toHex(parsed);
if (state.theme_color != hex) {
state = state.copyWith(theme_color: hex);
_prefs!.setString(_kThemeColor, hex);
_prefs.setString(_kThemeColor, hex);
}
}
}
@ -283,14 +244,14 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
if (!valid.contains(theme)) return;
state = state.copyWith(theme: theme);
await _ensureReady();
await _prefs!.setString(_kTheme, theme);
await _prefs.setString(_kTheme, theme);
}
Future<void> setLanguage(String language) async {
final normalized = _normalizeLanguageTag(language);
state = state.copyWith(language: normalized);
await _ensureReady();
await _prefs!.setString(_kLanguage, normalized);
await _prefs.setString(_kLanguage, normalized);
}
Future<void> setThemeColor(String themeColor) async {
@ -299,7 +260,7 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
final hex = _toHex(c);
state = state.copyWith(theme_color: hex);
await _ensureReady();
await _prefs!.setString(_kThemeColor, hex);
await _prefs.setString(_kThemeColor, hex);
}
Future<void> resetToDefaults() async {
@ -313,11 +274,11 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
theme_color: '#FF2196F3',
);
await _ensureReady();
await _prefs!.setString(_kTheme, 'system');
await _prefs!.setString(_kLanguage, normalized);
await _prefs!.setString(_kPageView, 'continuous');
await _prefs!.setDouble(_kExportDpi, 144.0);
await _prefs!.setString(_kThemeColor, '#FF2196F3');
await _prefs.setString(_kTheme, 'system');
await _prefs.setString(_kLanguage, normalized);
await _prefs.setString(_kPageView, 'continuous');
await _prefs.setDouble(_kExportDpi, 144.0);
await _prefs.setString(_kThemeColor, '#FF2196F3');
}
Future<void> setExportDpi(double dpi) async {
@ -325,13 +286,14 @@ class PreferencesStateNotifier extends Notifier<PreferencesState> {
if (!allowed.contains(dpi)) return;
state = state.copyWith(exportDpi: dpi);
await _ensureReady();
await _prefs!.setDouble(_kExportDpi, dpi);
await _prefs.setDouble(_kExportDpi, dpi);
}
}
final preferencesRepositoryProvider =
NotifierProvider<PreferencesStateNotifier, PreferencesState>(
PreferencesStateNotifier.new,
);
StateNotifierProvider<PreferencesStateNotifier, PreferencesState>((ref) {
// Construct with lazy SharedPreferences initialization.
return PreferencesStateNotifier();
});
// pageViewModeProvider removed; the app always runs in continuous mode.

View File

@ -3,9 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/domain/models/model.dart';
///
class SignatureAssetRepository extends Notifier<List<SignatureAsset>> {
@override
List<SignatureAsset> build() => const [];
class SignatureAssetRepository extends StateNotifier<List<SignatureAsset>> {
SignatureAssetRepository() : super(const []);
/// Preferred API: add from an already decoded image to avoid re-decodes.
void addImage(img.Image image, {String? name}) {
@ -18,6 +17,6 @@ class SignatureAssetRepository extends Notifier<List<SignatureAsset>> {
}
final signatureAssetRepositoryProvider =
NotifierProvider<SignatureAssetRepository, List<SignatureAsset>>(
SignatureAssetRepository.new,
StateNotifierProvider<SignatureAssetRepository, List<SignatureAsset>>(
(ref) => SignatureAssetRepository(),
);

View File

@ -54,9 +54,8 @@ class CachedSignatureCard {
);
}
class SignatureCardStateNotifier extends Notifier<List<SignatureCard>> {
@override
List<SignatureCard> build() => const [];
class SignatureCardStateNotifier extends StateNotifier<List<SignatureCard>> {
SignatureCardStateNotifier() : super(const []);
// Internal storage with cache
final List<CachedSignatureCard> _cards = <CachedSignatureCard>[];
@ -208,6 +207,6 @@ class SignatureCardStateNotifier extends Notifier<List<SignatureCard>> {
}
final signatureCardRepositoryProvider =
NotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
SignatureCardStateNotifier.new,
StateNotifierProvider<SignatureCardStateNotifier, List<SignatureCard>>(
(ref) => SignatureCardStateNotifier(),
);

View File

@ -1,13 +1,13 @@
import 'dart:io';
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:image/image.dart' as img;
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdfrx_engine/pdfrx_engine.dart' as engine;
import 'package:pdfrx/pdfrx.dart' show pdfrxFlutterInitialize;
import 'package:printing/printing.dart' as printing;
import 'package:image/image.dart' as img;
import '../../domain/models/model.dart';
// math moved to utils in rot
import '../../utils/rotation_utils.dart' as rot;
import '../../utils/background_removal.dart' as br;
@ -18,32 +18,32 @@ import '../../utils/background_removal.dart' as br;
// cannot import/modify existing PDF pages. If/when a suitable FOSS library exists, wire it here.
class ExportService {
ExportService({this.enableRaster = true});
// Deprecated: retained for API compatibility. Raster is no longer used.
final bool enableRaster;
/// Compose a new PDF by rendering source pages to images (FOSS path via pdfrx)
/// and overlaying signature images at normalized rects. Returns resulting bytes.
/// Compose a new PDF from source PDF bytes; returns the resulting PDF bytes.
Future<Uint8List?> exportSignedPdfFromBytes({
required Uint8List srcBytes,
required Size uiPageSize, // not used in this implementation
required Uint8List?
signatureImageBytes, // not used; placements carry images
required Size uiPageSize,
required Uint8List? signatureImageBytes,
Map<int, List<SignaturePlacement>>? placementsByPage,
Map<String, img.Image>? libraryImages,
double targetDpi = 144.0,
}) async {
// Caches per call
// Per-call caches to avoid redundant decode/encode and image embedding work
final Map<String, img.Image> _baseImageCache = <String, img.Image>{};
final Map<String, img.Image> _processedImageCache = <String, img.Image>{};
final Map<String, Uint8List> _encodedPngCache = <String, Uint8List>{};
final Map<String, pw.MemoryImage> _memoryImageCache =
<String, pw.MemoryImage>{};
final Map<String, double> _aspectRatioCache = <String, double>{};
// Returns a stable-ish cache key for bytes within this process (not content-hash, but good enough per-call)
String _baseKeyForImage(img.Image im) =>
'im:${identityHashCode(im)}:${im.width}x${im.height}';
String _adjustKey(GraphicAdjust adj) =>
'c=${adj.contrast}|b=${adj.brightness}|bg=${adj.bgRemoval}';
// Removed: PNG signature helper is no longer needed; we always encode to PNG explicitly.
// Resolve base (unprocessed) image for a placement, considering library override.
img.Image _getBaseImage(SignaturePlacement placement) {
final libKey = placement.asset.name;
if (libKey != null && libraryImages != null) {
@ -58,6 +58,7 @@ class ExportService {
return placement.asset.sigImage;
}
// Get processed image for a placement, with caching.
img.Image _getProcessedImage(SignaturePlacement placement) {
final base = _getBaseImage(placement);
final key =
@ -73,15 +74,14 @@ class ExportService {
brightness: adj.brightness,
);
}
Future<void> _ = Future<void>.delayed(Duration.zero);
if (adj.bgRemoval) {
processed = br.removeNearWhiteBackground(processed, threshold: 240);
}
Future<void> _ = Future<void>.delayed(Duration.zero);
_processedImageCache[key] = processed;
return processed;
}
// Get PNG bytes for the processed image, caching the encoding.
Uint8List _getProcessedPng(SignaturePlacement placement) {
final base = _getBaseImage(placement);
final key =
@ -94,6 +94,20 @@ class ExportService {
return png;
}
// Wrap bytes in a pw.MemoryImage with caching.
pw.MemoryImage? _getMemoryImage(Uint8List bytes, String key) {
final cached = _memoryImageCache[key];
if (cached != null) return cached;
try {
final imgObj = pw.MemoryImage(bytes);
_memoryImageCache[key] = imgObj;
return imgObj;
} catch (_) {
return null;
}
}
// Compute and cache aspect ratio (width/height) for given image
double? _getAspectRatioFromImage(img.Image image) {
final key = _baseKeyForImage(image);
final c = _aspectRatioCache[key];
@ -104,56 +118,119 @@ class ExportService {
return ar;
}
// Initialize engine (safe to call multiple times)
pdfrxFlutterInitialize();
// Open source document from memory; if not supported, write temp file
engine.PdfDocument? doc;
try {
doc = await engine.PdfDocument.openData(srcBytes);
} catch (_) {
debugPrint('Warning: pdfrx openData failed');
final tmp = File(
'${Directory.systemTemp.path}/pdfrx_src_${DateTime.now().millisecondsSinceEpoch}.pdf',
);
await tmp.writeAsBytes(srcBytes, flush: true);
doc = await engine.PdfDocument.openFile(tmp.path);
try {
tmp.deleteSync();
} catch (_) {
debugPrint('Warning: temp file delete failed');
}
}
// doc is guaranteed to be assigned by either openData or openFile above
final out = pw.Document(version: pdf.PdfVersion.pdf_1_4, compress: false);
final pages = doc.pages;
final scale = targetDpi / 72.0;
for (int i = 0; i < pages.length; i++) {
// Cooperative yield between pages so the UI can animate the spinner.
await Future<void>.delayed(Duration.zero);
final page = pages[i];
final pageIndex = i + 1;
final widthPts = page.width;
final heightPts = page.height;
int pageIndex = 0;
bool anyPage = false;
try {
await for (final raster in printing.Printing.raster(
srcBytes,
dpi: targetDpi,
)) {
anyPage = true;
pageIndex++;
final widthPx = raster.width;
final heightPx = raster.height;
final widthPts = widthPx * 72.0 / targetDpi;
final heightPts = heightPx * 72.0 / targetDpi;
// Render background image via engine
final imgPage = await page.render(
fullWidth: widthPts * scale,
fullHeight: heightPts * scale,
);
if (imgPage == null) continue;
final bgImage = imgPage.createImageNF();
imgPage.dispose();
// Lower compression for background snapshot too.
final bgPng = Uint8List.fromList(img.encodePng(bgImage, level: 1));
final _ = Future<void>.delayed(Duration.zero);
final bgMem = pw.MemoryImage(bgPng);
final bgPng = await raster.toPng();
final bgImg = pw.MemoryImage(bgPng);
final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements =
hasMulti
? (placementsByPage[pageIndex] ?? const <SignaturePlacement>[])
: const <SignaturePlacement>[];
out.addPage(
pw.Page(
pageTheme: pw.PageTheme(
margin: pw.EdgeInsets.zero,
pageFormat: pdf.PdfPageFormat(widthPts, heightPts),
),
build: (ctx) {
final children = <pw.Widget>[
pw.Positioned(
left: 0,
top: 0,
child: pw.Image(
bgImg,
width: widthPts,
height: heightPts,
fit: pw.BoxFit.fill,
),
),
];
// Multi-placement stamping: per-placement image from libraryBytes
if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) {
final placement = pagePlacements[i];
final r = placement.rect;
// rect is stored in normalized units (0..1) relative to page
final left = r.left * widthPts;
final top = r.top * heightPts;
final w = r.width * widthPts;
final h = r.height * heightPts;
// Get processed image and embed as MemoryImage (cached)
final processedPng = _getProcessedPng(placement);
final baseImage = _getBaseImage(placement);
final memKey =
'${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
if (processedPng.isNotEmpty) {
final imgObj = _getMemoryImage(processedPng, memKey);
if (imgObj != null) {
// Align with RotatedSignatureImage: counterclockwise positive
final angle = rot.radians(placement.rotationDeg);
// Use AR from base image
final ar = _getAspectRatioFromImage(baseImage);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.SizedBox(
width: w,
height: h,
child: pw.FittedBox(
fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate(
angle: angle,
child: pw.Image(imgObj),
),
),
),
),
),
);
}
}
}
}
return pw.Stack(children: children);
},
),
);
}
} catch (e) {
anyPage = false;
}
if (!anyPage) {
// Fallback as A4 blank page with optional signature
final widthPts = pdf.PdfPageFormat.a4.width;
final heightPts = pdf.PdfPageFormat.a4.height;
final hasMulti =
(placementsByPage != null && placementsByPage.isNotEmpty);
final pagePlacements =
(placementsByPage ??
const <int, List<SignaturePlacement>>{})[pageIndex] ??
const <SignaturePlacement>[];
hasMulti
? (placementsByPage[1] ?? const <SignaturePlacement>[])
: const <SignaturePlacement>[];
out.addPage(
pw.Page(
@ -163,70 +240,72 @@ class ExportService {
),
build: (ctx) {
final children = <pw.Widget>[
pw.Positioned(
left: 0,
top: 0,
child: pw.Image(
bgMem,
width: widthPts,
height: heightPts,
fit: pw.BoxFit.fill,
),
pw.Container(
width: widthPts,
height: heightPts,
color: pdf.PdfColors.white,
),
];
for (final placement in pagePlacements) {
final r = placement.rect;
final left = r.left * widthPts;
final top = r.top * heightPts;
final w = r.width * widthPts;
final h = r.height * heightPts;
if (hasMulti && pagePlacements.isNotEmpty) {
for (var i = 0; i < pagePlacements.length; i++) {
final placement = pagePlacements[i];
final r = placement.rect;
// rect is stored in normalized units (0..1) relative to page
final left = r.left * widthPts;
final top = r.top * heightPts;
final w = r.width * widthPts;
final h = r.height * heightPts;
final processedPng = _getProcessedPng(placement);
if (processedPng.isEmpty) continue;
final memImg = pw.MemoryImage(processedPng);
final angle = rot.radians(placement.rotationDeg);
final baseImage = _getBaseImage(placement);
final ar = _getAspectRatioFromImage(baseImage);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
final processedPng = _getProcessedPng(placement);
final baseImage = _getBaseImage(placement);
final memKey =
'${_baseKeyForImage(baseImage)}|${_adjustKey(placement.graphicAdjust)}';
if (processedPng.isNotEmpty) {
final imgObj = _getMemoryImage(processedPng, memKey);
if (imgObj != null) {
final angle = rot.radians(placement.rotationDeg);
final ar = _getAspectRatioFromImage(baseImage);
final scaleToFit = rot.scaleToFitForAngle(angle, ar: ar);
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.SizedBox(
width: w,
height: h,
child: pw.FittedBox(
fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate(
angle: angle,
child: pw.Image(memImg),
children.add(
pw.Positioned(
left: left,
top: top,
child: pw.SizedBox(
width: w,
height: h,
child: pw.FittedBox(
fit: pw.BoxFit.contain,
child: pw.Transform.scale(
scale: scaleToFit,
child: pw.Transform.rotate(
angle: angle,
child: pw.Image(imgObj),
),
),
),
),
),
),
),
),
);
// Yield occasionally within large placement lists to keep UI responsive.
// ignore: unused_local_variable
final _ = Future<void>.delayed(Duration.zero);
);
}
}
}
}
return pw.Stack(children: children);
},
),
);
final _ = Future<void>.delayed(Duration.zero);
}
final bytes = await out.save();
doc.dispose();
debugPrint('exportSignedPdfFromBytes succeeded');
return bytes;
try {
return await out.save();
} catch (_) {
return null;
}
}
/// Helper: write bytes returned from [exportSignedPdfFromBytes] to a file path.
Future<bool> saveBytesToFile({
required Uint8List bytes,
required String outputPath,
@ -236,8 +315,9 @@ class ExportService {
await file.writeAsBytes(bytes, flush: true);
return true;
} catch (_) {
debugPrint('Error: saveBytesToFile failed');
return false;
}
}
// Background removal implemented in utils/background_removal.dart
}

View File

@ -1,22 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:pdf_signature/app.dart';
import 'package:pdf_signature/utils/pdfrx_cache_init/pdfrx_cache_init.dart';
export 'package:pdf_signature/app.dart';
Future<void> main() async {
void main() {
// Ensure Flutter bindings are initialized before platform channel usage
WidgetsFlutterBinding.ensureInitialized();
// Initialize pdfrx core (safe to call multiple times) and set up cache directory.
pdfrxFlutterInitialize();
await initPdfrxCache();
// Disable right-click context menu on web using Flutter API
if (kReleaseMode) {
debugPrint = (String? message, {int? wrapWidth}) {
// Empty implementation in release mode, effectively disabling debugPrint
};
}
if (kIsWeb) {
BrowserContextMenu.disableContextMenu();
}

View File

@ -26,16 +26,15 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: '/',
builder: (context, state) {
final sessionVm = ref.read(pdfSessionViewModelProvider.notifier);
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
return WelcomeScreen(
onPickPdf: () => sessionVm.pickAndOpenPdf(router),
onPickPdf: () => sessionVm.pickAndOpenPdf(),
onOpenPdf:
({String? path, Uint8List? bytes, String? fileName}) =>
sessionVm.openPdf(
path: path,
bytes: bytes ?? Uint8List(0),
bytes: bytes,
fileName: fileName,
router: router,
),
);
},
@ -43,13 +42,12 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: '/pdf',
builder: (context, state) {
final sessionVm = ref.read(pdfSessionViewModelProvider.notifier);
final sessionState = ref.read(pdfSessionViewModelProvider);
final sessionVm = ref.read(pdfSessionViewModelProvider(router));
return PdfSignatureHomePage(
onPickPdf: () => sessionVm.pickAndOpenPdf(router),
onClosePdf: () => sessionVm.closePdf(router),
currentFile: sessionState.currentFile,
currentFileName: sessionState.displayFileName,
onPickPdf: () => sessionVm.pickAndOpenPdf(),
onClosePdf: () => sessionVm.closePdf(),
currentFile: sessionVm.currentFile,
currentFileName: sessionVm.displayFileName,
);
},
),

View File

@ -1,31 +0,0 @@
import 'dart:typed_data';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_version.freezed.dart';
/// Internal data model for tracking document versions in the UI layer.
/// This is separate from the domain Document model to avoid coupling UI concerns with business logic.
@freezed
abstract class DocumentVersion with _$DocumentVersion {
const factory DocumentVersion({
@Default(0) int version,
Uint8List? lastBytes,
}) = _DocumentVersion;
factory DocumentVersion.initial() => const DocumentVersion();
}
extension DocumentVersionMethods on DocumentVersion {
/// Generate the source name for PdfDocumentRefData based on version
String get sourceName => 'document_v$version.pdf';
/// Check if bytes have changed and need version increment
bool shouldIncrementVersion(Uint8List? newBytes) {
return !identical(lastBytes, newBytes);
}
/// Increment version and update bytes
DocumentVersion incrementVersion(Uint8List? newBytes) {
return copyWith(version: version + 1, lastBytes: newBytes);
}
}

View File

@ -1,14 +0,0 @@
/// Immutable state for PdfExportViewModel
class PdfExportState {
final bool exporting;
const PdfExportState({required this.exporting});
factory PdfExportState.initial() {
return const PdfExportState(exporting: false);
}
PdfExportState copyWith({bool? exporting}) {
return PdfExportState(exporting: exporting ?? this.exporting);
}
}

View File

@ -1,66 +1,47 @@
import 'dart:io' show Platform;
import 'package:file_picker/file_picker.dart' as fp;
import 'package:path_provider/path_provider.dart' as pp;
import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_export_state.dart';
import 'package:pdf_signature/data/services/export_service.dart';
/// ViewModel for export-related UI state and helpers.
class PdfExportViewModel extends Notifier<PdfExportState> {
PdfExportViewModel({
class PdfExportViewModel extends ChangeNotifier {
final Ref ref;
bool _exporting = false;
// Dependencies (injectable via constructor for tests)
final ExportService _exporter;
// Zero-arg picker retained for backward compatibility with tests.
final Future<String?> Function() _savePathPicker;
// Preferred picker that accepts a suggested filename.
final Future<String?> Function(String suggestedName)
_savePathPickerWithSuggestedName;
PdfExportViewModel(
this.ref, {
ExportService? exporter,
Future<String?> Function()? savePathPicker,
Future<String?> Function(String suggestedName)?
savePathPickerWithSuggestedName,
}) : _overrideSavePathPicker = savePathPicker,
_overrideSavePathPickerWithSuggestedName =
savePathPickerWithSuggestedName;
}) : _exporter = exporter ?? ExportService(),
_savePathPicker = savePathPicker ?? _defaultSavePathPicker,
// Prefer provided suggested-name picker; otherwise, if only zero-arg
// picker is given (tests), wrap it; else use default that honors name.
_savePathPickerWithSuggestedName =
savePathPickerWithSuggestedName ??
(savePathPicker != null
? ((_) => savePathPicker())
: _defaultSavePathPickerWithSuggestedName);
// Dependencies (injectable via constructor for tests)
// Zero-arg picker retained for backward compatibility with tests.
final Future<String?> Function()? _overrideSavePathPicker;
final Future<String?> Function(String suggestedName)?
_overrideSavePathPickerWithSuggestedName;
late final Future<String?> Function() _savePathPicker;
// Preferred picker that accepts a suggested filename.
late final Future<String?> Function(String suggestedName)
_savePathPickerWithSuggestedName;
@override
PdfExportState build() {
// Initialize with default pickers
_savePathPicker = _overrideSavePathPicker ?? _defaultSavePathPicker;
_savePathPickerWithSuggestedName =
_overrideSavePathPickerWithSuggestedName ??
_defaultSavePathPickerWithSuggestedName;
return PdfExportState.initial();
}
bool get exporting => state.exporting;
bool get exporting => _exporting;
void setExporting(bool value) {
if (state.exporting == value) return;
state = state.copyWith(exporting: value);
if (_exporting == value) return;
_exporting = value;
notifyListeners();
}
/// Perform export via document repository. Returns true on success.
Future<bool> exportToPath({
required String outputPath,
required Size uiPageSize,
required Uint8List? signatureImageBytes,
double targetDpi = 144.0,
}) async {
return await ref
.read(documentRepositoryProvider.notifier)
.exportDocument(
outputPath: outputPath,
uiPageSize: uiPageSize,
signatureImageBytes: signatureImageBytes,
targetDpi: targetDpi,
);
}
/// Get the export service (overridable in tests via constructor).
ExportService get exporter => _exporter;
/// Show save dialog and return the chosen path (null if canceled).
Future<String?> pickSavePath() async {
@ -79,55 +60,18 @@ class PdfExportViewModel extends Notifier<PdfExportState> {
static Future<String?> _defaultSavePathPickerWithSuggestedName(
String suggestedName,
) async {
// Prefer native save dialog via file_picker on all non-web platforms.
// If the user cancels (null) simply bubble up null. If an exception occurs
// (unsupported platform or plugin issue), fall back to an app documents path.
try {
final result = await fp.FilePicker.platform.saveFile(
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)',
final group = fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final location = await fs.getSaveLocation(
acceptedTypeGroups: [group],
suggestedName: suggestedName,
confirmButtonText: 'Save',
);
// 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;
}
return location?.path; // null if user cancels
}
}
final pdfExportViewModelProvider =
NotifierProvider<PdfExportViewModel, PdfExportState>(
PdfExportViewModel.new,
);
final pdfExportViewModelProvider = ChangeNotifierProvider<PdfExportViewModel>((
ref,
) {
return PdfExportViewModel(ref);
});

View File

@ -1,23 +0,0 @@
import 'package:cross_file/cross_file.dart';
/// Immutable state for PdfSessionViewModel
class PdfSessionState {
final XFile currentFile;
final String displayFileName;
const PdfSessionState({
required this.currentFile,
required this.displayFileName,
});
factory PdfSessionState.initial() {
return PdfSessionState(currentFile: XFile(''), displayFileName: '');
}
PdfSessionState copyWith({XFile? currentFile, String? displayFileName}) {
return PdfSessionState(
currentFile: currentFile ?? this.currentFile,
displayFileName: displayFileName ?? this.displayFileName,
);
}
}

View File

@ -1,76 +1,81 @@
// ignore_for_file: unnecessary_import
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_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/model.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/document_version.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_state.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_session_state.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:path_provider/path_provider.dart';
import 'package:flutter/foundation.dart';
class PdfViewModel extends Notifier<PdfViewState> {
@override
PdfViewState build() {
return PdfViewState.initial();
}
PdfViewerController get controller => state.controller;
int get currentPage => state.currentPage;
bool get useMockViewer => state.useMockViewer;
Rect? get activeRect => state.activeRect;
Set<String> get lockedPlacements => state.lockedPlacements;
class PdfViewModel extends ChangeNotifier {
final Ref ref;
PdfViewerController _controller = PdfViewerController();
PdfViewerController get controller => _controller;
int _currentPage = 1;
late final bool _useMockViewer;
bool _isDisposed = false;
// Active rect for signature placement overlay
Rect? _activeRect;
Rect? get activeRect => _activeRect;
set activeRect(Rect? value) {
state = state.copyWith(activeRect: value, clearActiveRect: value == null);
_activeRect = value;
if (!_isDisposed) {
notifyListeners();
}
}
// Locked placements: Set of (page, index) tuples
final Set<String> _lockedPlacements = {};
Set<String> get lockedPlacements => Set.unmodifiable(_lockedPlacements);
// const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
PdfViewModel(this.ref, {bool? useMockViewer})
: _useMockViewer =
useMockViewer ??
const bool.fromEnvironment('FLUTTER_TEST', defaultValue: false);
bool get useMockViewer => _useMockViewer;
int get currentPage => _currentPage;
set currentPage(int value) {
final doc = ref.read(documentRepositoryProvider);
final clamped = value.clamp(1, doc.pageCount);
debugPrint('PdfViewModel.currentPage set to $clamped');
state = state.copyWith(currentPage: clamped);
}
// Get current document source name for PdfDocumentRefData
String get documentSourceName {
// Return the current source name without updating state
// State updates should be done explicitly via updateDocumentVersionIfNeeded()
return state.documentVersion.sourceName;
}
void updateDocumentVersionIfNeeded() {
final document = ref.read(documentRepositoryProvider);
if (!identical(state.documentVersion.lastBytes, document.pickedPdfBytes)) {
state = state.copyWith(
documentVersion: DocumentVersion(
version: state.documentVersion.version + 1,
lastBytes: document.pickedPdfBytes,
),
);
_currentPage = value.clamp(1, document.pageCount);
if (!_isDisposed) {
notifyListeners();
}
}
// Do not watch the document repository here; watching would cause this
// notifier to be disposed/recreated on every document change, which
// ChangeNotifier to be disposed/recreated on every document change, which
// resets transient UI state like locked placements. Read instead.
Document get document => ref.read(documentRepositoryProvider);
void jumpToPage(int page) {
debugPrint('PdfViewModel.jumpToPage $page');
currentPage = page;
}
// Make this view model "int-like" for tests that compare it directly to an
// integer or use it as a Map key for page lookups.
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is int) {
return other == currentPage;
}
return false;
}
@override
int get hashCode => currentPage.hashCode;
// Allow repositories to request a UI refresh without mutating provider state
void notifyPlacementsChanged() {
// Force a rebuild by updating state
state = state.copyWith();
if (!_isDisposed) {
notifyListeners();
}
}
// Document repository methods
@ -105,7 +110,11 @@ class PdfViewModel extends Notifier<PdfViewState> {
}) {
ref
.read(documentRepositoryProvider.notifier)
.modifyPlacement(page: page, index: index, rotationDeg: rotationDeg);
.updatePlacementRotation(
page: page,
index: index,
rotationDeg: rotationDeg,
);
}
void removePlacement({required int page, required int index}) {
@ -113,9 +122,10 @@ class PdfViewModel extends Notifier<PdfViewState> {
.read(documentRepositoryProvider.notifier)
.removePlacement(page: page, index: index);
// Also remove from locked placements if it was locked
final newLocked = Set<String>.from(state.lockedPlacements)
..remove(_placementKey(page, index));
state = state.copyWith(lockedPlacements: newLocked);
_lockedPlacements.remove(_placementKey(page, index));
if (!_isDisposed) {
notifyListeners();
}
}
void updatePlacementRect({
@ -125,7 +135,7 @@ class PdfViewModel extends Notifier<PdfViewState> {
}) {
ref
.read(documentRepositoryProvider.notifier)
.modifyPlacement(page: page, index: index, rect: rect);
.updatePlacementRect(page: page, index: index, rect: rect);
}
List<SignaturePlacement> placementsOn(int page) {
@ -143,21 +153,23 @@ class PdfViewModel extends Notifier<PdfViewState> {
// Check if a placement is locked
bool isPlacementLocked({required int page, required int index}) {
return state.lockedPlacements.contains(_placementKey(page, index));
return _lockedPlacements.contains(_placementKey(page, index));
}
// Lock a placement
void lockPlacement({required int page, required int index}) {
final newLocked = Set<String>.from(state.lockedPlacements)
..add(_placementKey(page, index));
state = state.copyWith(lockedPlacements: newLocked);
_lockedPlacements.add(_placementKey(page, index));
if (!_isDisposed) {
notifyListeners();
}
}
// Unlock a placement
void unlockPlacement({required int page, required int index}) {
final newLocked = Set<String>.from(state.lockedPlacements)
..remove(_placementKey(page, index));
state = state.copyWith(lockedPlacements: newLocked);
_lockedPlacements.remove(_placementKey(page, index));
if (!_isDisposed) {
notifyListeners();
}
}
// Toggle lock state of a placement
@ -214,166 +226,109 @@ class PdfViewModel extends Notifier<PdfViewState> {
void clearAllSignatureCards() {
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
}
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
}
final pdfViewModelProvider = NotifierProvider<PdfViewModel, PdfViewState>(
PdfViewModel.new,
);
final pdfViewModelProvider = ChangeNotifierProvider<PdfViewModel>((ref) {
return PdfViewModel(ref);
});
/// ViewModel managing PDF session lifecycle (file picking/open/close) and
/// navigation. Replaces the previous PdfManager helper.
class PdfSessionViewModel extends Notifier<PdfSessionState> {
@override
PdfSessionState build() {
return PdfSessionState.initial();
}
class PdfSessionViewModel extends ChangeNotifier {
final Ref ref;
final GoRouter router;
fs.XFile _currentFile = fs.XFile('');
// 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
// XFile.name derives from that basename, yielding a random UUID instead of
// the actual filename the user selected. We preserve the picker/drop name
// here to offer a sensible default like "signed_<original>.pdf".
String _displayFileName = '';
XFile get currentFile => state.currentFile;
String get displayFileName => state.displayFileName;
PdfSessionViewModel({required this.ref, required this.router});
Future<void> pickAndOpenPdf(GoRouter router) async {
final result = await fp.FilePicker.platform.pickFiles(
type: fp.FileType.custom,
allowedExtensions: const ['pdf'],
withData: true,
);
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) {
fs.XFile get currentFile => _currentFile;
String get displayFileName => _displayFileName;
Future<void> pickAndOpenPdf() async {
final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']);
final XFile? file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file != null) {
Uint8List? bytes;
try {
effectiveBytes = await XFile(path).readAsBytes();
} catch (e, st) {
effectiveBytes = null;
debugPrint(
'[PdfSessionViewModel] Failed to read PDF data from path=$path error=$e',
);
debugPrint(st.toString());
bytes = await file.readAsBytes();
} catch (_) {
bytes = null;
}
}
if (effectiveBytes != null) {
await openPdf(
path: path,
bytes: effectiveBytes,
fileName: name,
router: router,
);
} else {
debugPrint(
'[PdfSessionViewModel] No PDF data available to open (path=$path, name=$name)',
);
await openPdf(path: file.path, bytes: bytes, fileName: file.name);
}
}
Future<void> openPdf({
String? path,
required Uint8List bytes,
Uint8List? bytes,
String? fileName,
required GoRouter router,
}) async {
int pageCount = 1; // default
try {
// Defensive: ensure Pdfrx cache directory set (in case main init skipped in tests)
if (Pdfrx.getCacheDirectory == null && !kIsWeb) {
debugPrint('[PdfSessionViewModel] Setting Pdfrx cache directory (io)');
try {
final temp = await getTemporaryDirectory();
Pdfrx.getCacheDirectory = () async => temp.path;
} catch (e, st) {
debugPrint(
'[PdfSessionViewModel] Failed to set fallback cache dir error=$e',
);
debugPrint(st.toString());
}
if (bytes != null) {
try {
final doc = await PdfDocument.openData(bytes);
pageCount = doc.pages.length;
} catch (_) {
// ignore invalid bytes
}
// Ensure engine initialized (safe multiple calls)
pdfrxFlutterInitialize();
final doc = await PdfDocument.openData(bytes);
pageCount = doc.pages.length;
debugPrint(
'[PdfSessionViewModel] Opened PDF bytes length=${bytes.length} pages=$pageCount',
);
// Use fast path to populate repository BEFORE navigation so the first
// build of PdfViewerWidget sees a loaded document and avoids showing
// transient "No PDF loaded".
ref
.read(documentRepositoryProvider.notifier)
.openDocument(
bytes: bytes,
pageCount: pageCount,
knownPageCount: true,
);
} catch (e, st) {
debugPrint(
'[PdfSessionViewModel] Failed to read PDF data from bytes error=$e',
);
debugPrint(st.toString());
}
XFile newFile;
if (path != null && path.isNotEmpty) {
newFile = XFile(path);
} else if ((fileName != null && fileName.isNotEmpty)) {
_currentFile = fs.XFile(path);
} else if (bytes != null && (fileName != null && fileName.isNotEmpty)) {
// Keep in-memory XFile so .name is available for suggestion
try {
newFile = XFile.fromData(
_currentFile = fs.XFile.fromData(
bytes,
name: fileName,
mimeType: 'application/pdf',
);
} catch (e, st) {
newFile = XFile(fileName);
debugPrint(
'[PdfSessionViewModel] Failed to create XFile.fromData name=$fileName error=$e',
);
debugPrint(st.toString());
} catch (_) {
_currentFile = fs.XFile(fileName);
}
} else {
newFile = XFile('');
_currentFile = fs.XFile('');
}
// Update display name: prefer explicit fileName (from picker/drop),
// fall back to basename of path, otherwise empty.
String newDisplayFileName;
if (fileName != null && fileName.isNotEmpty) {
newDisplayFileName = fileName;
_displayFileName = fileName;
} else if (path != null && path.isNotEmpty) {
newDisplayFileName = path.split('/').last.split('\\').last;
_displayFileName = path.split('/').last.split('\\').last;
} else {
newDisplayFileName = '';
_displayFileName = '';
}
state = state.copyWith(
currentFile: newFile,
displayFileName: newDisplayFileName,
);
// If fast path failed to set repository (e.g., exception earlier), fallback to async derive.
if (ref.read(documentRepositoryProvider).pickedPdfBytes != bytes) {
debugPrint(
'[PdfSessionViewModel] Fallback deriving page count via openDocument',
);
ref.read(documentRepositoryProvider.notifier).openDocument(bytes: bytes);
}
// Keep existing signature cards when opening a new document.
// The feature "Open a different document will reset signature placements but keep signature cards"
// relies on this behavior. Placements are reset by openPicked() above.
debugPrint('[PdfSessionViewModel] Navigating to /pdf');
ref
.read(documentRepositoryProvider.notifier)
.openPicked(pageCount: pageCount, bytes: bytes);
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
router.go('/pdf');
debugPrint('[PdfSessionViewModel] State updated after open');
notifyListeners();
}
void closePdf(GoRouter router) {
void closePdf() {
ref.read(documentRepositoryProvider.notifier).close();
ref.read(signatureCardRepositoryProvider.notifier).clearAll();
state = state.copyWith(currentFile: XFile(''), displayFileName: '');
_currentFile = fs.XFile('');
_displayFileName = '';
router.go('/');
notifyListeners();
}
}
final pdfSessionViewModelProvider =
NotifierProvider<PdfSessionViewModel, PdfSessionState>(
PdfSessionViewModel.new,
);
ChangeNotifierProvider.family<PdfSessionViewModel, GoRouter>((ref, router) {
return PdfSessionViewModel(ref: ref, router: router);
});

View File

@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:pdf_signature/ui/features/pdf/view_model/document_version.dart';
import 'package:pdfrx/pdfrx.dart';
/// Immutable state for PdfViewModel
class PdfViewState {
final PdfViewerController controller;
final int currentPage;
final bool useMockViewer;
final Rect? activeRect;
final Set<String> lockedPlacements;
final DocumentVersion documentVersion;
const PdfViewState({
required this.controller,
required this.currentPage,
required this.useMockViewer,
this.activeRect,
required this.lockedPlacements,
required this.documentVersion,
});
factory PdfViewState.initial({bool? useMockViewer}) {
// Default to false (no mocking) - tests should explicitly pass true if they want mock viewer
final defaultUseMock = useMockViewer ?? false;
return PdfViewState(
controller: PdfViewerController(),
currentPage: 1,
useMockViewer: defaultUseMock,
activeRect: null,
lockedPlacements: const {},
documentVersion: DocumentVersion.initial(),
);
}
PdfViewState copyWith({
PdfViewerController? controller,
int? currentPage,
bool? useMockViewer,
Rect? activeRect,
bool clearActiveRect = false,
Set<String>? lockedPlacements,
DocumentVersion? documentVersion,
}) {
return PdfViewState(
controller: controller ?? this.controller,
currentPage: currentPage ?? this.currentPage,
useMockViewer: useMockViewer ?? this.useMockViewer,
activeRect: clearActiveRect ? null : (activeRect ?? this.activeRect),
lockedPlacements: lockedPlacements ?? this.lockedPlacements,
documentVersion: documentVersion ?? this.documentVersion,
);
}
}

View File

@ -40,29 +40,11 @@ class ThumbnailsView extends ConsumerWidget {
// to update provider when the page is actually reached.
// For mock/unready: update provider immediately to drive scroll.
final isRealViewer = !viewModel.useMockViewer;
// Debug trace for navigation taps
// ignore: avoid_print
debugPrint(
'PagesSidebar.onTap page=$pageNumber isRealViewer=$isRealViewer controllerReady=${controller.isReady}',
);
if (isRealViewer && controller.isReady) {
try {
controller.goToPage(
pageNumber: pageNumber,
anchor: PdfPageAnchor.top,
);
// ignore: avoid_print
debugPrint(
'controller.goToPage invoked for page=$pageNumber',
);
} catch (e, st) {
// ignore: avoid_print
debugPrint(
'[ERR] controller.goToPage exception: ' + e.toString(),
);
// ignore: avoid_print
debugPrint(st.toString());
}
controller.goToPage(
pageNumber: pageNumber,
anchor: PdfPageAnchor.top,
);
// Do not set provider here; let onPageChanged handle it
} else {
// In tests or when controller isn't ready, drive state directly
@ -70,8 +52,6 @@ class ThumbnailsView extends ConsumerWidget {
ref
.read(pdfViewModelProvider.notifier)
.jumpToPage(pageNumber);
// ignore: avoid_print
debugPrint('jumpToPage set directly to $pageNumber');
} catch (_) {}
}
},
@ -93,18 +73,14 @@ class ThumbnailsView extends ConsumerWidget {
padding: const EdgeInsets.all(6),
child: Column(
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: AspectRatio(
// A4 portrait aspect: width:height 1:1.4142
aspectRatio: 1 / 1.4142,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: PdfPageView(
document: document,
pageNumber: pageNumber,
alignment: Alignment.center,
),
SizedBox(
height: 180,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: PdfPageView(
document: document,
pageNumber: pageNumber,
alignment: Alignment.center,
),
),
),

View File

@ -6,19 +6,16 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import 'pdf_viewer_widget.dart';
import 'package:pdfrx/pdfrx.dart';
import '../view_model/pdf_view_model.dart';
import '../view_model/pdf_export_view_model.dart';
class PdfPageArea extends ConsumerStatefulWidget {
const PdfPageArea({
super.key,
required this.pageSize,
required this.controller,
this.onDocumentChanged,
});
final Size pageSize;
final PdfViewerController controller;
final void Function(PdfDocument?)? onDocumentChanged;
@override
ConsumerState<PdfPageArea> createState() => _PdfPageAreaState();
}
@ -41,8 +38,10 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
super.initState();
// If app starts in continuous mode with a loaded PDF, ensure the viewer
// is instructed to align to the provider's current page once ready.
// Do not schedule mock scroll sync in real viewer mode.
// In mock mode, scrolling is driven on demand when currentPage changes.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// initial scroll not needed; controller handles positioning
});
}
// No dispose required for PdfViewerController (managed by owner if any)
@ -55,9 +54,6 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
void _scrollToPage(int page) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Only valid in mock viewer mode; skip otherwise
final useMock = ref.read(pdfViewModelProvider).useMockViewer;
if (!useMock) return;
_programmaticTargetPage = page;
// Mock continuous: try ensureVisible on the page container
// Mock continuous: try ensureVisible on the page container
@ -112,20 +108,12 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
@override
Widget build(BuildContext context) {
final pdfViewState = ref.watch(pdfViewModelProvider);
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final pdfViewModel = ref.watch(pdfViewModelProvider);
final pdf = pdfViewModel.document;
const pageViewMode = 'continuous';
// React to PdfViewModel currentPage changes. With NotifierProvider,
// prev/next are now different state objects, so we can compare them directly.
// React to PdfViewModel currentPage changes. With ChangeNotifierProvider,
// prev/next are the same instance, so compare to a local cache.
ref.listen(pdfViewModelProvider, (prev, next) {
// Only perform manual scrolling in mock viewer mode. In real viewer mode,
// PdfViewerController + onPageChanged keep things in sync, and attempting
// to scroll here (without mock page keys) creates repeated frame
// callbacks that never find targets, leading to hangs.
if (!next.useMockViewer) {
return;
}
if (_suppressProviderListen) return;
final target = next.currentPage;
if (_lastListenedPage == target) return;
@ -155,20 +143,11 @@ class _PdfPageAreaState extends ConsumerState<PdfPageArea> {
// Use real PDF viewer
if (isContinuous) {
// While exporting, fully detach the viewer to avoid background activity
// and ensure a clean re-initialization afterward.
final exporting = ref.watch(pdfExportViewModelProvider).exporting;
if (exporting) {
return const SizedBox.expand(key: Key('exporting_viewer_placeholder'));
}
return PdfViewerWidget(
pageSize: widget.pageSize,
pageKeyBuilder: _pageKey,
scrollToPage: _scrollToPage,
controller: widget.controller,
// Remove fixed innerViewerKey to allow PdfViewerWidget to generate dynamic keys
// innerViewerKey: const ValueKey('viewer_idle'),
onDocumentChanged: widget.onDocumentChanged,
);
}
return const SizedBox.shrink();

View File

@ -7,7 +7,6 @@ import '../../../../domain/models/model.dart';
import 'signature_overlay.dart';
import '../../signature/widgets/signature_drag_data.dart';
import '../../signature/view_model/dragging_signature_view_model.dart';
import 'pdf_viewer_widget.dart' show viewerOverlaysEnabledProvider;
/// Builds all overlays for a given page: placed signatures and the active one.
class PdfPageOverlays extends ConsumerWidget {
@ -32,16 +31,12 @@ class PdfPageOverlays extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final overlaysEnabled = ref.watch(viewerOverlaysEnabledProvider);
if (!overlaysEnabled) {
return const SizedBox.shrink();
}
final pdfViewState = ref.watch(pdfViewModelProvider);
final pdfViewModel = ref.watch(pdfViewModelProvider);
// Subscribe to document changes to rebuild overlays
final pdf = ref.watch(documentRepositoryProvider);
final placed =
pdf.placementsByPage[pageNumber] ?? const <SignaturePlacement>[];
final activeRect = pdfViewState.activeRect;
final activeRect = pdfViewModel.activeRect;
final widgets = <Widget>[];
// Base DragTarget filling the whole page to accept drops from signature cards.
@ -116,10 +111,10 @@ class PdfPageOverlays extends ConsumerWidget {
// TODO:Add active overlay if present and not using mock (mock has its own)
final useMock = pdfViewState.useMockViewer;
final useMock = pdfViewModel.useMockViewer;
if (!useMock &&
activeRect != null &&
pageNumber == pdfViewState.currentPage) {
pageNumber == pdfViewModel.currentPage) {
widgets.add(
LayoutBuilder(
builder: (context, constraints) {

View File

@ -1,5 +1,4 @@
import 'package:cross_file/cross_file.dart';
import 'package:file_picker/file_picker.dart' as fp;
import 'package:file_selector/file_selector.dart' as fs;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -17,20 +16,16 @@ import '../view_model/pdf_export_view_model.dart';
import 'package:pdf_signature/utils/download.dart';
import '../view_model/pdf_view_model.dart';
import 'package:image/image.dart' as img;
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'package:responsive_framework/responsive_framework.dart';
class PdfSignatureHomePage extends ConsumerStatefulWidget {
final Future<void> Function() onPickPdf;
final VoidCallback onClosePdf;
final XFile currentFile;
final fs.XFile currentFile;
// Optional display name for the currently opened file. On Linux
// xdg-desktop-portal, XFile.name/path can be a UUID-like value. When
// available, this name preserves the user-selected filename so we can
// suggest a proper "signed_*.pdf" on save.
final String? currentFileName;
// Optional listener for underlying Pdfrx document change events.
final void Function(PdfDocument?)? onDocumentChanged;
const PdfSignatureHomePage({
super.key,
@ -38,7 +33,6 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
required this.onClosePdf,
required this.currentFile,
this.currentFileName,
this.onDocumentChanged,
});
@override
@ -63,7 +57,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final double _signaturesMin = 140;
final double _signaturesMax = 250;
late PdfViewModel _viewModel;
bool? _lastCanShowPagesSidebar;
// Exposed for tests to trigger the invalid-file SnackBar without UI.
@visibleForTesting
@ -117,18 +110,15 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
Future<img.Image?> _loadSignatureFromFile() async {
final result = await fp.FilePicker.platform.pickFiles(
type: fp.FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
withData: true,
final typeGroup = fs.XTypeGroup(
label:
Localizations.of<AppLocalizations>(context, AppLocalizations)?.image,
extensions: ['png', 'jpg', 'jpeg', 'webp'],
);
if (result == null || result.files.isEmpty) return null;
final picked = result.files.single;
final Uint8List? bytes =
picked.bytes ??
(picked.path != null ? await XFile(picked.path!).readAsBytes() : null);
final file = await fs.openFile(acceptedTypeGroups: [typeGroup]);
if (file == null) return null;
final bytes = await file.readAsBytes();
try {
if (bytes == null) return null;
var sigImage = img.decodeImage(bytes);
return _toStdSignatureImage(sigImage);
} catch (_) {
@ -154,114 +144,105 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
Future<void> _saveSignedPdf() async {
// Show exporting overlay and then run the heavy work asynchronously so
// the UI thread remains responsive to gestures like page navigation.
ref.read(pdfExportViewModelProvider.notifier).setExporting(true);
// ignore: avoid_print
debugPrint('_saveSignedPdf: exporting flag set true');
final weakContext = context;
Future<void>(() async {
try {
// ignore: avoid_print
debugPrint('_saveSignedPdf: async export task started');
final pdf = _viewModel.document;
final messenger = ScaffoldMessenger.of(weakContext);
if (!pdf.loaded) {
// ignore: avoid_print
debugPrint('_saveSignedPdf: document not loaded');
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(weakContext).nothingToSaveYet),
),
);
return;
}
// get DPI from preferences
final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi;
bool ok = false;
String? savedPath;
try {
final pdf = _viewModel.document;
final messenger = ScaffoldMessenger.of(context);
if (!pdf.loaded) {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).nothingToSaveYet),
),
);
return;
}
final exporter = ref.read(pdfExportViewModelProvider).exporter;
// Derive a suggested filename based on the opened file.
final display = widget.currentFileName;
final originalName =
(display != null && display.trim().isNotEmpty)
? display.trim()
: widget.currentFile.name.isNotEmpty
? widget.currentFile.name
: widget.currentFile.path.isNotEmpty
? widget.currentFile.path.split('/').last.split('\\').last
: 'document.pdf';
final suggested = _suggestSignedName(originalName);
// get DPI from preferences
final targetDpi = ref.read(preferencesRepositoryProvider).exportDpi;
bool ok = false;
String? savedPath;
if (!kIsWeb) {
final path = await ref
.read(pdfExportViewModelProvider.notifier)
.pickSavePathWithSuggestedName(suggested);
if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim());
savedPath = fullPath;
// ignore: avoid_print
debugPrint('_saveSignedPdf: picked save path ' + fullPath);
ok = await ref
.read(pdfExportViewModelProvider.notifier)
.exportToPath(
outputPath: fullPath,
uiPageSize: _pageSize,
signatureImageBytes: null,
targetDpi: targetDpi,
);
// ignore: avoid_print
debugPrint('_saveSignedPdf: saveBytesToFile ok=' + ok.toString());
} else {
// Web: export and trigger browser download
final out = await ref
.read(documentRepositoryProvider.notifier)
.exportDocumentToBytes(
uiPageSize: _pageSize,
signatureImageBytes: null,
targetDpi: targetDpi,
);
if (out != null) {
ok = await downloadBytes(out, filename: suggested);
savedPath = suggested;
} else {
debugPrint('_saveSignedPdf: export to bytes failed');
}
// Derive a suggested filename based on the opened file. Prefer the
// provided display name if available (see Linux portal note above).
final display = widget.currentFileName;
final originalName =
(display != null && display.trim().isNotEmpty)
? display.trim()
: widget.currentFile.name.isNotEmpty
? widget.currentFile.name
: widget.currentFile.path.isNotEmpty
? widget.currentFile.path.split('/').last.split('\\').last
: 'document.pdf';
final suggested = _suggestSignedName(originalName);
if (!kIsWeb) {
final path = await ref
.read(pdfExportViewModelProvider)
.pickSavePathWithSuggestedName(suggested);
if (path == null || path.trim().isEmpty) return;
final fullPath = _ensurePdfExtension(path.trim());
savedPath = fullPath;
final src = pdf.pickedPdfBytes ?? Uint8List(0);
final out = await exporter.exportSignedPdfFromBytes(
srcBytes: src,
uiPageSize: _pageSize,
signatureImageBytes: null,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
if (out != null) {
ok = await exporter.saveBytesToFile(bytes: out, outputPath: fullPath);
}
if (!kIsWeb) {
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? AppLocalizations.of(
weakContext,
).savedWithPath(savedPath ?? '')
: AppLocalizations.of(weakContext).failedToSavePdf,
),
),
);
debugPrint('_saveSignedPdf: SnackBar shown ok=' + ok.toString());
} else {
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? AppLocalizations.of(
weakContext,
).savedWithPath(savedPath ?? 'signed.pdf')
: AppLocalizations.of(weakContext).failedToSavePdf,
),
),
);
}
} finally {
if (mounted) {
ref.read(pdfExportViewModelProvider.notifier).setExporting(false);
// ignore: avoid_print
debugPrint('_saveSignedPdf: exporting flag set false');
} else {
// Web: export and trigger browser download
final src = pdf.pickedPdfBytes ?? Uint8List(0);
final out = await exporter.exportSignedPdfFromBytes(
srcBytes: src,
uiPageSize: _pageSize,
signatureImageBytes: null,
placementsByPage: pdf.placementsByPage,
targetDpi: targetDpi,
);
if (out != null) {
// Use suggested filename for browser download
ok = await downloadBytes(out, filename: suggested);
savedPath = suggested;
}
}
});
if (!kIsWeb) {
if (ok) {
messenger.showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).savedWithPath(savedPath ?? ''),
),
),
);
} else {
messenger.showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).failedToSavePdf),
),
);
}
} else {
// Web: show a toast-like confirmation
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? AppLocalizations.of(
context,
).savedWithPath(savedPath ?? 'signed.pdf')
: AppLocalizations.of(context).failedToSavePdf,
),
),
);
}
} finally {
ref.read(pdfExportViewModelProvider.notifier).setExporting(false);
}
}
String _ensurePdfExtension(String name) {
@ -316,28 +297,17 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
max: _pagesMax,
builder:
(context, area) => Offstage(
offstage: () {
try {
return !(ResponsiveBreakpoints.of(
context,
).largerThan(MOBILE) &&
_showPagesSidebar);
} catch (_) {
// In test environments without ResponsiveBreakpoints, default to showing
return !_showPagesSidebar;
}
}(),
offstage: !_showPagesSidebar,
child: Consumer(
builder: (context, ref, child) {
final pdfViewState = ref.watch(pdfViewModelProvider);
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final pdfViewModel = ref.watch(pdfViewModelProvider);
final pdf = pdfViewModel.document;
final documentRef =
pdf.loaded && pdf.pickedPdfBytes != null
? PdfDocumentRefData(
pdf.pickedPdfBytes!,
sourceName: pdfViewModel.documentSourceName,
sourceName: 'document.pdf',
)
: null;
@ -358,7 +328,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
controller: _viewModel.controller,
key: const ValueKey('pdf_page_area'),
pageSize: _pageSize,
onDocumentChanged: widget.onDocumentChanged,
),
),
),
@ -382,24 +351,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
_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
void dispose() {
_viewModel.controller.removeListener(_onControllerChanged);
@ -408,65 +359,29 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
}
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
final left = _splitController.areas[0];
final wantPagesVisible = _showPagesSidebar && canShowPagesSidebar;
final isPagesHidden =
(left.max == 1 && left.min == 0 && (left.size ?? 1) == 1);
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;
}
if (_showPagesSidebar) {
left.max = _pagesMax;
left.min = _pagesMin;
left.size = _lastPagesWidth.clamp(_pagesMin, _pagesMax);
} else {
// Only collapse if currently visible; remember current size for restore.
if (!isPagesHidden) {
_lastPagesWidth = left.size ?? _lastPagesWidth;
left.min = 0;
left.max = 1;
left.size = 1; // effectively hidden
}
_lastPagesWidth = left.size ?? _lastPagesWidth;
left.min = 0;
left.max = 1;
left.size = 1; // effectively hidden
}
// Right signatures sidebar
final right = _splitController.areas[2];
final isSignaturesHidden =
(right.max == 1 && right.min == 0 && (right.size ?? 1) == 1);
if (_showSignaturesSidebar) {
if (isSignaturesHidden) {
right.max = _signaturesMax;
right.min = _signaturesMin;
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
} else {
right.max = _signaturesMax;
right.min = _signaturesMin;
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
}
right.max = _signaturesMax;
right.min = _signaturesMin;
right.size = _lastSignaturesWidth.clamp(_signaturesMin, _signaturesMax);
} else {
if (!isSignaturesHidden) {
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
right.min = 0;
right.max = 1;
right.size = 1;
}
_lastSignaturesWidth = right.size ?? _lastSignaturesWidth;
right.min = 0;
right.max = 1;
right.size = 1;
}
}
@ -478,13 +393,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
Widget _buildScaffold(BuildContext context) {
final isExporting = ref.watch(pdfExportViewModelProvider).exporting;
final l = AppLocalizations.of(context);
// Defensive flag for tests not wrapped in ResponsiveBreakpoints
bool largerThanMobile;
try {
largerThanMobile = ResponsiveBreakpoints.of(context).largerThan(MOBILE);
} catch (_) {
largerThanMobile = true;
}
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(12),
@ -545,28 +453,6 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
_applySidebarVisibility();
}),
),
// Optional quick toggle for pages sidebar on larger screens
if (largerThanMobile)
Align(
alignment: Alignment.centerLeft,
child: SizedBox(
height: 0,
width: 0,
child: Offstage(
offstage: true,
child: IconButton(
key: const Key('btn_toggle_pages_sidebar_hidden'),
onPressed: () {
setState(() {
_showPagesSidebar = !_showPagesSidebar;
_applySidebarVisibility();
});
},
icon: const Icon(Icons.view_sidebar),
),
),
),
),
// Expose a compact signature drawer trigger area for tests when sidebar hidden
if (!_showSignaturesSidebar)
Align(

View File

@ -2,10 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/data/repositories/document_repository.dart';
class PdfToolbar extends ConsumerStatefulWidget {
const PdfToolbar({
@ -59,11 +57,9 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
@override
Widget build(BuildContext context) {
final pdfViewState = ref.watch(pdfViewModelProvider);
final pdf = ref.watch(
documentRepositoryProvider,
); // Watch document directly for updates
final currentPage = pdfViewState.currentPage;
final pdfViewModel = ref.watch(pdfViewModelProvider);
final pdf = pdfViewModel.document;
final currentPage = pdfViewModel.currentPage;
final l = AppLocalizations.of(context);
final pageInfo = l.pageInfo(currentPage, pdf.pageCount);
@ -71,21 +67,6 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
builder: (context, constraints) {
final bool compact = constraints.maxWidth < 260;
final double gotoWidth = 50;
// Be defensive in tests that don't provide ResponsiveBreakpoints
final bool isLargerThanMobile = () {
try {
return ResponsiveBreakpoints.of(context).largerThan(MOBILE);
} catch (_) {
return true; // default to full toolbar on tests/minimal hosts
}
}();
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
final center = Wrap(
@ -101,15 +82,14 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
children: [
const Icon(Icons.insert_drive_file, size: 18),
const SizedBox(width: 6),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
fileDisplay,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
// if filePath not null
widget.filePath != null
? widget.filePath!
: 'No file selected',
overflow: TextOverflow.ellipsis,
),
),
],
@ -150,68 +130,62 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
),
],
),
if (isLargerThanMobile)
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(l.goTo),
SizedBox(
width: gotoWidth,
child: TextField(
key: const Key('txt_goto'),
controller: _goToController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: !widget.disabled,
decoration: InputDecoration(
isDense: true,
hintText: '1..${pdf.pageCount}',
),
onSubmitted: (_) => _submitGoTo(),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(l.goTo),
SizedBox(
width: gotoWidth,
child: TextField(
key: const Key('txt_goto'),
controller: _goToController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: !widget.disabled,
decoration: InputDecoration(
isDense: true,
hintText: '1..${pdf.pageCount}',
),
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,
),
],
),
if (isLargerThanMobile) ...[
const SizedBox(width: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
),
if (!compact)
IconButton(
key: const Key('btn_zoom_out'),
tooltip: 'Zoom out',
onPressed: widget.disabled ? null : widget.onZoomOut,
icon: const Icon(Icons.zoom_out),
key: const Key('btn_goto_apply'),
tooltip: l.goTo,
icon: const Icon(Icons.arrow_forward),
onPressed: widget.disabled ? null : _submitGoTo,
),
Text(
//if not null
widget.zoomLevel != null
? '${widget.zoomLevel}%'
: '',
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),
],
],
),
const SizedBox(width: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton(
key: const Key('btn_zoom_out'),
tooltip: 'Zoom out',
onPressed: widget.disabled ? null : widget.onZoomOut,
icon: const Icon(Icons.zoom_out),
),
Text(
//if not null
widget.zoomLevel != null ? '${widget.zoomLevel}%' : '',
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),
],
),
],
@ -220,21 +194,19 @@ class _PdfToolbarState extends ConsumerState<PdfToolbar> {
return Row(
children: [
if (isLargerThanMobile) ...[
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,
),
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),
],
),
const SizedBox(width: 8),
Expanded(child: center),
const SizedBox(width: 8),
IconButton(

View File

@ -5,13 +5,6 @@ import 'package:pdf_signature/l10n/app_localizations.dart';
import 'pdf_page_overlays.dart';
import './pdf_mock_continuous_list.dart';
import '../view_model/pdf_view_model.dart';
import 'package:pdf_signature/domain/models/document.dart';
import 'package:pdf_signature/data/repositories/document_repository.dart';
import 'dart:typed_data';
// Provider to control whether viewer overlays (like scroll thumbs) are enabled.
// Integration tests can override this to false to avoid long-running animations.
final viewerOverlaysEnabledProvider = Provider<bool>((ref) => true);
class PdfViewerWidget extends ConsumerStatefulWidget {
const PdfViewerWidget({
@ -20,57 +13,19 @@ class PdfViewerWidget extends ConsumerStatefulWidget {
this.pageKeyBuilder,
this.scrollToPage,
required this.controller,
this.innerViewerKey,
this.onDocumentChanged,
});
final Size pageSize;
final GlobalKey Function(int page)? pageKeyBuilder;
final void Function(int page)? scrollToPage;
final PdfViewerController controller;
// Optional key applied to the inner Pdfrx PdfViewer to force disposal/rebuild
final Key? innerViewerKey;
// External hook to observe document changes (forwarded from Pdfrx onDocumentChanged)
final void Function(PdfDocument?)? onDocumentChanged;
@override
ConsumerState<PdfViewerWidget> createState() => _PdfViewerWidgetState();
}
class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
final ValueNotifier<PdfDocumentRef?> _docRefNotifier = ValueNotifier(null);
Uint8List? _lastBytes;
void _updateDocRef(Document doc) {
if (!doc.loaded || doc.pickedPdfBytes == null) {
if (_docRefNotifier.value != null) {
debugPrint('[PdfViewerWidget] Clearing docRef (no document loaded)');
_docRefNotifier.value = null;
}
return;
}
final bytes = doc.pickedPdfBytes!;
if (!identical(bytes, _lastBytes)) {
_lastBytes = bytes;
final viewModel = ref.read(pdfViewModelProvider.notifier);
// Update document version outside of build
Future.microtask(() {
viewModel.updateDocumentVersionIfNeeded();
});
debugPrint(
'[PdfViewerWidget] New PDF bytes detected -> ${viewModel.documentSourceName}',
);
// Force a full detach by setting null first so PdfViewer unmounts even if the
// framework would otherwise optimize rebuilds with same key ordering.
if (_docRefNotifier.value != null) {
_docRefNotifier.value = null;
}
final newRef = PdfDocumentRefData(
bytes,
sourceName: viewModel.documentSourceName,
);
_docRefNotifier.value = newRef;
}
}
PdfDocumentRef? _documentRef;
// Public getter for testing the actual viewer page
int? get viewerCurrentPage => widget.controller.pageNumber;
@ -88,10 +43,32 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
@override
Widget build(BuildContext context) {
final pdfViewState = ref.watch(pdfViewModelProvider);
final document = ref.watch(documentRepositoryProvider);
final useMock = pdfViewState.useMockViewer;
_updateDocRef(document);
final pdfViewModel = ref.watch(pdfViewModelProvider);
final document = pdfViewModel.document;
final useMock = pdfViewModel.useMockViewer;
// trigger rebuild when active rect changes
// Update document ref when document changes
if (document.loaded && document.pickedPdfBytes != null) {
if (_documentRef == null) {
_documentRef = PdfDocumentRefData(
document.pickedPdfBytes!,
sourceName: 'document.pdf',
);
}
} else {
_documentRef = null;
}
if (_documentRef == null && !useMock) {
String text;
try {
text = AppLocalizations.of(context).noPdfLoaded;
} catch (_) {
text = 'No PDF loaded';
}
return Center(child: Text(text));
}
if (useMock) {
return PdfMockContinuousList(
@ -104,120 +81,77 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
);
}
final overlaysEnabled = ref.watch(viewerOverlaysEnabledProvider);
return ValueListenableBuilder<PdfDocumentRef?>(
valueListenable: _docRefNotifier,
builder: (context, docRef, _) {
if (docRef == null) {
String text;
try {
text = AppLocalizations.of(context).noPdfLoaded;
} catch (_) {
text = 'No PDF loaded';
return PdfViewer(
_documentRef!,
key: const Key(
'pdf_continuous_mock_list',
), // Keep the same key for test compatibility
controller: widget.controller,
params: PdfViewerParams(
onViewerReady: (document, controller) {
// Update page count in repository
ref
.read(pdfViewModelProvider.notifier)
.setPageCount(document.pages.length);
},
onPageChanged: (page) {
if (page != null) {
// Also update the view model to keep them in sync
ref.read(pdfViewModelProvider.notifier).jumpToPage(page);
}
return Center(child: Text(text));
}
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final viewerKey =
widget.innerViewerKey ??
Key('pdf_viewer_${pdfViewModel.documentSourceName}');
return PdfViewer(
docRef,
key: viewerKey,
controller: widget.controller,
params: PdfViewerParams(
onViewerReady: (document, controller) {
// Update page count in repository
ref
.read(pdfViewModelProvider.notifier)
.setPageCount(document.pages.length);
},
onPageChanged: (page) {
if (page != null) {
// Also update the view model to keep them in sync
ref.read(pdfViewModelProvider.notifier).jumpToPage(page);
}
},
onDocumentChanged: (doc) async {
final pc = doc?.pages.length;
debugPrint(
'[PdfViewerWidget] onDocumentChanged called (pages=$pc)',
);
if (doc != null) {
// Update internal page count state
ref
.read(pdfViewModelProvider.notifier)
.setPageCount(doc.pages.length);
}
// Invoke external listener after internal handling
try {
widget.onDocumentChanged?.call(doc);
} catch (e, st) {
debugPrint(
'[PdfViewerWidget] external onDocumentChanged threw: $e\n$st',
);
}
},
viewerOverlayBuilder:
overlaysEnabled
? (context, size, handle) {
return [
// Vertical scroll thumb on the right
PdfViewerScrollThumb(
controller: widget.controller,
orientation: ScrollbarOrientation.right,
thumbSize: const Size(40, 25),
thumbBuilder:
(context, thumbSize, pageNumber, controller) =>
Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
},
viewerOverlayBuilder: (context, size, handle) {
return [
// Vertical scroll thumb on the right
PdfViewerScrollThumb(
controller: widget.controller,
orientation: ScrollbarOrientation.right,
thumbSize: const Size(40, 25),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
// Horizontal scroll thumb on the bottom
PdfViewerScrollThumb(
controller: widget.controller,
orientation: ScrollbarOrientation.bottom,
thumbSize: const Size(40, 25),
thumbBuilder:
(context, thumbSize, pageNumber, controller) =>
Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
),
),
),
),
// Horizontal scroll thumb on the bottom
PdfViewerScrollThumb(
controller: widget.controller,
orientation: ScrollbarOrientation.bottom,
thumbSize: const Size(40, 25),
thumbBuilder:
(context, thumbSize, pageNumber, controller) => Container(
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
];
}
: (context, size, handle) => const <Widget>[],
// Per-page overlays to enable page-specific drag targets and placed signatures
pageOverlaysBuilder: (context, pageRect, page) {
return [
PdfPageOverlays(
pageSize: Size(pageRect.width, pageRect.height),
pageNumber: page.pageNumber,
),
];
},
),
);
},
),
),
),
),
];
},
// Per-page overlays to enable page-specific drag targets and placed signatures
pageOverlaysBuilder: (context, pageRect, page) {
return [
PdfPageOverlays(
pageSize: Size(pageRect.width, pageRect.height),
pageNumber: page.pageNumber,
),
];
},
),
);
}
}

View File

@ -42,10 +42,9 @@ class SignatureOverlay extends ConsumerWidget {
Future<void> _showContextMenu(Offset position) async {
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final isLocked = pdfViewModel.isPlacementLocked(
page: pageNumber,
index: placedIndex,
);
final isLocked = ref
.watch(pdfViewModelProvider)
.isPlacementLocked(page: pageNumber, index: placedIndex);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
@ -93,7 +92,7 @@ class SignatureOverlay extends ConsumerWidget {
allowContentFlipping: false,
onChanged:
ref
.read(pdfViewModelProvider.notifier)
.watch(pdfViewModelProvider)
.isPlacementLocked(
page: pageNumber,
index: placedIndex,
@ -118,11 +117,9 @@ class SignatureOverlay extends ConsumerWidget {
},
// Keep default handles; you can customize later if needed
contentBuilder: (context, boxRect, flip) {
// Watch the provider state to rebuild when lock state changes
final pdfViewState = ref.watch(pdfViewModelProvider);
final isLocked = pdfViewState.lockedPlacements.contains(
'${pageNumber}_$placedIndex',
);
final isLocked = ref
.watch(pdfViewModelProvider)
.isPlacementLocked(page: pageNumber, index: placedIndex);
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(

View File

@ -1,14 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Global flag indicating whether a signature card is currently being dragged.
class IsDraggingSignatureNotifier extends Notifier<bool> {
@override
bool build() => false;
void setDragging(bool value) => state = value;
}
final isDraggingSignatureViewModelProvider =
NotifierProvider<IsDraggingSignatureNotifier, bool>(
IsDraggingSignatureNotifier.new,
);
final isDraggingSignatureViewModelProvider = StateProvider<bool>(
(ref) => false,
);

View File

@ -158,14 +158,10 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
),
),
onDragStarted: () {
ref
.read(isDraggingSignatureViewModelProvider.notifier)
.setDragging(true);
ref.read(isDraggingSignatureViewModelProvider.notifier).state = true;
},
onDragEnd: (_) {
ref
.read(isDraggingSignatureViewModelProvider.notifier)
.setDragging(false);
ref.read(isDraggingSignatureViewModelProvider.notifier).state = false;
},
feedback: Opacity(
opacity: 0.9,

View File

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdf_signature/ui/features/pdf/view_model/pdf_view_model.dart';
import 'package:go_router/go_router.dart';
@ -11,17 +11,9 @@ class WelcomeViewModel {
WelcomeViewModel(this.ref, this.router);
Future<void> openPdf({required String path, Uint8List? bytes}) async {
// Return early if no bytes provided - can't open PDF without data
if (bytes == null) {
debugPrint(
'[WelcomeViewModel] Cannot open PDF: no bytes provided for $path',
);
return;
}
// Use PdfSessionViewModel to open and navigate.
final session = ref.read(pdfSessionViewModelProvider.notifier);
await session.openPdf(path: path, bytes: bytes, router: router);
final session = ref.read(pdfSessionViewModelProvider(router));
await session.openPdf(path: path, bytes: bytes);
}
}

View File

@ -25,6 +25,9 @@ class _DropReadableFromDesktop implements DropReadable {
Future<Uint8List> readAsBytes() => inner.readAsBytes();
}
// Allow injecting Riverpod's read function from either WidgetRef or ProviderContainer
typedef Reader = T Function<T>(ProviderListenable<T> provider);
// Select first .pdf file (case-insensitive) or fall back to first entry.
Future<void> handleDroppedFiles(
Future<void> Function({String? path, Uint8List? bytes, String? fileName})

View File

@ -1,17 +1,11 @@
import 'package:flutter/foundation.dart';
import 'dart:typed_data';
// On modern Flutter Web (Wasm GC, e.g., Chromium), dart:html is not available.
// Use js_interop capability to select the web implementation that relies on
// package:web instead of dart:html.
import 'download_stub.dart'
if (dart.library.js_interop) 'download_web.dart'
as impl;
import 'download_stub.dart' if (dart.library.html) 'download_web.dart' as impl;
/// Initiates a platform-appropriate download/save operation.
///
/// On Web: triggers a browser download with the provided filename.
/// On non-Web: returns false (no-op). Use your existing IO save flow instead.
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) {
debugPrint('downloadBytes: initiating download');
return impl.downloadBytes(bytes, filename: filename);
}

View File

@ -1,9 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) async {
// Not supported on non-web. Return false so caller can fallback to file save.
debugPrint('downloadBytes: not supported on this platform');
return false;
}

View File

@ -1,28 +1,23 @@
// Implementation for Web using package:web to support Wasm GC (Chromium)
// without importing dart:html directly.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
// ignore_for_file: deprecated_member_use
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'dart:typed_data';
Future<bool> downloadBytes(Uint8List bytes, {required String filename}) async {
try {
// Use a data URL to avoid Blob/typed array interop issues under Wasm GC.
final url = 'data:application/pdf;base64,${base64Encode(bytes)}';
// Create an anchor element and trigger a click to download
final blob = html.Blob([bytes], 'application/pdf');
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor =
web.HTMLAnchorElement()
html.document.createElement('a') as html.AnchorElement
..href = url
..download = filename
..style.display = 'none';
web.document.body?.append(anchor);
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
return true;
} catch (e, st) {
debugPrint('Error: downloadBytes failed: $e\n$st');
} catch (_) {
return false;
}
}

View File

@ -1,3 +0,0 @@
// Conditional export: use IO implementation except when compiling for web (html).
export 'pdfrx_cache_init_io.dart'
if (dart.library.html) 'pdfrx_cache_init_web.dart';

View File

@ -1,26 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdfrx/pdfrx.dart';
/// Initialize Pdfrx cache directory for IO platforms (mobile/desktop). No-op on web.
Future<void> initPdfrxCache() async {
try {
if (kIsWeb) return; // Guard (should not be used on web, but extra safety)
if (Pdfrx.getCacheDirectory != null) return; // Already set
final dir = await getTemporaryDirectory();
final cacheDir = Directory('${dir.path}/pdfrx_cache');
if (!await cacheDir.exists()) {
await cacheDir.create(recursive: true);
}
Pdfrx.getCacheDirectory = () async => cacheDir.path;
debugPrint(
'[pdfrx_cache_init_io] Pdfrx cache directory set to ${cacheDir.path}',
);
} catch (e, st) {
debugPrint(
'[pdfrx_cache_init_io] Failed to initialize Pdfrx cache directory: $e',
);
debugPrint(st.toString());
}
}

View File

@ -1,17 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:pdfrx/pdfrx.dart';
/// Web stub: pdfrx can operate without a filesystem cache; leave getCacheDirectory null.
Future<void> initPdfrxCache() async {
// Intentionally no-op. If desired, could set an in-memory indicator.
debugPrint(
'[pdfrx_cache_init_web] Skipping Pdfrx cache directory setup on web',
);
// Ensure any previous (hot-reload) IO assignment isn't kept when switching target.
if (kIsWeb && Pdfrx.getCacheDirectory != null) {
// Leave as-is; clearing could break existing references. Merely log.
debugPrint(
'[pdfrx_cache_init_web] Existing getCacheDirectory left unchanged',
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -2,7 +2,7 @@ name: pdf_signature
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@ -21,14 +21,6 @@ version: 1.1.0+1
environment:
sdk: ^3.7.0
platforms:
android:
ios:
linux:
macos:
web:
windows:
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
@ -42,35 +34,33 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_riverpod: ^3.0.3
flutter_riverpod: ^2.6.1
shared_preferences: ^2.5.3
flutter_dotenv: ^6.0.0
file_selector: ^1.0.3
path_provider: ^2.1.5
pdfrx: ^2.2.16
pdfrx: ^2.1.9
pdf: ^3.10.8
# printing: ^5.14.2 # extension of pdf pkg
hand_signature: ^3.1.0+2
image: ^4.2.0
printing: ^5.14.2
result_dart: ^2.1.1
go_router: ^17.0.0
go_router: ^16.2.0
flutter_localizations:
sdk: flutter
intl: any
flutter_localized_locales: ^2.0.5
desktop_drop: ^0.7.0
desktop_drop: ^0.5.0
multi_split_view: ^3.6.1
freezed_annotation: ^3.1.0
json_annotation: ^4.9.0
share_plus: ^12.0.0
share_plus: ^11.1.0
logging: ^1.3.0
riverpod_annotation: ^4.0.2
riverpod_annotation: ^2.6.1
colorfilter_generator: ^0.0.8
flutter_box_transform: ^0.4.7
file_picker: ^10.3.3
responsive_framework: ^1.5.1
# disable_web_context_menu: ^1.1.0
# ml_linalg: ^13.12.6
web: ^1.1.1
dev_dependencies:
flutter_test:
@ -78,12 +68,12 @@ dev_dependencies:
integration_test:
sdk: flutter
build_runner: ^2.4.12
build: ^4.0.3
bdd_widget_test: ^2.1.3
build: ^3.0.2
bdd_widget_test: ^2.0.1
mocktail: ^1.0.4
freezed: ^3.0.0
# custom_lint: ^0.7.6
# riverpod_lint: ^2.6.5
custom_lint: ^0.7.6
riverpod_lint: ^2.6.5
go_router_builder: ^4.0.1
# The "flutter_lints" package below contains a set of recommended lints to
@ -141,12 +131,12 @@ flutter:
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
image_path: "assets/icon/pdf_signature-icon.png"
android: true
android: "launcher_icon"
ios: true
image_path: "assets/icon/pdf_signature-icon.png"
min_sdk_android: 21 # android min sdk min:16, default 21
remove_alpha_ios: true
web:
generate: true
image_path: "assets/icon/pdf_signature-icon.png"

View File

@ -34,37 +34,31 @@ class FakeExportService extends ExportService {
}
}
class _TestDocumentStateNotifier extends DocumentStateNotifier {
@override
Document build() {
// Initialize with sample document for tests, bypassing the parent build
return Document.initial().copyWith(
loaded: true,
pageCount: 5,
pickedPdfBytes: null,
placementsByPage: <int, List<SignaturePlacement>>{},
);
}
}
Future<ProviderContainer> pumpApp(
WidgetTester tester, {
Map<String, Object> initialPrefs = const {},
}) async {
SharedPreferences.setMockInitialValues(initialPrefs);
final prefs = await SharedPreferences.getInstance();
final fakeExport = FakeExportService();
final container = ProviderContainer(
overrides: [
preferencesRepositoryProvider.overrideWith(() {
final notifier = PreferencesStateNotifier();
notifier.initWithPrefs(prefs);
return notifier;
}),
documentRepositoryProvider.overrideWith(
() => _TestDocumentStateNotifier(),
preferencesRepositoryProvider.overrideWith(
(ref) => PreferencesStateNotifier(prefs),
),
documentRepositoryProvider.overrideWith(
(ref) => DocumentStateNotifier()..openSample(),
),
pdfViewModelProvider.overrideWith(
(ref) => PdfViewModel(ref, useMockViewer: true),
),
pdfExportViewModelProvider.overrideWith(
(ref) => PdfExportViewModel(
ref,
exporter: fakeExport,
savePathPicker: () async => 'out.pdf',
),
),
pdfViewModelProvider.overrideWith(() => PdfViewModel()),
pdfExportViewModelProvider.overrideWith(() => PdfExportViewModel()),
],
);
await tester.pumpWidget(

View File

@ -9,6 +9,13 @@ Feature: document browser
And the user can move to the next or previous page
And the page label shows "Page {1} of {5}"
Scenario: Jump to a specific page by typing Enter
Given the document is open
When the user types {3} into the Go to input and presses Enter
Then page {3} is displayed
And the page label shows "Page {3} of {5}"
And the left pages overview highlights page {3}
Scenario: Jump to a specific page using the Apply button
Given the document is open
When the user types {4} into the Go to input
@ -22,6 +29,15 @@ Feature: document browser
Then page {2} is displayed
And the page label shows "Page {2} of {5}"
Scenario: Continuous mode scrolls target page into view on jump
Given the document is open
And the Page view mode is set to Continuous
When the user jumps to page {5}
Then page {5} becomes visible in the scroll area
And the left pages overview highlights page {5}
Scenario: Go to clamps out-of-range inputs to valid bounds
Given the document is open
When the user enters {0} into the Go to input and applies it
@ -34,14 +50,3 @@ Feature: document browser
Scenario: Go to is disabled when no document is loaded
Given no document is open
Then the Go to input cannot be used
Scenario: Open a different document will reset signature placements but keep signature cards
Given the document is open
When the user opens a different document with {3} pages
And {1} signature placements exist on page {1}
And {1} signature placements exist on page {2}
And {2} signature cards exist
Then the first page of the new document is displayed
And the page label shows "Page {1} of {3}"
And number of signature placements is {0}
And {2} signature cards exist

View File

@ -36,9 +36,6 @@ class TestWorld {
// Generic flags/values
static int? selectedPage;
static int? pendingGoTo; // for simulating typed Go To value across steps
static int?
nextDocPageCount; // for BDD: desired page count for the next opened document
static Map<int, int>? prevPlacementsCount; // snapshot before an action
// Preferences & settings
static Map<String, String> prefs = {};
@ -64,8 +61,6 @@ class TestWorld {
nothingToSaveAttempt = false;
selectedPage = null;
pendingGoTo = null;
nextDocPageCount = null;
prevPlacementsCount = null;
// Preferences
prefs = {};
@ -99,9 +94,8 @@ class MockSignatureState {
}) : strokes = strokes ?? [];
}
class MockSignatureNotifier extends Notifier<MockSignatureState> {
@override
MockSignatureState build() => MockSignatureState();
class MockSignatureNotifier extends StateNotifier<MockSignatureState> {
MockSignatureNotifier() : super(MockSignatureState());
void setStrokes(List<List<Offset>> strokes) {
state = MockSignatureState(
@ -175,32 +169,11 @@ class MockSignatureNotifier extends Notifier<MockSignatureState> {
}
final signatureProvider =
NotifierProvider<MockSignatureNotifier, MockSignatureState>(
() => MockSignatureNotifier(),
StateNotifierProvider<MockSignatureNotifier, MockSignatureState>(
(ref) => MockSignatureNotifier(),
);
// Mock other providers using NotifierProvider instead of StateProvider
class _CurrentRectNotifier extends Notifier<Rect?> {
@override
Rect? build() => null;
}
class _EditingEnabledNotifier extends Notifier<bool> {
@override
bool build() => false;
}
class _AspectLockedNotifier extends Notifier<bool> {
@override
bool build() => false;
}
final currentRectProvider = NotifierProvider<_CurrentRectNotifier, Rect?>(
() => _CurrentRectNotifier(),
);
final editingEnabledProvider = NotifierProvider<_EditingEnabledNotifier, bool>(
() => _EditingEnabledNotifier(),
);
final aspectLockedProvider = NotifierProvider<_AspectLockedNotifier, bool>(
() => _AspectLockedNotifier(),
);
// Mock other providers
final currentRectProvider = StateProvider<Rect?>((ref) => null);
final editingEnabledProvider = StateProvider<bool>((ref) => false);
final aspectLockedProvider = StateProvider<bool>((ref) => false);

View File

@ -12,9 +12,7 @@ Future<void> aDocumentIsOpenAndContainsAtLeastOneSignaturePlacement(
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
container
.read(documentRepositoryProvider.notifier)
.addPlacement(

View File

@ -13,9 +13,7 @@ aDocumentIsOpenAndContainsMultiplePlacedSignaturePlacementsAcrossPages(
) async {
final container = TestWorld.container ?? ProviderContainer();
TestWorld.container = container;
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
container
.read(documentRepositoryProvider.notifier)
.addPlacement(

View File

@ -11,6 +11,6 @@ Future<void> aDocumentIsOpenWithNoSignaturePlacementsPlaced(
TestWorld.container = container;
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
.openPicked(pageCount: 5);
// No placements added
}

View File

@ -11,7 +11,7 @@ Future<void> aDocumentPageIsSelectedForSigning(WidgetTester tester) async {
// Ensure a document is open
final repo = container.read(documentRepositoryProvider.notifier);
if (!container.read(documentRepositoryProvider).loaded) {
repo.openDocument(pageCount: 5);
repo.openPicked(pageCount: 5);
}
// Ensure current page is 1 for consistent subsequent steps
try {

View File

@ -18,9 +18,7 @@ Future<void> aMultipageDocumentIsOpen(WidgetTester tester) async {
container.read(signatureCardRepositoryProvider.notifier).state = [
SignatureCard.initial(),
];
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
container.read(documentRepositoryProvider.notifier).openPicked(pageCount: 5);
// Reset page state providers
try {
container.read(pdfViewModelProvider.notifier).jumpToPage(1);

View File

@ -11,5 +11,5 @@ Future<void> aSampleMultipageDocument5PagesIsAvailable(
TestWorld.container = container;
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
.openPicked(pageCount: 5);
}

View File

@ -17,7 +17,7 @@ Future<void> aSignatureAssetIsPlacedOnThePage(WidgetTester tester) async {
if (!container.read(documentRepositoryProvider).loaded) {
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
.openPicked(pageCount: 5);
}
// Get or create an asset

View File

@ -9,9 +9,8 @@ Future<void> aSignaturePlacementAppearsOnThePageBasedOnTheSignatureCard(
) async {
final container = TestWorld.container!;
final pdf = container.read(documentRepositoryProvider);
final pdfView = container.read(pdfViewModelProvider);
final currentPage = pdfView.currentPage;
final placements = pdf.placementsByPage[currentPage] ?? const [];
final page = container.read(pdfViewModelProvider);
final placements = pdf.placementsByPage[page] ?? const [];
expect(
placements.isNotEmpty,
true,

View File

@ -17,7 +17,7 @@ Future<void> aSignaturePlacementIsPlacedOnPage(
if (!container.read(documentRepositoryProvider).loaded) {
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
.openPicked(pageCount: 5);
}
final page = param1.toInt();
container

View File

@ -16,7 +16,7 @@ Future<void> aSignaturePlacementIsPlacedWithAPositionAndSizeRelativeToThePage(
if (!container.read(documentRepositoryProvider).loaded) {
container
.read(documentRepositoryProvider.notifier)
.openDocument(pageCount: 5);
.openPicked(pageCount: 5);
}
final currentPage = container.read(pdfViewModelProvider).currentPage;
container

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