Skip to content

OpenScan v3: Revamp - #118

Open
veejayts wants to merge 169 commits into
masterfrom
dev_v3
Open

OpenScan v3: Revamp#118
veejayts wants to merge 169 commits into
masterfrom
dev_v3

Conversation

@veejayts

Copy link
Copy Markdown
Member

No description provided.

veejayts and others added 30 commits August 25, 2026 23:15
Re-scan appended a second page and left the bad one behind, so redoing a
page meant deleting the old one by hand every time. It now replaces the
page in place — same position, same database row, old files removed —
and appends only the extra pages a batch session captured beyond the
first. Pages that keep no original of their own clear the column rather
than leaving it pointing at the deleted file.

Drag-to-reorder never started: each tile's own long-press (enter
selection mode) won the gesture arena over ReorderableWrap's long-press
drag. Selection moves to the overflow menu, the gesture is named in the
subtitle, and the "+" tile leaves the grid.

The camera's top row was a Row stretched to full height inside an
expanded Stack, which centred its buttons vertically — hence controls in
the middle of the screen. The screen is now a column: controls at top,
a bounded rounded viewfinder that owns only the feed, the quad, the
status line and a compact vertical zoom rail, and one bottom row of
gallery | shutter | Done. The grid toggle joins the expanded controls,
chrome buttons grow to 48px, and tap-to-focus now measures against the
preview texture rather than a letterboxing box.

Storage: nothing was ever normalized, so gallery imports were stored
byte-for-byte and auto-crop re-encoded at quality 100. Pages are now
capped at 2400px/q85 and kept originals at 3200px/q80 on every path
into storage, filters encode at the same quality as the page, and
keeping originals is off by default. Measured on device: 565KB -> 122KB
per page.

Export sizes were a guessed multiplier against on-disk bytes. One
representative page is now actually encoded at every quality offered,
and the ratio applied to the selection; a new row quotes the total.
Verified on device: ~306KB estimated vs 312KB written for a 3-page PDF,
~2.2MB vs 2.17MB for PNG. For those numbers to be honest, PDF export
had to stop using its own 60/80/100 scale, and sharing a PDF had to
stop skipping compression entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uqWdfipRGVL3jdRRy9EHR
When detection fell back to manual corners, a dismissible banner
covered the top of the image. The manual handles are already visible
and draggable, so the message added nothing but occlusion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uqWdfipRGVL3jdRRy9EHR
Every camera control now sits on one row above the viewfinder — torch, a
magic-wand auto-capture toggle, grid, lens switch, and undo once there is
something to undo — so nothing hides behind a caret. The EV stepper is
gone with the row it lived in; tap-to-focus still sets an exposure point.
Below the viewfinder, gallery and Done are 60px peers of the shutter,
evenly spaced on its centre line.

A capture with a page on screen is acknowledged by that page filling with
light from its bottom edge upward, clipped to the detected quad, instead
of the whole screen blinking white — the blink stays as the fallback for
a shot taken with nothing detected.

Gallery imports made from inside the camera now skip the crop screen: an
imported picture was never framed through this viewfinder, so it goes
into the document as-is, exactly like a library-screen import.

Backing out of the camera without capturing anything no longer lands on
an empty document. ViewScreen starts the session itself and closes again
if it ends with no pages, so a cancelled scan returns to the library.

Normal scan and Quick scan are gone from the home sheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
Four separate things moved the overlay for a document that was sitting
still.

Every frame pools candidates from three thresholds and five RDP epsilons,
and the best-scoring one won outright — but several of those are the same
shape found repeatedly, separated by a hair of score, so which one won was
a coin flip re-tossed every frame, and each flip moved the overlay by
however far apart the two versions were. Candidates are now clustered,
averaged corner-wise, and scored with a bonus for how many candidates back
the cluster: the shape several thresholds independently agree on wins, and
it is also the one still there next frame.

sortCorners picked each corner independently (min x+y for top-left, min
y-x for top-right, ...), which on a tilted quad can hand the same point to
two slots and drop another — a bow-tie that flips as the document rotates.
It now orders the points around their centroid and picks the rotation of
that cycle whose labels fit best, so the corners are always a permutation
of the input and the right corners really are right of the left ones.

