Flutter app for finding pictures that contain a searched word in a group of photos
Note: The new gallery UI is now the default. The legacy UI remains available temporarily via a runtime toggle.
- Dual UI modes: modern gallery UI and legacy interface with in‑app toggle
- Gallery browsing: masonry grid (sliver) with full‑screen review and swipe
- Search, filter, sort: rich search; filters for state, verification, platform added; multiple sort options with order toggle
- Handles management: edit/view Snapchat, Instagram, Discord; verify/unverify with timestamps; mark added/reset added
- OCR & AI: ChatGPT‑based extraction; manual crop/redo flow; safe post‑processing that preserves verified data
- Import workflow: pick from device albums for a chosen import directory; move/copy into DCIM/Comb; auto‑process and save
- Data & persistence: Hive storage with autosave; migrations from SharedPreferences; legacy path recovery
- Cloud backup: Google Drive JSON backup; sign‑in/out; merge cloud→local; explicit sync and pull‑to‑refresh
- Notes & actions: edit notes; open socials; guided unfriend flows with timestamped notes
- Settings: manage import directory, cloud account, view last sync time
- Performance & reliability: cache trimming on lifecycle/memory pressure; targeted image decoding; progress/error feedback; crash reporting
The project ships with two gallery experiences that can be switched live at runtime. The new UI is the default.
- Legacy UI ("classic") – the original directory‑driven workflow with explicit Find / Display operations and a ProgressDialog overlay.
- New UI (default) – a modern, card/page based gallery fed from persisted ContactEntry objects, with incremental search, filtering, state tags, and pull‑to‑refresh for cloud sync.
A single flattened MaterialApp (see MyRootWidget in main.dart) hosts both. A SharedPreferences flag (use_new_ui_candidate) records the chosen UI. Switching can be initiated from:
- The AppBar swap icon in either UI.
- The Settings screen toggle (Interface section) in the new UI.
Both triggers present a confirmation dialog and then call UiMode.switchTo(bool useNew), which updates the preference and tells the root widget to rebuild immediately. Because the rebuild disposes the previous screen tree, any dialog / async callback must avoid using a stale BuildContext after switching (all recent dialogs now pop using their dialog context to prevent exceptions).
The legacy UI will be removed in a future cleanup once the new UI is fully settled.
Run on a connected device or emulator:
flutter pub get
flutter runRun the focused widget tests used during the new UI migration:
flutter test test/image_gallery_test.dart -r expandedTip (Android): if you get installation/signing errors across machines, see “Shared Debug Keystore” below or simply run the bootstrap script under “Bootstrap setup”.
| Layer | Purpose | Key Artifacts |
|---|---|---|
| Root Shell | Single MaterialApp with dynamic home choosing legacy or new UI |
MyRootWidget, UiMode |
| Legacy Utility Shell | Static services used only by legacy UI (progress, gallery mutation) | LegacyAppShell (replaces old nested MyApp) |
| Data Persistence | Contact entries + metadata | Hive / StorageUtils + ContactEntry |
| Preferences | Lightweight flags & last state / directory | SharedPreferences (use_new_ui_candidate, _last_selected_state, import dir) |
| Cloud Sync | Google Drive JSON backup | CloudUtils (getCloudJson, updateCloudJson, firstSignIn) |
| OCR & AI | Text extraction + enrichment | Local OCR (tesseract‐based) + ChatGPTService + postProcessChatGptResult |
| Search | Unified token string for fast filtering | SearchService.searchEntriesWithOcr |
| UI State Tags | Semantic grouping of images / contacts | Directory names → state strings (Buzz buzz, Honey, Strings, Stale, Comb) |
Earlier versions instantiated a second MaterialApp inside a legacy MyApp widget. This caused theme / navigator duplication and dialog context issues. The refactor removed the wrapper; LegacyAppShell now exposes only static utilities (ProgressDialog, gallery, updateFrame) and updateFrame is nullable to avoid early calls when the legacy UI has not yet built.
- Legacy UI: Relies on a global
ProgressDialogviaLegacyAppShell.prand callback updates (LegacyAppShell.updateFrame?.call). - New UI: Avoids global progress dialog; instead uses local SnackBars, pull‑to‑refresh indicators, and (optionally)
CloudUtils.progressCallbackfor cloud operations. If needed, a thin adapter can map that callback into aSnackBar/ inline banner.
- On startup
initializeApp()triggersCloudUtils.firstSignIn()which attempts sign‑in and downloads the Drive JSON file (contains serialized contact data / file path map for the experimental UI). CloudUtils.getCloudJson()merges remote JSON into local Hive viaStorageUtils.mergethen (optionally) triggers a UI refresh throughLegacyAppShell.updateFrame?.call(no‑op if legacy UI not mounted).- Manual sync (
_forceSyncin new UI) callsupdateCloudJson()with a timeout, surfaces status via SnackBars, and stamps_lastSyncTimefor display in Settings.
StorageUtils.migrateSharedPrefsToHive filters out reserved preference keys (e.g., use_new_ui_candidate) so preferences are not misinterpreted as contact entries. Add any new internal preference keys to the reserved list when introducing them.
The legacy Find operation runs OCR across a directory only when invoked, while the new UI:
- Continuously maintains a searchable index (filename, identifier, handles, extracted text, prior OCR fields) through
SearchService. - Ensures missing OCR content is lazily populated when a search term first references an entry without cached text.
Original folder names are mapped to a state field (e.g., "Buzz buzz", "Honey", "Strings", "Stale", "Comb"). The UI filter in the new gallery lets users view subsets by these tags; the last chosen tag persists across sessions.
- Do not hold references to widgets across a switch.
- Avoid using a dialog's parent screen context after the switch; always
Navigator.pop(dialogCtx, result)using the builder'sdialogCtx. - Global mutable singletons (e.g. progress dialog) must be reinitialized with a fresh context after switching; the code now recreates the ProgressDialog on legacy init and null‑checks it elsewhere.
- Add pure functionality inside
utils/or a new service class. - Expose optional progress through
CloudUtils.progressCallback/ a new callback rather than invoking UI elements directly. - Have each UI decide how (or whether) to surface progress.
- Widget tests that open dialogs from the new gallery require consistent use of the root navigator; ensure
navigatorKeyis passed fromMyRootWidgetonly (avoid multipleMaterialApps to keep semantics stable). - When writing tests that flip UI modes, set the preference (
use_new_ui_candidate) before pumpingMyRootWidgetto start directly in a target UI for speed.
- Unified progress surface bridging both UIs (in-app banner service rather than dialog).
- Incremental background OCR for newly imported images (update index without manual refresh).
- Replace global statics (
LegacyAppShell) with a scoped provider once migration from legacy UI nears completion. - Drive delta sync (upload only changed records, download only diff) to reduce bandwidth.
| Action | Where | Result |
|---|---|---|
| Swap icon | Legacy & New AppBars | Immediate confirmation then mode flip |
| Settings toggle | New UI > Settings > Interface | Confirmation then mode flip |
| Programmatic | await UiMode.switchTo(true/false); |
Persists preference & rebuilds root |
If you introduce another UI, extend UiMode or add an enum; for now the boolean flag keeps code simple.
The new gallery uses SearchService, which builds a search string from each entry's filename, identifier, usernames, social media handles, and extracted text (from either extractedText or older ocr fields). Typing in the search box filters the gallery using this service. When a search term is entered, any entry missing extracted text is automatically processed through the OCR service so its content can be searched next time.
- Sorting by name, date or file size
- Filtering by stored state tag (migrated from the image's original folder name)
- Searching filenames, usernames and extracted text
- Long-press selection of entries with a contextual action menu
- Tap an image tile to view it full size and read all extracted text
- The gallery remembers your last selected state filter across sessions
- A counter shows which image is currently visible out of the filtered list
- Quick actions to open Snapchat/Instagram/Discord profiles and mark them as unfriended with a timestamped note
- Separate flows for quick unfriending when there's no response versus when a chat went poorly
The app can't confirm whether a friend request was ever accepted. When removing someone you may also need to clear the "added" flag for that platform so the username can be reused later.
- Add photos to gallery in batches (requires async functionality)
- Switch ChatGPT requests in the new UI from sequential to asynchronous so each request starts immediately; the app's ChatGPT service handles rate limiting
- Minimize code into services approach
- Give selection text background color and highlight color
- Change Snap detection to checking all text on one line
- Create GalleryCell object
- Save original image used in GalleryCell object inside the object so that it can be used when
redoingand the image doesn't get reloaded/re-adjusted - Test closing details dialog opened from popup menu (blocked by failing widget tests)
- Update minimum Flutter version supported by the app to include the patch that prevents focus loss when toggling Android voice input
- Fix the directory/state name typo "Strings" to be "Stings" and update any references
The legacy interface includes a Find command which scans a directory of images using OCR. The logic lives in lib/utils/operations_utils.dart and processes each file through ocrParallel, adding results to the gallery once text extraction is finished. The new interface does not yet trigger this operation directly.
To avoid Android uninstalling the app when switching development machines, all builds are signed with the same debug keystore. Copy the team's keystore to android/app/debug.keystore before running the app so installations from different PCs share the same signature.
The debug keystore is stored in Firebase Functions config as a Base64 string so
each machine can retrieve the same signing key. You must be authenticated with
the Firebase CLI and have access to the photowordfind.keystore config value.
Windows (PowerShell):
# Recommended (does everything):
powershell -ExecutionPolicy Bypass -File ./scripts/bootstrap.ps1
# Or manual retrieval:
$b64 = (firebase functions:config:get photowordfind.keystore --project pwfapp-f314d)
if ($b64.StartsWith('"') -and $b64.EndsWith('"')) { $b64 = $b64.Trim('"') }
[IO.File]::WriteAllBytes('android/app/debug.keystore', [Convert]::FromBase64String($b64))The scripts/bootstrap.ps1 script installs the Firebase CLI using winget,
signs in, downloads the keystore from this config value, and registers its
fingerprint with Firebase automatically on Windows.
From the scripts and Gradle config, this shared key is the standard Android debug keystore:
- Alias:
androiddebugkey - Keystore password:
android - Key password:
android - Algorithm/size: RSA 2048
- Validity: 10,000 days
- DN:
CN=Android Debug,O=Android,C=US
You can recreate an identical debug keystore with:
Important: Do not regenerate the keystore if you want installs on existing devices to continue without uninstall. Always reuse the exact same debug.keystore file. The passwords below do not affect the app’s identity; they only unlock the file. Changing the file (regenerating keys) changes the signature and breaks continuity.
Windows (PowerShell) — only if you are intentionally creating a new shared debug keystore:
$keytool = (Get-Command keytool).Source
& $keytool -genkeypair -v `
-keystore "android/app/debug.keystore" `
-alias androiddebugkey `
-storepass android -keypass android `
-keyalg RSA -keysize 2048 -validity 10000 `
-dname "CN=Android Debug,O=Android,C=US"List fingerprint (useful for verification):
$keytool = (Get-Command keytool).Source
& $keytool -list -v -keystore "android/app/debug.keystore" -alias androiddebugkey -storepass android -keypass android | Select-String 'SHA1:'Once created, store it as Base64 in Functions config so all machines can fetch the same file.
Windows (PowerShell):
$bytes = [IO.File]::ReadAllBytes("android/app/debug.keystore")
$b64 = [Convert]::ToBase64String($bytes)
firebase functions:config:set photowordfind.keystore="$b64" --project=pwfapp-f314dAfter uploading, teammates can use the retrieval command above or run scripts/bootstrap.ps1 to download and install the keystore automatically.
Keep the keystore in your Firebase project's function config so it can be fetched securely from any development machine. Avoid copying the raw file between PCs; instead rely on the commands above or the bootstrap script.
On Windows, run the included PowerShell script to install the Firebase CLI via
winget and the required JDK before retrieving the debug keystore. Set the
execution policy for the current process and then run the script with
-ExecutionPolicy Bypass -File:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
powershell -ExecutionPolicy Bypass -File ./scripts/bootstrap.ps1The script installs Eclipse Temurin JDK 17 and Android Studio via winget,
refreshing the current PATH after each installation so new commands are
available immediately. Android platform-tools are installed as well so adb
works without extra steps. The JDK location is persisted in PWF_JAVA_HOME and
prepended to your user PATH without affecting other JDK versions. The script
creates a .jdk junction in the repository root pointing to this path and
android/gradle.properties always contains org.gradle.java.home=../.jdk, so
Gradle can locate the JDK without modifying the file per-machine. Every
Firebase CLI command includes --project=pwfapp-f314d, so no firebase use
state is required. It prints progress messages for each step—including when
downloading the keystore, parsing the Firebase Functions config value and
registering the SHA‑1 fingerprint with Firebase app
1:1082599556322:android:66fb03c1d8192758440abb if missing—and finally writes a
.bootstrap_complete file in the repository root and opens the Windows Developer
Mode settings for convenience. Because Gradle executes from the android
subdirectory it looks for this flag relative to the parent
directory and runs the script automatically when it is absent.