Skip to main content

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

ItemVersion / Choice
Flutter3.41
Dart3.x (SDK >=3.2.0 <4.0.0)
ArchitectureClean Architecture (data/domain/presentation/)
State managementprovider ^6.1.0, ChangeNotifier + MultiProvider in main.dart
NavigationCustom push/pop stack in AppShell (manual _navigationStack)
HTTP clienthttp ^1.2.0, wrapped in singleton ApiClient (core/network/api_client.dart)
Simple prefsshared_preferences ^2.2.2, onboarding flag, simple settings
Offline cachesqflite ^2.3.2, SQLite via DatabaseHelper (core/database/database_helper.dart)
Backend clienthttp ^1.2.0, wrapped in ApiClient; all data goes through the NestJS backend API
Functional patternsdartz ^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 KeyDefaultDescription
APP_ENVdevelopmentdevelopment / staging / production
API_BASE_URLdev localhostNestJS backend base URL

Always override API_BASE_URL with --dart-define for 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
}
Sync with Backend

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 Phase1FeeStrategy in domain/finance/FeeCalculationStrategy.ts.
  • escrowReleaseHours = 48 is 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.
  • invoiceExpiryHours was removed from app_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))
TokenHexUsage
SekenColors.primary#006D77Primary teal, buttons, headers
SekenColors.accent#E29578Accent terracotta, highlights, CTAs
SekenColors.background#FAF8F5Warm linen, backgrounds
SekenColors.dark#1A1A1AText
SekenColors.muted#EDECE9Borders, 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:

TableTTL defaultContents
cached_listings1 hourSingle listing detail (PDP cache)
cached_orders1 hourOrder history
cached_notifications1 hourNotification 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 fileTab / Entry pointDescription
home_screen.dartTab 0Feed, browse active listings
search_screen.dartTab 1Search with filters
create_listing_screen.dartTab 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.dartTab 3Saved / liked listings
settings_screen.dartTab 4Profile, account, navigation hub
product_detail_screen.dartPushListing detail, buy or make offer
checkout_screen.dartPushCheckout flow
orders_screen.dartPushBuyer + seller order management
chat_screen.dartPushBuyer-seller messaging
ktp_verification_screen.dartPushKTP verification flow
seller_dashboard_screen.dartPushSeller analytics
admin_panel_screen.dartPush (admin only)Internal ops panel
onboarding_screen.dartFirst launchShown 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

ProblemLikely CauseFix
pub get failsOutdated SDKflutter upgrade
Build fails on iOSCocoaPods not installedsudo gem install cocoapods && pod install
Android build failsMissing signing configAdd keystore in android/key.properties
API returns 401JWT not attachedCheck that ApiClient.setUserToken() was called after login
Colors look wrongHardcoded hex usedUse SekenTheme / SekenColors tokens
Cache stale after updateSQLite TTL not expiredCall DatabaseHelper.clearTable('cached_listings')
AI categorization returns mock results on listing creationCover photo was passed as a local device path before upload, backend SSRF guard rejects non-HTTPS URLsFixed 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 itemcreate_listing_screen.dart compared result['gender_id'] against 'M'/'F', but the backend returns 'men'/'women', comparison never matchedFixed 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 droppedFixed 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 resultsThe 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 filtersListingRepositoryImpl.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 emptyTwo 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 selectionListingsProvider.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.