The smoother treated anything within 12% of the frame diagonal as
continued tracking, letting visibly different shapes drag the position
filters along; that is now 6%, and a confirmed jump eases the corners over
from where they are drawn instead of snapping.

Detection publishes about ten times a second, and the overlay redrew only
on those results, so even well-filtered corners moved in steps. Each new
detection is now the target of a short corner-wise tween that runs at
refresh rate.

The worker isolate also remembered its last quad forever, biasing frames
toward a position nothing was at any more; it forgets after five misses.

Drops the counter smoke test, which tested a UI this app never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
A manual shot went through the crop screen while an auto-capture didn't,
so framing a page carefully by hand was punished with an extra checkpoint.
Both now crop to the boundary that was on the live preview when the
shutter fired, and a capture with no boundary is stored whole. The crop
screen is reachable only from a page's own Crop button, which is where a
deliberate crop belongs.

Keeping originals now defaults to on. A capture is cropped in place, so
without one the full photo is gone the moment the page is stored and a
re-crop can only work off the already-cropped page — worth the storage.

Removes what nothing reaches any more: createImage's system-camera branch
and the whole quickScan parameter, FileOperations.openCamera (its only
caller), ViewScreen's quickScan plumbing, LiveCapture.autoMode and
canAutoCrop now that both shutter modes crop alike, and the normal_scan /
quick_scan strings from all four catalogs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
Storing a page did the same photo three times over: warp the capture in
place at full resolution and quality 100, decode that again to downscale
it into a page, decode the untouched capture a third time to downscale it
into a kept original. Three decodes, three encodes, three freshly spawned
isolates, plus a full-file copy to stash the original — for every page,
one after another, before anything appeared on screen.

storeCaptureIsolateEntry does all of it in one pass over one decode. The
warp samples the source once per output pixel, so asking it for a
page-sized result directly is cheaper than warping at capture resolution
and then shrinking; where that discards more than half the detail the
source is box-filtered down first, so the result is averaged rather than
point-sampled. A 12MP capture with originals on: 3169ms -> 1751ms, same
output sizes to the kilobyte.

The pages also stop waiting on any of that to be visible. Captures that
have not been written yet are carried in the state and drawn as
placeholders — the photo itself, dimmed, with a progress line and its page
number — so a finished scan fills the grid the moment the camera closes,
and each placeholder is replaced by the real page as it lands. They sit
outside the reorderable set, having no database row for a drag to rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
Rotate bumped an angle that only the image's Transform read. The polygon
was painted from body coordinates with no transform at all, so it stayed
put over a page that had turned under it — and the same handler rewrote
canvasSize, which crop() divides by to map canvas points back onto the
photo, so a rotate skewed the crop as well. The angle never reached the
file either: the warp ran on the unrotated image, so a sideways page came
back sideways however many times it was turned.

The polygon stays in the page's own unrotated coordinates — every
constraint, slope and crossover check untouched — and is transformed only
at the edges: toDisplay on the way to the painter and the magnifier,
fromDisplay on the way back from a touch, and quarterTurns travelling with
the crop so the warped result is actually turned.

The fit factor was the aspect ratio, which only shrinks a portrait page;
a landscape one was being enlarged past its box on every odd turn. It is
now the smaller of the two ratios, which fits either way round. One
controller drives the turn, since the image, the overlay and the magnifier
sit in different subtrees and have to move on the same frames. Turns count
up rather than wrapping, so the fourth tap finishes the circle instead of
unwinding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
integration_test ships with the Flutter SDK, so driving the real app on a
connected phone costs no new dependency: 24 tests across the library, a
document and its pages, the crop screen, settings, and the camera's
chrome. Documents are seeded through the app's own data layer, so what a
test reads is what a scan would have written, and each page carries a
colour band so it stays identifiable through a reorder or a delete.

Two bugs surfaced on the way:

saveCapture wasn't awaiting its database writes. The page count and the
library grid's cover both land in those calls, so a caller that refreshed
the library the moment saveCapture returned raced them — a one-page
document listed as "0 pages", which is exactly what the first run showed.

