Flutter App
Setup guide and conventions for the Seken mobile app, built with Flutter 3.41 and Dart. For mobile developers working on the app.
Stack
| Item | Version / Choice |
|---|---|
| Flutter | 3.41 |
| Dart | 3.x (SDK >=3.2.0 <4.0.0) |
| Architecture | Clean Architecture (data/ → domain/ → presentation/) |
| State management | provider ^6.1.0, ChangeNotifier + MultiProvider in main.dart |
| Navigation | Custom push/pop stack in AppShell (manual _navigationStack) |
| HTTP client | http ^1.2.0, wrapped in singleton ApiClient (core/network/api_client.dart) |
| Simple prefs | shared_preferences ^2.2.2, onboarding flag, simple settings |
| Offline cache | sqflite ^2.3.2, SQLite via DatabaseHelper (core/database/database_helper.dart) |
| Backend client | http ^1.2.0, wrapped in ApiClient; all data goes through the NestJS backend API |
| Functional patterns | dartz ^0.10.1 (Either), equatable ^2.0.5 |
Project Structure
/mobile/
lib/
config/
app_config.dart → Business rules, env vars, Supabase URLs
constants.dart → App-wide string/int constants
core/
database/
database_helper.dart → sqflite singleton (offline cache)
network/
api_client.dart → HTTP singleton with dual-header auth
network_info.dart → Connectivity check (connectivity_plus)
errors/
exceptions.dart → ServerException, NetworkException, etc.
failures.dart → dartz Failure types
widgets/
seken_cached_image.dart → cached_network_image wrapper
features/ → One folder per domain feature
auth/
data/datasources/ → auth_remote_datasource, ktp_remote_datasource
data/repositories/
data/models/
domain/entities/
domain/repositories/
presentation/providers/ → auth_provider, ktp_provider
listings/ → Same structure (remote + local datasources)
orders/
chat/
shipping/
social/ → Follows, likes, addresses, bank accounts
notifications/
reviews/
admin/
models/ → Legacy flat models (listing, order, user_profile, chat)
screens/ → All screen widgets (flat, not feature-scoped)
widgets/ → Shared UI components (bottom_nav_bar, etc.)
theme/
seken_theme.dart → Light + dark MaterialTheme definitions
injection_container.dart → DI setup — wires all providers
main.dart → Entry point, MultiProvider setup, AppShell
test/
pubspec.yaml
Setup
cd mobile
# Install dependencies
flutter pub get
# Verify environment
flutter doctor
# Run on simulator / device
flutter run
# Run tests
flutter test
See Local Setup → for full environment setup including Supabase CLI and env vars.
app_config.dart, Business Rules & Env Vars
All business rules, env var bindings, and tuning constants live in lib/config/app_config.dart. Variables are injected at build time via --dart-define:
# Required --dart-define keys
flutter run \
--dart-define=APP_ENV=production \
--dart-define=API_BASE_URL=https://api.pasarseken.id
--dart-define Key | Default | Description |
|---|---|---|
APP_ENV | development | development / staging / production |
API_BASE_URL | dev localhost | NestJS backend base URL |
Always override
API_BASE_URLwith--dart-definefor staging or production builds. The default points to a local dev server.
The actual business-rule constants (from source):
class AppConfig {
// Buyer Protection Fee — flat IDR 10,000 (proteksi 6,500 + asuransi 1,000 + pembayaran 2,500).
// Runtime-overridable fallback defaults, loaded from server fee config (PS-677).
static int bpfTotal = 10000;
static int proteksiIdr = 6500;
static int asuransiIdr = 1000;
static int pembayaranIdr = 2500;
static const int minListingPhotos = 3;
static const int escrowReleaseHours = 48; // ⚠️ stale vs server — see note below
static const int shipDeadlineHours = 48;
static const int pageSize = 15; // feed pagination
static const int thumbWidth = 400; // px — thumbnail CDN transform
static const int detailImageWidth = 800; // px — PDP gallery CDN transform
}
Any change to these values must also be updated in the NestJS backend config (BusinessRulesConfig.ts) and frontend constants (shared-constants.tsx). They must stay in sync. Mobile changes require App Store review (1-7 days).
- Buyer Protection Fee is a flat IDR 10,000 (not a percentage). Server source of truth is
Phase1FeeStrategyindomain/finance/FeeCalculationStrategy.ts. escrowReleaseHours = 48is stale on mobile. The server-canonical dispute/auto-release window is 24h (escrow_hold_hours,BusinessRulesConfig.ts; unified to 24h in PS-1112). The mobile constant is pending an App Store sync.invoiceExpiryHourswas removed fromapp_config.dart(PS-494, "was 12h, unused"); invoice expiry is governed server-side (24h).
For context on why these values exist, see Architecture →.
Theme System
Colors are never hardcoded in widgets. Always use SekenColors:
// ✅ Correct
Container(color: SekenColors.primary)
Text('Hello', style: TextStyle(color: SekenColors.accent))
// ❌ Wrong — never do this
Container(color: Color(0xFF006D77))
| Token | Hex | Usage |
|---|---|---|
SekenColors.primary | #006D77 | Primary teal, buttons, headers |
SekenColors.accent | #E29578 | Accent terracotta, highlights, CTAs |
SekenColors.background | #FAF8F5 | Warm linen, backgrounds |
SekenColors.dark | #1A1A1A | Text |
SekenColors.muted | #EDECE9 | Borders, dividers |
See Brand Guidelines → for the full brand system.
API Communication
The Flutter app calls the NestJS backend for all data. All requests go through ApiClient.
ApiClient (core/network/api_client.dart) is a singleton that attaches two headers on every request:
// Dual-header auth (from api_client.dart):
{
'Authorization': 'Bearer ${AppConfig.supabaseAnonKey}', // gateway pass-through (anon key)
'X-User-Token': userJwt, // SEKEN JWT, verified by NestJS
}
// ✅ Correct — call through ApiClient
final response = await apiClient.get('/listings/$id');
final list = await apiClient.getList('/listings');
await apiClient.post('/orders', {'listing_id': id});
// ❌ Wrong — never do this
// Direct database or storage access bypasses all business logic
The server wraps all responses in a standard envelope: { "success": bool, "data": T, "error": string? }. ApiClient unwraps it transparently, callers always receive the inner payload.
Offline Cache
DatabaseHelper (core/database/database_helper.dart) maintains a local SQLite database (seken_cache.db) with these tables:
| Table | TTL default | Contents |
|---|---|---|
cached_listings | 1 hour | Single listing detail (PDP cache) |
cached_orders | 1 hour | Order history |
cached_notifications | 1 hour | Notification feed |
admin_settings | , | Local ops/test flags |
Key Screens
Navigation is handled by a manual push/pop stack inside AppShell, not GoRouter (which is in pubspec.yaml but unused at the app shell level). Screens are pushed via _pushScreen(Widget) and popped via _popScreen().
| Screen file | Tab / Entry point | Description |
|---|---|---|
home_screen.dart | Tab 0 | Feed, browse active listings |
search_screen.dart | Tab 1 | Search with filters |
create_listing_screen.dart | Tab 2 (sell+) | AI-powered listing creation, 3-step flow (Photos → Details → Review). On "Lanjut" (step 0→1), cover photo is uploaded to storage first; the public HTTPS URL is then passed to /ai/categorize. The AI response (20+ fields) is mapped into form state: title, description, gender, category, condition, brand, size, color, style, sub-category, vibe tags, and original price. A confidence warning toast is shown when the AI confidence score is below 30%. |
wishlist_screen.dart | Tab 3 | Saved / liked listings |
settings_screen.dart | Tab 4 | Profile, account, navigation hub |
product_detail_screen.dart | Push | Listing detail, buy or make offer |
checkout_screen.dart | Push | Checkout flow |
orders_screen.dart | Push | Buyer + seller order management |
chat_screen.dart | Push | Buyer-seller messaging |
ktp_verification_screen.dart | Push | KTP verification flow |
seller_dashboard_screen.dart | Push | Seller analytics |
admin_panel_screen.dart | Push (admin only) | Internal ops panel |
onboarding_screen.dart | First launch | Shown once (tracked via SharedPreferences) |
Building for Release
# iOS
flutter build ios --release
# Then archive via Xcode → App Store Connect
# Android
flutter build appbundle --release
# Then upload to Google Play Console
See Deployment → for the full mobile release process.
Testing
# Unit and widget tests
flutter test
# Integration tests (requires device/simulator)
flutter test integration_test/
# Test coverage
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
Common Issues
| Problem | Likely Cause | Fix |
|---|---|---|
pub get fails | Outdated SDK | flutter upgrade |
| Build fails on iOS | CocoaPods not installed | sudo gem install cocoapods && pod install |
| Android build fails | Missing signing config | Add keystore in android/key.properties |
| API returns 401 | JWT not attached | Check that ApiClient.setUserToken() was called after login |
| Colors look wrong | Hardcoded hex used | Use SekenTheme / SekenColors tokens |
| Cache stale after update | SQLite TTL not expired | Call DatabaseHelper.clearTable('cached_listings') |
| AI categorization returns mock results on listing creation | Cover photo was passed as a local device path before upload, backend SSRF guard rejects non-HTTPS URLs | Fixed in PS-130: create_listing_screen.dart now uploads the cover photo to storage on "Lanjut" tap and passes the public HTTPS URL to /ai/categorize |
AI always assigns gender 'women' regardless of item | create_listing_screen.dart compared result['gender_id'] against 'M'/'F', but the backend returns 'men'/'women', comparison never matched | Fixed in PS-131: read result['gender'] directly; manual translation removed |
| Style, sub-category, vibe tags, and original price not populated after AI scan | _uploadAndRunAiCategorization() only mapped 7 of 20+ AI response fields, style, sub_category, vibe_tags, original_price, and confidence toast were silently dropped | Fixed in PS-132: all 4 fields now mapped into state and included in the submit payload; confidence warning toast shown when AI score is below 30% |
| Vibe Trending chips on home screen return empty results | The chip label (e.g. K-Style) was embedded in initialQuery as a text search string. The RPC sanitiser strips hyphens, turning K-Style into kstyle, but the tsvector stores k and style as separate tokens, so kstyle:* matches nothing. Chips like Cewek Bumi and Vintage Vibes have no matching vibe_tags entries in the database at all. | Fixed in PS-243: HomeScreen now passes vibe.$2 as vibeTag (not initialQuery). SearchScreen fires a filter-only search on mount. A new p_vibe_tag RPC parameter uses = ANY(l.vibe_tags) for exact array membership. vibeTag threaded through all Flutter layers. Migration 20260508000002. |
| Cari search results always empty regardless of query or filters | ListingRepositoryImpl.searchListings() read data['items'] from the /search response, but the backend returns results under "listings" (PS-233 contract). The ?? [] fallback silently returned an empty list on every call. | Fixed in PS-240: one-line change in listing_repository_impl.dart, data['items'] → data['listings']. |
| Cari filter sheet "Terapkan Filter" does nothing when search box is empty | Two guards blocked filter-only browsing. Screen layer: onApply callback in search_screen.dart was wrapped in if (_searchController.text.isNotEmpty). Provider layer: ListingsProvider.search() had if (query.trim().isEmpty) { return; } that cleared results before any API call. | Fixed in PS-241: screen guard replaced with if (text.isNotEmpty || hasFilters); provider guard changed to if (query.isEmpty && !hasFilters). |
| Cari filter sheet has no product category section; selecting category does nothing | _FilterSheet had no "Kategori Produk" section and FilterResult had no productCategory field, so the value was never captured or passed to _performSearch(). | Fixed in PS-242: FilterResult gained productCategory; _FilterSheet and _FilterSheetState updated; "Kategori Produk" chip section added (8 chips from Categories.productCategories); _SearchScreenState passes _selectedProductCategory to _performSearch(). |
| Category filter on home screen returns the same full feed regardless of selection | ListingsProvider.fetchListings() passed category: to GetListingsParams, sending ?category=X to the backend. The GET /listings endpoint only filters on ?product_category=X (PS-233 Zod schema). A second bug re-forwarded the wrong param in loadMore(), setSortBy(), and refresh(). | Fixed in PS-239: renamed _activeCategory → _activeProductCategory; fetchListings() and loadMore() now pass productCategory: to GetListingsParams. No backend change required. |