AI-powered WhatsApp sticker generator. Users pick a preset style + type a short prompt → the backend builds a strict "die-cut sticker on a flat chroma background" prompt → calls a configurable image provider chain → post-processes the result (chroma background removal + white outline) into a transparent 512×512 WebP → atomically deducts credits and returns a freshly generated sticker.
- Architecture
- Tech stack
- Repository layout
- Prerequisites
- Local setup
- Database migrations
- Edge function deployment
- Running the Flutter app
- State management approach
- Security model
- Accessibility (Okabe-Ito palette)
- Verification checklist
- Troubleshooting
┌─────────────────────────┐ ┌────────────────────────────────────┐
│ Flutter app │ │ Supabase backend │
│ │ │ │
│ UI (Material 3) │ │ Auth (email + password) │
│ ↓ │ │ Postgres │
│ BLoCs (flutter_bloc) │ HTTPS │ ├─ user_wallets │
│ ↓ │ ◀────── │ ├─ credit_transactions (ledger) │
│ Repositories │ │ ├─ sticker_generations │
│ ↓ │ │ └─ RPC deduct_credit_for_sticker │
│ SupabaseClient │ │ Storage (private 'stickers') │
│ │ │ Edge function: generate-sticker ──┼─▶ OpenRouter
└─────────────────────────┘ └────────────────────────────────────┘
The client never speaks to OpenRouter directly. Every generation flows through the generate-sticker edge function, which owns the OpenRouter API key, drives the atomic credit deduction RPC, performs the upload, and returns a short-lived signed URL.
Clean-architecture layering inside lib/:
| Layer | Folder | Purpose |
|---|---|---|
| Presentation | presentation/screens, presentation/widgets |
Pure UI, reads BLoC state |
| State | presentation/blocs |
flutter_bloc — Auth, Wallet, StickerGen, History |
| Domain glue | data/repositories |
Interface + Supabase implementation |
| Data sources | data/datasources, data/models |
Supabase client bootstrap, DTOs |
| Cross-cutting | core/ |
Theme, presets, DI, error types |
Dependencies always point downward; UI never imports supabase_flutter directly.
- Frontend: Flutter 3.41 (Dart 3.11),
flutter_bloc,equatable,get_it,flutter_dotenv,cached_network_image,intl. - Backend: Supabase (Auth + Postgres + Storage + Edge Functions).
- Edge runtime: Deno + TypeScript.
- AI provider: OpenRouter, model
sourceful/riverflow-v2-fastwithmodalities: ["image"].
.
├── android/ # Android project
├── ios/ # iOS project
├── lib/ # Flutter app
│ ├── main.dart
│ ├── app.dart
│ ├── core/
│ │ ├── theme/app_theme.dart
│ │ ├── constants/presets.dart
│ │ ├── errors/failures.dart
│ │ └── di.dart
│ ├── data/
│ │ ├── datasources/supabase_client.dart
│ │ ├── models/{wallet,sticker_generation,credit_transaction}.dart
│ │ └── repositories/{auth,wallet,sticker}_repository.dart
│ └── presentation/
│ ├── blocs/{auth,wallet,sticker_gen,history}/
│ ├── screens/{auth,home,history}/
│ └── widgets/status_indicator.dart
├── test/widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
├── .env.example
├── supabase/ # ← Git submodule (private repo: bikinstiker-supabase)
│ ├── config.toml # Contains: config, migrations, edge functions
│ ├── migrations/
│ │ ├── 20260505000001_init_schema.sql
│ │ ├── 20260505000002_wallet_trigger.sql
│ │ ├── 20260505000003_storage_bucket.sql
│ │ └── 20260505000004_deduct_credit_user_scope.sql
│ └── functions/
│ └── generate-sticker/
│ ├── index.ts
│ └── deno.json
├── .gitmodules # Submodule configuration
├── README.md
└── LICENSE
Note: The
supabase/directory is a git submodule pointing to the private repo bikinstiker-supabase. Backend code (migrations, edge functions) lives there separately.
- Flutter SDK ≥ 3.41 (Dart ≥ 3.11)
- Supabase CLI ≥ 1.200 (
scoop install supabaseorbrew install supabase/tap/supabase) - Docker Desktop (for local Supabase stack)
- Deno (only required if you want to run the edge function locally without Supabase CLI)
- OpenRouter API key with access to
sourceful/riverflow-v2-fast
# 1. Clone with submodules (includes private supabase repo)
git clone --recurse-submodules https://github.com/alamaby/bikinstiker.git
cd bikinstiker
# Or if already cloned without --recurse-submodules:
git submodule update --init --recursive
# 2. Install Flutter deps
cp .env.example .env # fill in SUPABASE_URL + SUPABASE_ANON_KEY
flutter pub get
# 3. Start Supabase locally (from repo root)
supabase start # spins up Postgres, Auth, Storage, StudioAfter supabase start completes, copy the printed API URL and anon key into .env.
API keys: Supabase is replacing the legacy JWT
anon/service_rolekeys withsb_publishable_.../sb_secret_...(deprecated end of 2026). The app accepts either — preferSUPABASE_PUBLISHABLE_KEY=sb_publishable_...in.env(legacySUPABASE_ANON_KEYstill works as a fallback). Locally,supabase startonly prints the legacy pair; deployed edge functions read the new-style keys first viasupabase/functions/_shared/keys.ts.
# Update submodule to latest
cd supabase
git pull origin main
cd ..
git add supabase
git commit -m "chore: update supabase submodule"
# Or using submodule update:
git submodule update --remote supabaseAll schema, RLS policies and RPCs live in supabase/migrations/ as raw SQL — the source of truth.
# Local: re-apply all migrations from a clean state
supabase db reset
# Remote: push migrations to your linked project
supabase link --project-ref <YOUR-PROJECT-REF>
supabase db pushMigrations included:
| File | What it does |
|---|---|
20260505000001_init_schema.sql |
Tables (user_wallets, credit_transactions, sticker_generations), transaction_type enum, indexes, RLS (SELECT-only for owners), and the SECURITY DEFINER RPCs deduct_credit_for_sticker and refund_failed_sticker. |
20260505000002_wallet_trigger.sql |
on_auth_user_created trigger → auto-creates a user_wallets row with 5 starter credits + a matching topup ledger entry on every signup. |
20260505000003_storage_bucket.sql |
Creates a private stickers bucket. RLS lets users SELECT only objects under stickers/{auth.uid()}/.... Uploads happen exclusively from the edge function via the service role. |
20260505000004_deduct_credit_user_scope.sql |
Hardens deduct_credit_for_sticker by deriving the target user from auth.uid() (dropping the caller-supplied p_user_id) to prevent cross-user credit deduction. The edge function is updated in lockstep. |
Mutations against user_wallets, credit_transactions, and sticker_generations are intentionally not exposed via RLS policies — every state change must go through the SECURITY DEFINER RPCs (called from the edge function) so the ledger remains the immutable source of truth.
The function supabase/functions/generate-sticker/index.ts does the following on every call:
- Authenticates the JWT and resolves
auth.uid(). - Validates
{ userInput, presetId }, caps prompt length at 200 chars, rejects unknown presets. - Maps the preset to a concrete style descriptor and builds the strict final prompt:
die-cut sticker foreground subject, centered on a perfectly flat solid chroma magenta background, no gradient, no texture, no scenery, high contrast, <style> style. Subject: <userInput>. - Calls
deduct_credit_for_sticker(p_cost=1, p_preset, p_prompt). User is derived fromauth.uid()inside the RPC. If insufficient → HTTP 402. - Calls the configured image provider chain (OpenRouter, Gemini, Pollinations, or Pixazo). The winning output is post-processed in the Edge Function: the chroma magenta background is removed via an edge-connected flood fill, a white outline is added programmatically, and the result is re-encoded as a transparent 512×512 WebP plus a lossless PNG derivative.
- Uploads the WebP and PNG to
stickers/{user_id}/{sticker_id}.{webp,png}using the service role key. - Updates the row with
image_url,image_png_path,final_prompt, andstatus='success'. - Returns
{ stickerId, signedUrl, path, finalPrompt }(signed URL valid 1 hour). - On any failure after step 4, calls
refund_failed_stickerto mark the row failed, restore the balance, and write a compensatingrefundledger row.
supabase secrets set OPENROUTER_API_KEY=sk-or-...
# SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY are auto-injected.supabase functions deploy generate-stickersupabase functions serve generate-sticker --env-file .env.localThen curl -X POST http://localhost:54321/functions/v1/generate-sticker \ -H "Authorization: Bearer <USER_JWT>" \ -H "Content-Type: application/json" \ -d '{"presetId":"kawaii","userInput":"a smiling boba tea cup"}'
flutter pub get
flutter run # picks up the connected device / emulatorStatic analysis & tests:
flutter analyze # currently clean
flutter testThe app uses flutter_bloc. Why BLoC:
- Strict
Event → Stateflow makes async side-effects (auth, generation) auditable. - Stream-based states pair naturally with Supabase realtime (
WalletBlocwatches the wallet stream). - Easy to test with
bloc_test+mocktail.
Layers:
View (Screen)
↳ context.read<Bloc>().add(Event)
↳ Bloc.handler — calls Repository
↳ Repository — wraps SupabaseClient / Edge Function
↳ DataSource (SupabaseClient)
emit(State) ← Bloc receives result
Four blocs:
| Bloc | Responsibility |
|---|---|
AuthBloc |
Subscribes to auth.onAuthStateChange. Drives unauthenticated / authenticated / submitting. |
WalletBloc |
Subscribes to a Supabase realtime stream on user_wallets filtered by user_id. Emits the live balance. |
StickerGenBloc |
Single-shot `idle → submitting → success(signedUrl) |
HistoryBloc |
Loads paginated history via repository on demand. |
Dependency injection uses get_it and is wired up in core/di.dart. Repositories are exposed to the widget tree via MultiRepositoryProvider so they can be overridden in tests.
- OpenRouter API key never leaves the server. Stored as a Supabase Functions secret; only the edge function can read it.
- Atomic credit deduction.
deduct_credit_for_stickeris a SECURITY DEFINER PL/pgSQL function that takesFOR UPDATElock on the wallet row, checks balance, decrements, inserts the sticker row, and writes the matching ledger entry — all in one transaction. - Immutable ledger. No client (or even authenticated user via SQL) can
INSERT/UPDATE/DELETEdirectly. Every credit movement is an append-only row incredit_transactions. - RLS everywhere. All three tables enable RLS with SELECT-only owner policies. No public columns, no
auth.role() = 'service_role'exemptions visible to the client. - Private storage + signed URLs. The
stickersbucket is private. Owners can onlySELECTobjects whose first path segment matches theirauth.uid(). Uploads happen exclusively from the edge function with the service role key. - Refund on failure. Any post-RPC error (OpenRouter outage, upload failure, etc.) triggers
refund_failed_sticker, which guards against double-refund via a status check. - Validation at the edge. Prompt length cap (200 chars) and preset whitelist enforced server-side; clients can't smuggle arbitrary style strings.
The UI uses the Okabe-Ito color-blind-safe palette to remain legible under the 8 most common forms of color vision deficiency.
| Role | Color | Hex |
|---|---|---|
| Primary | Blue | #0072B2 |
| Secondary / CTA | Orange | #E69F00 |
| Error | Vermilion | #D55E00 |
| Success | Bluish Green | #009E73 |
| Background | White | #FFFFFF |
| Text on background | Near-black | #111111 |
Crucially, color is never the sole signaling channel. Every status, button, and chip pairs:
- An icon (
Icons.check_circle,Icons.error_outline,Icons.bolt, …) - A text label (
Done,Failed,Low credits) - The semantic Okabe-Ito color
See lib/presentation/widgets/status_indicator.dart for the canonical pattern.
The following checks are documented for manual validation — they are not wired into CI for you yet.
- DB migrations:
supabase db resetruns cleanly. Inserting a fake user intoauth.usersshould auto-create auser_walletsrow with balance=5 via the trigger. Callingdeduct_credit_for_stickerwith insufficient balance should raise; with balance, should produce apendingsticker_generations row + a negativecredit_transactionsrow, all in one transaction. - Edge function (local):
supabase functions serve generate-sticker --env-file .env.local, thencurlPOST with a valid JWT and{userInput, presetId}should return 200 with{stickerId, signedUrl, path}. The image should land in Storage atstickers/{uid}/{id}.pngand the row should flip tostatus='success'. - Failure path: With an invalid
OPENROUTER_API_KEY, the function should return 5xx, the row should flip tostatus='failed', the wallet balance should be restored, and arefundcredit_transactionsrow should appear. - Flutter end-to-end:
flutter run→ sign up → starter credits visible → generate sticker → image renders → balance decrements → history shows the entry. Sign in as a second user to confirm RLS isolates data. - Accessibility: Toggle Android Deuteranopia simulation under Developer options; all status indicators should remain distinguishable thanks to the icon + text pairing.
flutter analyzeshould reportNo issues found!.
| Symptom | Likely cause |
|---|---|
StateError: Missing SUPABASE_URL on startup |
bikin_stiker/.env is missing or not listed under flutter.assets in pubspec.yaml. |
Insufficient credits on first generation |
Wallet trigger didn't fire. Confirm migration 20260505000002_wallet_trigger.sql was applied (select * from public.user_wallets where user_id = '<uid>'). |
Sticker row stuck in pending |
Edge function crashed mid-execution. Check supabase functions logs generate-sticker. The refund_failed_sticker RPC is idempotent — call it manually if needed. |
403 when fetching signed URL |
The bucket policy in migration 3 didn't apply, or the storage path doesn't start with the user's auth.uid(). |
| Wallet balance doesn't update live | Realtime is disabled for the user_wallets table on your remote project. Enable replication in Supabase Studio → Database → Replication. |
See LICENSE.