A permission request that fails rather than being denied — the platform
refuses a second one while the first is still running — took the library
screen down with it. Asking is worth a try; not getting an answer is not
worth a crash.

The README covers what these tests deliberately leave alone: the gallery
picker and the share sheet belong to other processes, what a capture
produces depends on what the phone is pointed at (test/cv/ covers that
with synthetic frames), and a long-press drag across a reorderable wrap is
a flake generator. tools/grant_permissions.sh handles the runtime grants a
reinstall clears, since the dialog itself cannot be tapped from a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
Every scanned document had a table of its own, created on scan and dropped
on delete. That made each schema change a per-table migration: reading a
page ran a PRAGMA and possibly an ALTER on every call, because there was
no one place to migrate. Table names had to be built by string surgery,
since identifiers cannot be bound as parameters, and which table a method
was working on lived in a mutable field that every call site had to set
immediately before use with no await in between.

Two tables now, joined by a foreign key with ON DELETE CASCADE, and an
index on (document_id, idx). Page count and cover are derived in the query
instead of stored: createImage used to write the count as the new row's
id, which climbs past deleted rows, so deleting a middle page and adding
one listed a three-page document as four. The database is also opened once
now rather than reopened on every single call.

The upgrade to version 2 folds the old tables in — copy each master row,
read whatever columns that document's table actually has (ones scanned
before originals and filters existed have neither), then drop it. A
document that fails is logged and skipped: losing one document's page
records beats throwing away the library. It runs inside sqflite's upgrade
transaction, so a crash halfway rolls back rather than leaving half a
library converted. Verified on a phone by planting a real v1 database and
launching the app, as well as by the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2tdGtP7qWLFyUgzzWaJfi
On gesture navigation Android reserves a strip down each side of the
display for the back swipe, and a touch that starts inside it never
reaches the crop screen's GestureDetector. The page was inset by 13, well
inside that strip on a full-width scan, so dragging a corner popped the
route instead of moving the point.

The default inset is now 20, which puts a corner's centre at the edge of
the strip rather than inside it. Going further costs page width on every
scan and only helps on gesture navigation, so the rest is a setting:
"Avoid back-gesture strip" widens the left and right insets to
systemGestureInsets plus the handle's own radius, so the whole dot clears
the strip and not just its centre. Off by default.

Flutter exposes systemGestureInsets but no wrapper for Android's
exclusion-rect API, and that API would not have covered the canvas
anyway — the system caps exclusions at 200dp of vertical extent per edge
and silently drops the excess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
saveToDevice built its own copy of the export directory logic and fell
back, when creating Documents/OpenScan failed, to pickDirectory — which
ignored both of its arguments and returned /storage/emulated/0/. The
manifest caps WRITE_EXTERNAL_STORAGE at API 28 and never asks for
MANAGE_EXTERNAL_STORAGE, so on anything recent that fallback cannot write
where the thing it was falling back from could not. exportDirectory
already had this right — same folder, created recursively, falling back to
app storage, which always works — so saveToDevice now calls it and
pickDirectory is gone, along with the "Pick custom directory" TODO parked
on a function that was not a picker. The absence of one is noted on
exportDirectory instead, where the path is actually chosen.

"remove await and display toast" was stale. Both callers need the returned
path: the export sheet throws on null and reports the file's size, the
library counts successes. Both already show progress and a result — the
exporting dialog and OSSnack.success, the sheet's exporting and success
stages. The await costs nothing to keep, since the work is inside compute
and off the UI isolate either way.

Verified on a phone with a throwaway integration test driving saveToDevice
against a seeded document: it wrote a 35KB PDF to
/storage/emulated/0/Documents/OpenScan. The library and document flows
still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
The scale ran low/medium/high/extreme at JPEG 55/70/85/100, and the top of
it could not do anything. Pages are stored at 2400px and quality 85
(kStoredPageMaxEdge, kStoredPageQuality), so "Extreme" re-encoded an
85-quality image at 100: a strictly larger file carrying no more detail.
The bottom could not do much either — quality alone, at full 2400px, has a
floor well above what a small file needs.

