Conversation
- get image data into cubit - create, reorder
- get image data into cubit - create, reorder
- get image data into cubit - create, reorder
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.