A native Android shopping-list app for people who shop at more than one store. One master list of items is tagged across multiple stores; each store gets its own per-aisle shopping view, and checking an item off at one store cascades — buying mozzarella at Lidl drops it from the Aldi and Pingo Doce lists too, so a single shopping trip satisfies the need.
The app ships with a starter set of stores and categories. Anything not on that list (a regional chain, a one-off shop, a category for a specific household) is added or renamed by the user.
Android v0.8.0 (Play Closed testing) introduces a one-time
$7.99 Premium IAP that gates household-invite creation and CSV
export. The app stays Free to install; everything you’d want from
v0.7.x — cloud sync, cross-store cascade, joining + using a shared
household someone else invited you to — is still free. The
inviter-pays model means whoever pays once can generate
invites; invitees join + use the shared household unconditionally.
Two paths grandfather users into a silent LegacyUser entitlement
(functionally equivalent to Premium): a Firebase account
creationTimestamp predating the v0.8 release date OR a match
against the explicit PREMIUM_VIP_EMAILS allowlist in
EntitlementRepository.kt. The dev account, Mike, Amanda, and Mom
are in that allowlist so they keep free Premium permanently. See
the v0.8.0 entry in CHANGELOG for the full breakdown, including
the in-line .1–.4 patches (VIP allowlist, vc bumps, Mom added,
and the v0.8.0.4 fix for a pull-before-push race where local
soft-deletes were being resurrected by the next pull).
Android v0.7.1 shipped a lossless sideload-APK → Play migration
path: user preferences (theme, language, sort modes,
hide-checked-off) cloud-sync to /userPrefs/{uid} via Firestore, and
Settings → Data has a “Force sync now” button so beta testers can
drain every pending write before uninstalling. Full runbook at
docs/v0.7.1-migration.md.
Android v0.7.0 shipped multi-user household
sharing on top of the existing single-user feature set: invite-code
generate (8-char Crockford base32, 24h TTL, single-use) + join + leave,
household-scoped data for every entity (items, stores, categories,
xref, store-category-order, purchase-records), and a cross-store
cascade that now extends across household members by design (Amanda
buying milk at Aldi drops Mike’s “milk needed at Lidl” entry).
Statistics deliberately stay per-user so Mike sees what HE bought, not
the household combined. Existing v0.5–v0.6 features all carry over:
anonymous-first onboarding with optional Google Sign-In, two-way
Firestore + Storage cloud sync (push and pull), Shop and Items tabs
with item photos, share-list-as-text, theme + language picker
(English, European Portuguese, Spanish, Italian), drag-reorder stores
(long-press on any tile), per-store aisle ordering, cross-store
check-off cascade, Manage Categories, hide / show checked-off items
toggle, QuickAdd autocomplete against the master Items library, in-app
update prompt via Play Core, CSV import / export of items and
categories, and a Statistics screen with a 12-week trend chart. See
docs/play-store-submission.md for
the Play Console listing answers and
docs/privacy-policy.md for the privacy
policy hosted at the Play listing’s required URL.
iOS port (in ios/ — SwiftUI + GRDB + Firebase iOS SDK,
mirrors the Android architecture 1:1): now at v0.8.1 in
main, fully caught up to the Android branch. v0.8.1 brings the
bulk store-tag flow over (long-press an Items-list row → contextual
toolbar → “Tag to stores…” sheet → apply across multiple items in
one transaction). Android’s v0.8.1 architectural xref-join fix is a
no-op on iOS because GRDB has no equivalent of Room’s leaky
@Junction — every iOS join query has filtered tombstones in SQL
since day one.
Beyond Android parity, the iOS branch is now App Store-ready
code-side: the AppIcon ships (1024×1024 sage flatten of the shared
design/shophop-icon-512.png source), light + dark mode both
render the actual brand palette (a Hex-the-Asset-Catalog-namespace
fix that was silently failing before — the app was painting with
iOS system defaults). Full E2E suite of 13 XCUITest cases + a
DesignSystemTourTest visual-regression artifact that walks every
major screen in both appearances. 187 unit + 13 UI tests green.
TestFlight ship is gated on the Apple-side config bundle: real
GoogleService-Info.plist, DEVELOPMENT_TEAM in project.yml,
PrivacyInfo.xcprivacy manifest, App Store Connect
premium_lifetime IAP setup, and a 2-device manual smoke. Full
walkthrough at
docs/ios-app-store-submission.md.
Open the project in Android Studio and let it sync. From the command line, the standard tasks are:
./gradlew :app:assembleDebug build the debug APK
./gradlew :app:installDebug install on a connected device
./gradlew :app:testDebugUnitTest run the unit-test suite
./gradlew :app:bundleRelease build the signed release AAB
Release signing reads keystore.properties at the repo root (gitignored;
see app/build.gradle.kts for the expected keys). When that file is
absent the release task succeeds but the AAB is left unsigned, so CI
and fresh checkouts can still run assembleDebug and the unit tests.
The unit-test suite uses Robolectric and runs without an emulator.
app/src/main/
java/com/storehop/app/
auth/ Google Sign-In, FirebaseAuth session provider
data/
dao/ Room DAOs
db/ Database, migrations, seeder, JSON-backed seeds
entity/ Sync-ready entities (UUID PKs, soft delete)
prefs/ DataStore-backed user preferences
repository/ Repository interfaces and implementations
storage/ Firebase Storage uploader for item photos
util/ IdGenerator, UserSessionProvider
di/ Hilt modules (App, Database, Firebase, Prefs, Repo)
sync/ Push side of the Firestore sync engine + DTOs
ui/
auth/ Sign-in screen
items/ Items master list, Add/Edit form
nav/ Compose Navigation routes
settings/ Account, theme, language picker
shop/ Store picker, Shop-at-Store, share-as-text
theme/ Material 3 theme
util/ CategoryLabel localization helper
MainActivity.kt
StorehopApplication.kt
assets/seed/ stores.json, categories.json, store_categories.json
res/
values/ English strings, theme colors, launcher icon
values-pt-rPT/ European Portuguese strings
Every entity carries id (UUID), createdAt, updatedAt, deletedAt
(soft-delete tombstone), userId, and pendingSync. This shape
supports offline edits, deterministic merging across devices, and the
push-side Firestore sync engine in sync/. Per-store need state lives
on item_store_xref.isNeeded; the default check-off cascades across
every tagged store (one trip clears the list everywhere), and the
manual markNeededAtStore path leaves a per-store override hook for
later if we ever want to expose it. The Room schema is exported to
app/schemas/ and tracked in version control so migrations are
reviewable.
Seeded stores and categories use stable string IDs (for example
store_lidl, cat_produce) rather than generated UUIDs so the seed
pack remains stable across devices and across reseeds.
AppCompatDelegate-only locale apply path
to fix Italian / Portuguese not applying on Pixel; that
regression broke Spanish too (no language switched at all).
Reverted in 0.5.10.es-* data preinstalled but
not Italian or Portuguese, so those splits weren’t delivered
even though they were in the AAB). Disabled the language
split via bundle { language { enableSplit = false } } so
the base APK always carries every locale.UndoBar (× close, swipe, 3s auto-dismiss). Replaces the
Material3 SnackbarHost that had the same indefinite-
duration issue we already fixed on the Shop screens.UndoBar migration on Manage Categories’
deleted-category undo prompt..onMove + EditButton pattern.isStaple && !purchasedThisSession
rows as “needed”, which over-included priority staples
the user had marked purchased. Reverted the partition
to isNeeded-only (matches Mike’s mental model:
marked-purchased = off the list, even for staples).
In-store view unchanged (still surfaces struck-through
staples for convenience). The “auto-renew staples at
session” idea is now flagged as opt-in only, not a
default.remember(categories) key on the
optimistic-local list state that raced the post-drop
LaunchedEffect re-sync and clobbered the new order before
the DB write landed. Normalised to the StorePicker /
EditAisleOrder pattern.householdId access scope.
Settings → Household provides invite-code generate (8-char
Crockford base32, 24h TTL, single-use) + join-with-code +
leave-household. Cross-store cascade extends to households
by design — Amanda buying milk at Aldi drops Mike’s “milk
needed at Lidl” entry. Statistics deliberately stay
per-user: Mike sees what HE bought, not Mike + Amanda
combined. iOS Phase 5 mirror — schema v8, HouseholdRepository
/ View / ViewModel, household-scoped DAOs, sync DTOs +
paths, parity unit tests — is on main; iOS marketing
version stays at v0.6.10 until a Mac xcodebuild test +
2-device smoke run, then bumps to 0.7.0. See CHANGELOG for
the deferred-to-v0.7.x list (real-time addSnapshotListener,
per-field merge, member roles, multiple households per
user, activity log, etc.).addSnapshotListener updates so
Amanda’s checks appear on Mike’s device without a refresh,
per-field conflict merging instead of last-write-wins,
cloud-side membership lookup for second-device sign-in,
and member roles for households that need it.