The scale is now ultraLow/low/medium/high, and each preset carries a pixel
cap as well as a quality: 900/30, 1200/45, 1800/65, and 2400/85 at the top,
which is exactly what the page is already stored at. Below about quality 45
the artefacts cost more legibility than the pixels are worth, so the small
presets shed resolution instead, which is also where the bytes are. On a
three-page document of realistic text the four presets measured 277 KB,
504 KB, 983 KB and 2576 KB against 2573 KB of stored pages — a 9x spread,
where the old scale's was closer to 2x. The default moves to Medium, which
is now a real middle rather than a step below the maximum.

maxEdge threads through the same path quality already took: saveToDevice,
saveToAppDirectory, exportImages and the two isolate entries. Its defaults
are the stored constants, so the library's bulk export — which picks no
preset — neither loses detail nor inflates the file re-encoding past it.
fitToMaxEdge is shared with normalizeImageIsolateEntry, which had the same
resize inline, and never enlarges: a page under the cap is left alone.

The pixel cap reaches PNG even though the JPEG quality does not, so PNG
size hints are real numbers now rather than an em dash. That made the
measurement API per-preset rather than per-quality: measureEncodedSizes
takes {quality, maxEdge} pairs, keyed through encodedSizeKey/pngSizeKey,
and reuses one scaled copy across presets that share a size. The chips
grew a FittedBox, since "Ultra low" and "~2.6 MB" both nearly fill a
quarter-screen chip.

Verified on a phone: the four presets produce the sizes above through the
real saveToDevice, and the sheet lays out all four chips with measured
hints. Unit tests cover the cap, the never-enlarge case, and that each
preset outweighs the one below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
Three things about the export sheet.

Open did nothing, silently. OpenFilex returned permissionDenied asking for
MANAGE_EXTERNAL_STORAGE, and the result was discarded, so a refusal looked
exactly like a dead button. The cause is where exports land, not the
plugin: open_filex on API 30+ rejects any path outside the app's own
directories unless it matches a hardcoded list of media folders, and
/Documents/ is not on it while /Download/ is. Probed on a device — the
same file is permissionDenied under Documents and opens under Download —
so exports move to Download/OpenScan, which is the more findable folder
anyway. There is no newer open_filex (4.7.0 is current, 17 months old);
open_filex_plus is an unverified fork with a hundredth of the downloads
and no claim to fix this; url_launcher is official but documents file:
support for desktop only. The result is now checked, and a refusal says so.

Documents are named OpenScan-2026-08-26-1787765434205 rather than
'OpenScan 2026-08-26 22:29:11.375905'. The old shape was the DateTime's
own toString, spaces and colons included, which FAT and exFAT reject — and
it was parsed back out of the folder name with a DateTime.parse that threw
on anything else. Naming lives in one place now, reads both schemes, and
returns null rather than throwing for a name that was never a date. Folders
scanned before this keep their names on disk: renaming one means rewriting
every page path that points into it, for a cosmetic gain, so the old shape
stays readable indefinitely. Exports follow the same rule the user asked
for — a document they named is filed under that name, one they never named
gets a freshly generated name, unique by construction, so re-exporting an
unnamed document no longer overwrites the last one.

The measured size estimate is gone. It encoded a representative page once
per preset, which on a real 2400px page measured 5.0s, or 10.4s once PNG
joined it — against 20s for the ten-page export it was describing. The
chips now show the preset's resolution, which is what the preset actually
controls and is known without touching a pixel. measureEncodedSizes and
its keys go with it. The size shown after an export is unaffected: that
one is a file length, not an encode.

Verified on a phone: an unnamed document exports as
OpenScan-2026-08-26-1787765434754.pdf into Download/OpenScan and opens;
a document named Rent Receipt exports as Rent_Receipt.pdf; a legacy folder
name still yields its created time. Unit tests cover both naming schemes
and the export-name rule; the library, document and settings flows pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
Exporting an unnamed document minted a second timestamp at the moment of
export, so a document showing as OpenScan-2026-08-26-1787765434205 in the
library landed on disk as OpenScan-2026-08-26-1787765434754.pdf — close
enough to look right and different enough to be useless for matching a
file back to the document it came from.

