eKYC 3.0 — Frontend Architecture

Flutter-based cross-platform eKYC client · Single codebase → Android + iOS + Web
Generated 10 April 2026 · Project: flutter_app · Flutter SDK 3.2+ · Dart 3.x

Table of Contents

  1. Overview & Tech Stack
  2. Component Diagram
  3. Component Sequence Diagrams
  4. Layered Architecture
  5. Code-Level Patterns
  6. State Management
  7. Network Layer
  8. Navigation & Routing
  9. Widget Composition
  10. Environment & Config
  11. Platform Build Pipeline
  12. Mock Mode Strategy
  13. Annexures: Android & iOS Build (collapsible)
  14. Appendix: Functional Flows (collapsible)

1 Overview & Tech Stack

The eKYC 3.0 frontend is a Flutter application built once in Dart and compiled natively to Android, iOS, and Web. It provides a 16-stage digital onboarding journey for opening Demat accounts.

Framework
Flutter 3.2+, Dart 3.x, Material 3
State
Vanilla setState, no BLoC/Provider
Network
http package, JWT bearer auth
Storage
SharedPreferences singleton
Animations
Lottie + Material transitions
Assets
SVG (flutter_svg) + PNG + Lottie JSON

2 Component Diagram - Full System View

How all components in the eKYC frontend connect with each other and with external services. Boxes represent components, lines represent dependencies/interactions.