The export is now named after the document whatever the document is
called, and the only thing that happens to that name is having the
characters a filesystem objects to stripped out. A legacy folder name
loses its spaces and colon and still identifies its document; the fallback
to a generated name is left for the one case that strips to nothing, a
name made entirely of punctuation.

Exporting the same document twice now produces the same filename and
overwrites, which is what naming a file after the thing it contains means.

Verified on a phone: an unnamed document exports as
OpenScan-2026-08-26-1787765820144.pdf, matching its title exactly, and one
named Rent Receipt as Rent_Receipt.pdf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
Sharing left the export sheet sitting on its success panel once the share
sheet was dismissed. That panel is about a file the user keeps — where it
landed, what it weighs, a button to open it — and none of that describes a
copy handed to another app. The sheet now closes when the share returns,
and the success stage is reached only by Save.

Image shares also wrote into Download/OpenScan, so every share the user
ever made left a file behind in a folder they browse. A share is a handoff,
not a save: those copies now go to app storage, which is where
saveToAppDirectory was already staging shared PDFs, so both formats leave
the same way. share_plus serves them through its own FileProvider either
way, so nothing about the handoff changes.

Verified on a phone that the two directories are now distinct and that
neither the share path nor app storage touches Download. The closing of
the sheet is not covered by a test: it happens after a system share sheet,
which a test cannot dismiss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
Three ways this app grew storage that nobody could reclaim.

Shared PDFs were written to the app documents directory and left there.
That directory is invisible to every file manager and untouched by "Clear
cache"; the only way to empty it is Clear all data, which also deletes the
user's library. Every share anyone had ever made was still on their phone.

Page copies re-encoded on the way into a PDF were written to the cache and
never deleted — a 40-page export at High leaves tens of megabytes behind,
every time. A run that failed part way was worse: it fell back to the
original pages and abandoned however many copies it had already written.

Both now go to a staging directory under the cache, which the user can
empty from Android's storage settings and the system reclaims on its own.
Page copies are deleted in a finally, so a PDF that throws half way still
cleans up after itself, and _compressedForPdf reports what it staged
rather than letting the caller infer it — on the fallback path the images
it returns are the user's own pages, and deleting those would destroy the
document. A share clears the previous share on the way in, so a handoff
interrupted by the app being killed does not leave its file for good.
saveToAppDirectory is now saveForSharing, since where it writes was the
only thing its name described.

Upgrading does not fix the copies already stranded, so the launch purge
also removes loose *.pdf files from the app documents directory, which is
exactly what the old share path put there. It is deliberately that narrow:
the database sits in the same directory, and PDFs nested below it are
somebody else's.

Verified on a phone: a save stages zero bytes; a share stages exactly one
file, in the cache, and the next share clears it; app documents storage
does not grow across any of it; a planted orphan PDF is removed on launch
while OpenScan.db and a nested PDF survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVcUJwv1ZSdWR41GhAAyph
Importing from the gallery could raise the page count without adding
anything the user could see: the new pages were blank cards in the grid
and holes in the exported PDF.

The capture pipeline copies a capture it cannot decode through untouched
rather than dropping it, which is right — an oversized page, or one in a
format only the platform's own decoder knows, like HEIC, is worth far
more than no page at all. But nothing checked those bytes afterwards, so
a truncated or corrupt gallery pick took exactly the same path: file
written, database row inserted, page counted, nothing drawable anywhere.

The fallback stays. The isolate now reports whether it actually decoded
what it wrote, and bytes that only got copied have to clear the
platform's image codec — the same decoder behind every Image.file in the
grid, the preview and the PDF — before they count as a page. What it
refuses is exactly what the user would never see, so the files are
deleted and no row is written. saveCapture returns null there, and
createImage reports how many picks it had to skip so the document screen
can say so rather than leaving the user staring at a document that did
not grow.

Re-scan is guarded the same way, before anything is deleted: a
replacement that cannot be drawn is not a replacement, and the old page
is the only copy left once it goes.

openGallery no longer swallows the picker's exceptions. An empty list
looks exactly like a picker the user backed out of, which left the
live-scan screen's "Couldn't open the gallery." unreachable.

Verified on a phone, and covered by an integration test that runs there
because the check is the platform decoder: a JPEG header followed by
garbage leaves no file and no row, and a readable capture still stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KidhNErG8hekzQ3f6UUdPs
Decoding was the most expensive step in storing a page, and
package:image does it in pure Dart: a 12MP capture cost ~2.7s to
decode and another ~1.3s to resize, against ~350ms for the platform
decoder doing both at once. The engine downsamples during the decode,
so the full-resolution bitmap never has to exist, which is easier on
memory as well as faster.

dart:ui's codecs are reachable only from the root isolate, so the
decode happens on the UI isolate — where only the await lands, the
work itself being on the engine's worker threads — and the isolate
keeps the JPEG encode, which is pure Dart and belongs off the main
thread. When the platform decoder cannot read the bytes at all, the
old pure-Dart pipeline still runs, so nothing that used to import
stops importing.

With a boundary, the decode is scaled so the warp's own output lands
at the page cap rather than the whole frame, so cropping no longer
costs resolution, and the engine filters where the warp used to point
sample.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KL7qaUXfNG2mqhLCuGnqRV
The merged release manifest asked for RECORD_AUDIO, READ_MEDIA_AUDIO,
READ_MEDIA_VIDEO and READ_MEDIA_IMAGES. None of them come from this
app: RECORD_AUDIO is declared by camera_android, the three media
permissions by open_filex.

None of them are needed either. The camera is opened with
enableAudio: false, open_filex only ever opens an export this app has
just written through its own FileProvider, and image_picker declares
no permissions at all because it goes through the system photo
picker.

This matters more here than it would elsewhere: OpenScan was delisted
from Play for shipping exactly this — a permission a dependency
declared and the app never used. A document scanner asking for the
microphone and for every audio and video file on the device is the
same finding waiting to happen.

tools:node="remove" strips them at merge time. Verified against the
merged release manifest, which now asks only for CAMERA, the two
maxSdkVersion-capped storage permissions, and ACCESS_NETWORK_STATE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KL7qaUXfNG2mqhLCuGnqRV
There was no analysis_options.yaml, so a clean `flutter analyze` only
meant the code compiled. This adds the subset of lints that catch
bugs rather than style — the rules that would flag deliberate choices
here, like relative imports inside lib/core, are left out rather than
suppressed case by case.

What it found:

- Four database writes in DirectoryCubit were never awaited. sqflite
  serializes them so the ordering held, but a failure surfaced as an
  unhandled async error rather than something the caller could see.
- deleteTemporaryImages recreated its directory without awaiting, so
  the directory could still be missing when it returned.
- Sorting the library popped the sheet under a State.mounted check —
  the wrong object's check — and then called setState with no check
  at all, which throws if the screen went away during the await.

The remaining reports were deliberate fire-and-forget: a blocking
progress dialog that must not be awaited, and the shutter click.
Those are now wrapped in unawaited() so the intent is on the page.

Storage permission removal is in here too: the manifest caps
READ/WRITE_EXTERNAL_STORAGE at API 32/28, so on anything newer the
request was answered "denied" without ever showing a dialog, and the
return value was discarded regardless.

One dropped Future is left: deleteSelectedImages is synchronous and
cannot await its deleteDirectory without a signature change its
callers would have to follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KL7qaUXfNG2mqhLCuGnqRV
pubspec has said 3.0.0 for a while, but the changelog stopped at
2.2.0 and skipped the entire rewrite. Written from the 156 commits
this branch carries over master, including the partial state of the
Greek, Hungarian and Polish translations, which cover only a small
part of the interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KL7qaUXfNG2mqhLCuGnqRV
The app registered four locales but barely used them: app_en.arb held 36
keys, only six AppLocalizations lookups existed in the entire UI, and
roughly 140 user-facing strings were hardcoded English. A Greek,
Hungarian or Polish user got an English app with four translated words
in it.