User Flutter Client (Android / iOS / Web) Presentation Layer Stage 0 Stage 1 Stage 2 Stage 3 Stage 4 Stage 5 Stage 6 Stage 7 Stage 8 Stage 9 Stage 11 Stage 12 Stage 13 Stage 14 Stage 15 Cross-cutting wrappers: BackButtonHandler SessionTimer LoadingOverlay JourneyProgressBar ErrorDialog Service Layer ApiService + journeyInit() : ApiResponse + registrationSignup() : ApiResponse + verifyMobileOtp() : ApiResponse + ... 60+ methods StorageService + getInstance() <singleton> + leadId : String? + token : String? + clearAll() AnalyticsService + trackEvent() + trackScreen() (stub - debugPrint) Domain Models (Plain Dart Classes with fromJson) Lead leadId, mobile PanResult pan, kraStatus BankResult isPreVerified OtpResult verified, locked Nominee name, pan, share ApiResponse<T> success, data, errorCode Configuration & Assets api_config.dart routes.dart theme.dart assets.dart (Lottie/SVG) --dart-define=ENV=dev|uat|prod (build-time environment selection) Backend API .NET 8 REST /api/v1/journey/init /api/v1/registration/* /api/v1/pan/* /api/v1/bank/* /api/v1/payment/* JWT bearer token auth External Providers (via WebView / SDK) DigiLocker (WebView) UPI Apps (deeplink) HyperVerge (camera) eMudhra (WebView) Razorpay (SDK / web) Decentro (server-side) Device Storage SharedPreferences (NSUserDefaults / sqflite) Persists leadId, token, stage interacts calls reads returns HTTPS/JSON platform channel launches reads URL
Solid lines = direct calls
Stage screens directly call ApiService and StorageService methods.
Dashed lines = external boundaries
HTTPS to backend, platform channels to native storage, deeplinks to external providers.

3 Component Sequence Diagrams

These diagrams show architectural component interactions - how the layers and classes talk to each other, independent of any specific business flow. Use these to understand the wiring of the app.

3.1 - API Call Pipeline (how any screen talks to the backend)

Every network call in the app follows this same pattern, regardless of stage or method. This diagram shows the internal component chain from a Screen to the backend and back.

«component» ScreenWidget «service» ApiService «helper» _post<T> «singleton» StorageService «library» http package «model» ApiResponse<T> 1. publicMethod(params) 2. _post<T>(url, body, fromJson) 3. get token, leadId, sessionId 4. cached values 5. inject headers, leadId into body 6. http.post(uri, headers, body).timeout(30s) 7. http.Response (statusCode + body) 8. ApiResponse.ok(fromJson(data)) | ApiResponse.fail() 9. typed ApiResponse instance 10. return 11. if success, storage.setCurrentStage() 12. ApiResponse<T>
This is the ONLY network path in the app
Every single API call goes through this same sequence. The only thing that changes per method is the URL and the fromJson factory. Auth, headers, error handling, stage tracking are centralized in _post/_get/_put.

3.2 - Reactive State Update Loop (Flutter widget + state)

How a user event triggers a UI rebuild. This is the mental model for vanilla setState - no external state library involved.

«actor» GestureEvent «component» StatefulWidget «component» State<T> «service» ApiService «framework» Flutter Engine «output» Render Tree 1. onTap callback 2. handler method 3. setState(_isLoading = true) 4. await api.someMethod() 5. ApiResponse 6. setState(_isLoading = false, _data = result) 7. markNeedsBuild() 8. build() on next frame 9. Widget tree 10. paint 11. pixels on screen (next vsync)
Why this is enough (no Redux/BLoC/Provider)
Flutter's framework is already a reactive system. setState() schedules a rebuild, Flutter diffs the widget tree, and repaints only the changed parts. For a linear journey app like eKYC, this loop handles 100% of state needs.

3.3 - Wrapper Widget Composition Chain

How cross-cutting concerns (back button, session timeout, loading) are layered as wrapper widgets without inheritance or mixins.

«input» Platform Event «wrapper» BackButtonHandler «wrapper» SessionTimer «material» Scaffold «wrapper» LoadingOverlay «screen» StageScreen body Construction: BackButtonHandler → SessionTimer → Scaffold → LoadingOverlay → body (each passes child) 1. device back pressed 2. show "KYC is one-time" sheet If user confirms exit → propagate; if cancels → swallow event (return) 3. tap event 4. bubble down 5. reset inactivity timer 6. pass-through 7. Scaffold → body slot 8. if !isLoading, let event through 9. StageScreen body handles tap
Composition over inheritance
No base class. No mixins. Each wrapper is an independent widget with a single child property. You can add/remove wrappers per screen without touching other code. Flutter's Element tree resolves the chain at build time.

3.4 - Singleton Storage Access (cross-component shared state)

How multiple services/screens share the same session data via the StorageService singleton.

«bootstrap» main.dart «screen» Any Screen «service» ApiService «singleton» StorageService «library» SharedPreferences «os» Native KV Store 1. StorageService.getInstance() (first time) 2. SharedPreferences.getInstance() 3. load file / load NSUserDefaults 4. _instance (cached for app lifetime) Subsequent calls to getInstance() return the same _instance without touching OS 5. StorageService.getInstance() 6. same _instance (no OS call) 7. storage.leadId (in-memory read) 8. storage.leadId 9. same value (shared state) 10. setLeadId(newId) → prefs.setString (async, persists)
Why singleton?
The Flutter widget tree is stateful per-screen, but leadId, token, sessionId must survive screen transitions and be accessible from any ApiService instance. A singleton gives us one source of truth for session state without DI frameworks.

3.5 - Build-Time Config Resolution

How the compile-time environment flag flows through components. No runtime lookups, no config files - everything is a Dart constant.

«tool» flutter CLI «compiler» dart compile «config» ApiConfig «service» ApiService «screen» Stage5/Stage6 «output» Compiled APK/IPA BUILD TIME 1. --dart-define=ENV=prod 2. resolve _env = "prod" 3. switch resolved at compile, dev URL tree-shaken 4. baseUrl = "https://ekyc.motilaloswal.com" (hardcoded constant) RUNTIME 5. ApiConfig.baseUrl (direct memory read) 6. if (ApiConfig.isDev && ...) — always false in prod, branch removed Result: prod APK has NO dev URLs and NO mock bypass code in the final binary
Tree-shaking guarantees production safety
Because _env is a compile-time constant, the Dart compiler evaluates all if (ApiConfig.isDev) branches at build time. In a prod build, these are dead code and get stripped. There is no runtime way to force mock mode or switch to a dev URL.

4 Layered Architecture

The app follows a clean layered architecture with strict unidirectional data flow. Each layer has a single responsibility and depends only on layers below it.

Presentation Layer lib/screens/ · lib/widgets/ 16 Stage Screens Reusable Widgets LoadingOverlay SessionTimer JourneyProgressBar Service Layer lib/services/ ApiService (60+ methods) StorageService (singleton) AnalyticsService (stub) Domain Layer (Models) lib/models/ Lead PanResult BankResult OtpResult Nominee ApiResponse<T> Configuration Layer lib/config/ api_config.dart routes.dart theme.dart assets.dart
Unidirectional Dependency
Screens depend on Services. Services depend on Models. Models depend on nothing. No circular references.
Single Responsibility
Each layer has one job. Screens render UI, Services talk to backend, Models hold data, Config holds constants.

5 Code-Level Architecture Patterns

The codebase deliberately uses simple, well-known patterns that any Flutter developer can pick up. No heavyweight frameworks.

1. Layered Architecture (Screens → Services → Models)
A 4-layer separation: PresentationServiceDomainConfig. Each screen calls ApiService, gets back typed ApiResponse<T>, displays the result. No business logic in widgets.
2. Repository (Single Service Aggregator)
All 60+ API methods are consolidated in ApiService. Instead of one repository per feature (BLoC pattern), one class with named methods. Easier to discover, single point of authentication and error handling.
3. Singleton Storage
StorageService.getInstance() returns a single SharedPreferences-backed instance. All screens share the same lead/session/token state. Async-initialized once at app boot.
4. State Machine UI
Complex screens (Stage 6 Bank, Stage 13 eSign) use Dart enum view-states. Example: enum _BankViewState { preVerified, upi, manual, verified }. The build method switches on the enum to render the right UI.
5. Wrapper Widget Composition
Cross-cutting concerns (loading, session timeout, back button) are implemented as wrapper widgets that screens compose: BackButtonHandler > SessionTimer > LoadingOverlay > ScreenContent. No mixins, no inheritance.
6. Result Type (ApiResponse<T>)
Every API call returns ApiResponse<T> with success, data, errorCode, displayMessage. No exceptions for control flow. Screens check result.success and act accordingly.
7. Build-Time Environment Config
String.fromEnvironment('ENV') reads --dart-define=ENV=prod at build time. Compile-time constant, tree-shakeable, no runtime config files. Three envs: dev/uat/prod.
8. Backend-Driven Mock Detection
Flutter doesn't know about mock mode. Backend sets GlobalMockEnabled=true and returns redirect URLs containing example.com. Flutter detects these URLs and skips external launches in dev only (guarded by ApiConfig.isDev).

6 State Management - Why Vanilla setState?

The new app uses plain setState instead of BLoC, Provider, Riverpod, or any state management library. Here's why and how it's structured.

StatefulWidget Stage6BankScreen Local State _isLoading, _viewState, _verifiedBank Event Handlers _onUpiSelected(), _onProceed() build() switch(_viewState) renders UI setState() triggers rebuild ApiService (stateless service) submitBankAccount() POST /api/v1/bank/account getBankDetails() GET /api/v1/bank/details Returns ApiResponse<T> success, data, errorCode StorageService (singleton) SharedPreferences leadId, sessionId, token getInstance() setLeadId() setToken() clearAll() await api.call() read/write

Why no BLoC?

AspectBLoC / ProviderVanilla setState (chosen)
Boilerplate3 files per feature (cubit, state, event)1 file per screen
Learning CurveHigh - new mental modelBuilt into Flutter
State scopeApp-wide reactive streamsLocal to widget tree
Best forComplex apps with shared state across many screensLinear journeys where each screen owns its state
eKYC fitOverkill - screens don't share state mid-journeyPerfect - each stage is isolated

7 Network Layer

Single ApiService class wraps the http package with auth, error handling, session tracking, and a typed result wrapper.

Stage Screen api.submitX(...) ApiService method _post / _get / _put + fromJson factory _post<T> helper inject headers add X-Lead-Id, Bearer http package http.post(uri, body, headers) 30s timeout JSON parse status fromJson(T) ApiResponse<T> Cross-Cutting Concerns (handled inside _post / _get) Auth: Bearer token + X-Lead-Id Session: X-Session-Id auto-inject Activity: update lastActivity Errors: ApiResponse.fail() Network: SocketException → "No internet" Body: leadId auto-injected if missing Stage: setCurrentStage() on success

Generic _post helper (simplified)

Future<ApiResponse<T>> _post<T>(
  String url,
  Map<String, dynamic> body, {
  required T Function(Map<String, dynamic>) fromJson,
}) async {
  // 1. Auto-inject leadId from storage
  body['leadId'] ??= _storage.leadId;

  try {
    final response = await http.post(
      Uri.parse(url),
      headers: _headers,                  // JWT + X-Session-Id auto-added
      body: jsonEncode(body),
    ).timeout(ApiConfig.apiTimeout);

    await _storage.updateActivity();    // session timer reset

    final json = jsonDecode(response.body);
    if (response.statusCode == 200 && json['success'] == true) {
      return ApiResponse.ok(fromJson(json['data']));
    }
    return ApiResponse.fail(json['message'], json['errorCode']);
  } on SocketException {
    return ApiResponse.fail('No internet connection');
  } catch (e) {
    return ApiResponse.fail('Something went wrong');
  }
}

9 Widget Composition

Cross-cutting concerns are wrapper widgets that compose around screen content. No mixins, no inheritance hierarchies.

BackButtonHandler — intercepts device back, shows "KYC is one-time" sheet SessionTimer — 15-min inactivity, 13-min warning dialog Scaffold (AppBar + Body) JourneyProgressBar (16 stages, % complete, striped fill) LoadingOverlay — full-screen Lottie loader when isLoading=true Screen Content (form, buttons, illustrations)

Composition pattern

return BackButtonHandler(
  child: SessionTimer(
    child: Scaffold(
      appBar: AppBar(title: const Text('Bank Verification')),
      body: LoadingOverlay(
        isLoading: _isLoading,
        message: 'Verifying...',
        child: Column(
          children: [
            const JourneyProgressBar(currentStage: 6),
            _buildBody(),
          ],
        ),
      ),
    ),
  ),
);

10 Environment & Build Config

Build-time environment selection via --dart-define. No runtime config files, no environment switchers in the UI.

// lib/config/api_config.dart
class ApiConfig {
  static const _env = String.fromEnvironment('ENV', defaultValue: 'dev');

  static String get baseUrl {
    switch (_env) {
      case 'prod': return 'https://ekyc.motilaloswal.com';
      case 'uat':  return 'https://ekycuat.motilaloswaluat.com';
      default:    return 'http://localhost:5000';
    }
  }

  static bool get isDev => _env == 'dev';
  static bool get isProd => _env == 'prod';
}
Build CommandEnvironmentBase URL
flutter run -d chromedev (default)http://localhost:5000
flutter build apk --dart-define=ENV=uatuathttps://ekycuat.motilaloswaluat.com
flutter build appbundle --dart-define=ENV=prodprodhttps://ekyc.motilaloswal.com
flutter build ios --dart-define=ENV=prodprodhttps://ekyc.motilaloswal.com
Compile-time constant = tree-shakeable
String.fromEnvironment resolves at build time. Dead code paths (e.g., dev URLs in a prod build) are removed by the Dart compiler. No risk of dev URLs leaking into prod APKs.

11 Platform Build Pipeline

Single Dart codebase → Flutter compiler → native binaries for each platform.

lib/ (Dart Source) 16 screens + services + widgets + models ~5000 lines of Dart code A Android Flutter Engine: AOT compile Dart → ARM64 / ARM32 native Build Tool: Gradle (Kotlin DSL) android/app/build.gradle Output: · APK (debug/release) · AAB (Play Store) Min SDK: 21 (Android 5.0) Targets ~98% devices i iOS Flutter Engine: AOT compile Dart → ARM64 native Build Tool: Xcode + CocoaPods ios/Runner.xcworkspace Output: · .app (simulator) · IPA (TestFlight / App Store) Min iOS: 12.0 Targets ~99% devices W Web (PWA) Flutter Engine: dart2js Dart → JavaScript / WASM Build Tool: Webpack-style bundler web/index.html Output: · Static HTML+JS bundle · Service worker (offline) Renderer: CanvasKit / HTML Modern Chrome / Safari / Edge

12 Mock Mode Strategy

The frontend is completely unaware of mock mode. All mock behavior is driven by the backend's GlobalMockEnabled flag. The frontend only detects mock URLs in responses and bypasses external launches.

Backend (.NET 8) Source of truth for mock mode appsettings.Development.json "GlobalMockEnabled": true Returns mock URLs "redirectUrl": "example.com/mock" Returns mock OTP FixedOtp: "1234" JSON Flutter App Detects mock URLs, never knows about flag if (ApiConfig.isDev && url.contains('example.com')) Skip WebView / UPI launch Auto-call success callback Prod build: guards prevent test data isDev=false → mock branch is dead code External Provider DigiLocker / Razorpay / HyperVerge Mock mode: NEVER called Backend returns mock data, Flutter bypasses redirect Live mode: real call Flutter opens redirect URL, user completes external flow
Why backend-driven, not frontend flag?
A Flutter mock flag could be tampered with at runtime (jailbreak/root). Backend-driven mock means the frontend has no way to "force mock mode" against a prod backend. Production builds compile away the dev URL guards entirely (ApiConfig.isDev is a compile-time constant).

13 Annexures: Platform Build Details

Platform-specific build configurations, commands, permissions, and plugin integration details. Click to expand each section.

A Android Build Details

Build Configuration

SettingValue
Min SDK21 (Android 5.0 Lollipop)
Target SDK34 (Android 14)
Build ToolGradle (AGP 8.x)
Kotlin Version1.9+
Java Version17 (recommended)
Architecturesarm64-v8a, armeabi-v7a, x86_64
OutputAPK / AAB (Android App Bundle)

Build Commands

# Debug APK
flutter build apk --debug

# Release APK (split per ABI)
flutter build apk --release \
  --split-per-abi \
  --dart-define=ENV=prod

# App Bundle for Play Store
flutter build appbundle --release \
  --dart-define=ENV=prod \
  --obfuscate \
  --split-debug-info=build/symbols

Native Components

Flutter generates a thin Android wrapper. Plugins that need native code (camera, geolocator, image_picker, signature, razorpay_flutter) include their own AndroidManifest entries and Gradle dependencies via the Flutter plugin system.

// android/app/build.gradle.kts
android {
    namespace = "com.motilaloswal.ekyc"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.motilaloswal.ekyc"
        minSdk = 21
        targetSdk = 34
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = true
            isShrinkResources = true
        }
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
}

Permissions (AndroidManifest.xml)

PermissionUsed By
INTERNETAPI calls
CAMERAStage 7 selfie capture
ACCESS_FINE_LOCATIONStage 0 / 7 GPS capture
READ_EXTERNAL_STORAGEStage 5 / 9 file picker
RECEIVE_SMSStage 2 OTP auto-fill (sms_autofill)
i iOS Build Details

Build Configuration

SettingValue
Min iOS12.0
Build ToolXcode 15+ / xcodebuild
Dependency MgrCocoaPods
Architecturesarm64 (device), x86_64 (simulator)
BitcodeDisabled (Apple deprecated)
Output.app / .ipa

Build Commands

# Debug build (simulator)
flutter build ios --debug --simulator

# Release IPA for TestFlight
flutter build ipa --release \
  --dart-define=ENV=prod \
  --export-options-plist=ios/ExportOptions.plist

# Open in Xcode for signing
open ios/Runner.xcworkspace

Info.plist Permissions

<key>NSCameraUsageDescription</key>
<string>Required for selfie capture and document upload</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>Required to verify your location during onboarding</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>Required to upload PAN, Aadhaar, and bank proof documents</string>

Native Plugin Integration

CocoaPods auto-installs native iOS dependencies for plugins. Run cd ios && pod install after adding new Flutter packages.

# ios/Podfile (auto-managed)
platform :ios, '12.0'

target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

14 Appendix: Functional Flow Sequences

The diagrams in this section show specific business flows (registration, OTP, payment, etc.) rather than architectural interactions. Click to expand.

▶ Show 5 functional flow sequences

3.1 - Stage 1: User Registration Flow

Happy path: user enters mobile + name, app creates lead and navigates to OTP screen.

User Stage1Registration ApiService Backend API StorageService Navigator 1. taps "Proceed" 2. validate form (regex check) 3. read sessionId 4. registrationSignup(mobile, name) 5. POST /registration/signup + JWT auth headers 6. create lead, generate OTP 7. ApiResponse<Lead> 8. setLeadId(leadId) 9. return ApiResponse 10. setState(_isLoading = false) 11. pushReplacementNamed(stage2Otp) 12. render Stage 2 OTP screen

3.2 - Stage 0: Journey Resume on App Launch

App boots, finds an active lead in storage, asks backend where to resume, and jumps to that stage.

User Stage0Arrival StorageService ApiService Backend API Navigator 1. launch app 2. show Lottie loader 3. hasActiveLead? 4. yes, leadId="abc-123" 5. getResumeState(leadId) 6. GET /registration/resume/{leadId} 7. lookup current stage 8. resumePage="BANK" 9. ApiResponse{ resumePage } 10. _resumePageToRoute("BANK") 11. pushReplacementNamed(stage6Bank)

3.3 - Stage 15: Payment Dual Path (Web vs Mobile)

Same code branches at runtime - mobile uses Razorpay SDK with backend-provided key, web opens redirect URL.

User Stage15Congrats ApiService Backend Razorpay SDK Browser/Web 1. tap "Add Fund" + amount 2. getPaymentDefaults() 3. GET /payment/defaults 4. { razorpayKey, minAmt, maxAmt } 5. payment defaults 6. initiatePayment(amount) 7. POST /payment/initiate 8. { transactionRef, redirectUrl } alt [platform check] Mobile (kIsWeb=false && razorpayKey != null): 9a. razorpay.open({ key, amount, ... }) 10a. Razorpay native checkout sheet Web (kIsWeb=true || razorpayKey == null): 9b. launchUrl(redirectUrl, externalApplication) 10b. open MOSL hosted Razorpay page

3.4 - Stage 6: Decentro Pre-Verified Bank Flow

Decentro fired at Stage 3 (background). When user reaches Stage 6, the screen shows a confirmation card instead of the entry form.

User Stage6Bank ApiService BankVerificationService BankAutoVerifyService Database Note: Decentro mobile-to-bank already fired in background after Stage 3 (Email verification) 1. navigates to Stage 6 2. getBankDetails() 3. GET /bank/details 4. SELECT * FROM bank_accounts WHERE leadId=... 5. NULL (no entry yet) 6. GetPreVerifiedDataAsync() 7. SELECT FROM bank_auto_verifies 8. Decentro record (HDFC, ...) 9. BankAutoVerify 10. { isPreVerified: true, ... } 11. BankResult 12. _viewState = preVerified 13. "We found your bank!" + Confirm/Change buttons

3.5 - Stage 5: Backend-Driven Mock Mode (DigiLocker)

Backend in mock mode returns a fake URL. Flutter detects it (only in dev build) and bypasses the WebView.

User Stage5Digilocker ApiService Backend (mock=true) DigiLocker 1. accept consent & continue 2. initiateDigilocker() 3. POST /aadhaar/digilocker/initiate 4. mock=true → return mock URL 5. { redirectUrl: "example.com/mock-digilocker" } 6. ApiResponse 7. ApiConfig.isDev && url.contains("example.com")? alt [isDev && mock URL] Yes (dev + mock URL): 8a. digilockerCallback("MOCK") 9a. POST /aadhaar/digilocker/callback 10a. mock Aadhaar data → success No (prod or real URL): 8b. open WebView with redirectUrl → user completes real flow
Lifelines stay vertical
Each component has a vertical dashed line. Time flows top-to-bottom. Solid arrows = synchronous calls, dashed arrows = returns.
alt frames show branches
When the same flow has two paths (mobile vs web, mock vs real), an alt frame splits the diagram horizontally.
eKYC 3.0 Frontend Architecture · Generated 10 April 2026 · Flutter cross-platform app
Single Dart codebase — compiled to native Android (APK/AAB), iOS (IPA), and Web (PWA)