app_en.arb now carries 173 keys covering every string the user can read,
and el/hu/pl/ta/hi are complete against it — Tamil and Hindi are new.
gen-l10n reports no untranslated messages for any locale.

Some of this could not be a straight string swap:

- Counts go through ICU plurals rather than `n == 1 ? a : b`, which only
  ever described English. Polish gets its one/few/many forms, so 3 and 9
  pages read correctly rather than both taking the 2-4 ending.
- Dates come from DateFormat.MMMd rather than a hardcoded month table,
  so they follow the locale instead of always reading as English.
- LibrarySort.label moved out of AppSettings: a stored preference has no
  business reaching for a BuildContext, so the sheet that draws the
  label now owns it.
- The onboarding slides were a const list holding their own text, which
  a translated string cannot be. slideList keeps the order and the
  artwork; the words arrive with the locale.
- OSDialog.cancelLabel defaulted to the const 'Cancel'. It is now
  nullable and resolved against the context.
- Quick-action shortcut titles are registered in didChangeDependencies
  so they follow a locale change. Their `type` stays an English id: it
  is what comes back through the callback and is matched on.

Left in English deliberately: the OpenScan wordmark, the A4/Letter/Legal
paper names, the language endonyms in the picker, and Filter.name and
the shortcut types, which are stable ids stored in the database and
matched against, not display text.

Adds test/l10n/localizations_test.dart, which loads every locale and
checks the plurals actually take different forms and the placeholders
actually substitute — the failures gen-l10n cannot see.

The el, hu and pl translations were not written by native speakers and
are worth a review pass before release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KL7qaUXfNG2mqhLCuGnqRV
The v3.0.0 entry was written while the interface was still mostly
hardcoded English, and listed that as a known limitation. It no longer
is, and two more languages ship with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KL7qaUXfNG2mqhLCuGnqRV
The three slides were marketing copy — a claim about edge detection, a
privacy pitch, and a permission ask — and taught nothing. None of them
showed a screen, so nothing in them told a new user where the shutter is,
that shots collect into one document, or how a document becomes a PDF.

Six slides now, one per step of making a document: auto-capture,
multi-page, edge adjustment and filters, page reordering, export, and
privacy. Each carries a miniature of the screen it describes, animating
the gesture being taught — the quad locking green, the Done counter
climbing, a finger walking a crop handle back onto the page, a page card
lifting to reorder, the quality chips cycling.

The miniatures are drawn from the theme's own tokens rather than shipped
as screenshots: a screenshot goes stale the first time a screen changes,
costs a PNG per locale, and cannot follow the user's accent.

Fixes found on the way:

- The back button, when the tutorial is opened from Settings, sat in the
  top-right corner. Both it and Skip were sharing one right-aligned slot,
  and the IconButton's own alignment only moved the glyph inside its box.
- Slides overflowed on short screens and at large text sizes: the hero was
  a fixed 220px that could not shrink and nothing scrolled. The copy was
  pushed off the bottom in every language at 200% text. The illustration
  is now a share of the viewport and the slide scrolls.
- The screen is fixed dark but drew its accent from the current theme, so
  light mode put the light accent on a near-black ground. It now renders
  under AppTheme.dark throughout.
- showSkip was a nullable bool force-unwrapped at every use.

test/view/screens/demo_screen_test.dart walks every slide at three screen
sizes, three text scales and all six locales; the overflow was silent
before because nothing covered this screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeuLLZuNDNed59po7ndqbp
pubspec declares no asset bundle, so nothing under assets/ ships in the
app; these were only ever repository files, and grep finds no reference to
any of them in the README or anywhere else.

Removed: view_doc_02, view_doc_03 and view_doc_05, annotated screenshots
of the pre-rewrite dark UI that no longer exists; scan_w, the unused white
variant of the logo; github-sign, an Octocat; and vikkiboi and vj_jpg, two
contributor headshots the README never used.

The five that stay are all linked from the README: scan_g, Playstore,
home, view_doc_01 and view_doc_04. Only Playstore is served from this
branch — the rest are linked at master, so removing them here would break
the README the moment dev_v3 merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeuLLZuNDNed59po7ndqbp
Restores the Developers section v2 had, in the current design system: a
card each for Vijay and Vikram, portrait in an accent ring, name, and a
LinkedIn label with an open-in-new glyph so the card reads as a link
rather than decoration. The two headshots come back with them — I removed
them in the previous commit as unreferenced, which they were, because this
screen had dropped the section entirely.

Fixed while here:

- No outbound link worked. Android 11+ package visibility hides every
  browser from canLaunchUrl unless the manifest declares a browser query,
  and <queries> only listed IMAGE_CAPTURE — so About's GitHub button had
  been silently dead, failing into a debugPrint nobody sees. The query is
  declared, and a link that still cannot open now raises a snackbar using
  the couldnt_launch_url string that was already translated and unused.
- The description opened mid-sentence: app_description is written in every
  locale to follow the app's name inline ("is an open-source app…",
  "είναι…", "एक … है"), and the rebuild had dropped the name, leaving a
  fragment in all six languages. The paragraph leads with OpenScan again,
  as it does on master.

The correction to the previous commit's reasoning: pubspec does declare an
asset bundle — `assets: - assets/` — further down the flutter section than
I read. The seven files removed there were shipping in the APK, so that
deletion took 1.8MB out of the build rather than none; the files were
still unreferenced and the removal still right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeuLLZuNDNed59po7ndqbp
The button led with Icons.code_rounded — angle brackets, which say "code"
rather than "GitHub".

The v2 asset could not be used as it stood: github-sign.png draws the
Octocat in luminance, white on an opaque black disc, with only the area
outside the disc transparent. Tinting that to the button's foreground
collapses it to a filled blob, since srcIn keeps the alpha and replaces
every colour. assets/github-mark.png carries the same artwork with the
shape moved into the alpha channel, so it takes the foreground colour like
any other icon: dark on the light button, light in dark mode. Verified on
device in both.

OSButton grows a `leading` slot for marks Material has no glyph for. It is
built inside the button's existing IconTheme, so a Builder reading
IconTheme.of(context) picks up the same foreground the label resolved to,
disabled state included, rather than having to guess the colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeuLLZuNDNed59po7ndqbp
A live-scan session did all its image work at the end: every page went
into the list raw, and pressing Done handed the whole batch to
directory_cubit, which decoded, cropped and re-encoded each
multi-megapixel photo one after another. A ten-page scan meant a ten-page
wait on the last screen, with placeholder thumbnails standing in for
pages that were already shot.

The work now happens per capture, in the camera. _prepareCapture runs the
same storage pipeline (writeCapture, so the fast native-decode path and
its pure-Dart fallback are both reused as they are) into a staging
directory under the cache, and the LiveCapture it returns is marked
prepared: the page file is finished, its orig_ companion comes with it
when keep-originals is on, and quad is null because the crop has already
been applied. saveCapture/writeCapture grow a prepared branch that moves
those files in rather than decoding them again, so nothing is
re-compressed on the way into the document and Done is instant.

Ordering inside the capture: the image stream restarts before processing
begins, so the viewfinder is live and detecting again while the last page
encodes. _capturing stays true across the whole span, which the shutter
already renders as a spinner — it also gates auto-capture, and now gates
Done, since the shot in flight is not in the list yet and leaving on it
would drop it.

Failure is non-destructive. A capture the pipeline could not process is
returned raw with its quad intact and stored the way it always was;
_adoptPrepared keeps the displayability guard, so a staged file that
turns out unreadable leaves no page behind, and an original that fails to
move is simply not recorded.

The trade-off: total work is unchanged, it just moved into the gaps
between shots, so back-to-back shooting now waits on the encode where it
did not before.

Staging lives at cache/staging/capture, which purgeStaging already clears
at startup, so a session killed mid-batch leaves nothing behind.

Added an integration test asserting the adopted page is byte-identical to
the staged one — proof no second encode happens — and that the crop is
not applied twice. It needs a device; none was attached, so it has not
been run. flutter analyze is clean and the 188 unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeuLLZuNDNed59po7ndqbp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants