# Flutter WebSocket Integration — Complete Guide

## Overview

Connect your Flutter app to Socket.IO `/admin` namespace to get real-time sequential IDs for:

- **Loan ID** (`DL{YYYY}{NNN}`) — e.g., DL2026030
- **Receipt No** (`NNN`) — e.g., 018, 019

Both support:
- **Peek** — read next ID without consuming
- **Consume** — take the next ID (one terminal only)
- **Broadcast** — server pushes next ID to all terminals when any terminal consumes

---

## 1. Add Dependency

```yaml
# pubspec.yaml
dependencies:
  socket_io_client: ^3.0.0
```

---

## 2. WebSocket Service (Singleton)

```dart
import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as IO;

class WebSocketService {
  static final WebSocketService _instance = WebSocketService._();
  factory WebSocketService() => _instance;
  WebSocketService._();

  IO.Socket? _socket;
  bool get isConnected => _socket?.connected ?? false;

  final _loanNextController = StreamController<String>.broadcast();
  final _receiptNextController = StreamController<String>.broadcast();

  Stream<String> get loanNextStream => _loanNextController.stream;
  Stream<String> get receiptNextStream => _receiptNextController.stream;

  // Map to track pending ack callbacks by requestId
  final Map<String, Function(Map<String, dynamic>)> _ackCallbacks = {};

  void connect({
    required String token,
    String serverUrl = 'https://dayloan.agniplay.com',
    String path = '/ws',
  }) {
    if (_socket != null && _socket!.connected) return;

    _socket = IO.io(
      '$serverUrl/admin',
      OptionBuilder()
          .setPath(path)
          .setAuth({'token': token})
          .setTransports(['websocket'])
          .disableAutoConnect()
          .build(),
    );

    _socket!.connect();

    _socket!.onConnect((_) {
      print('[WS] Connected');
    });

    _socket!.onDisconnect((_) {
      print('[WS] Disconnected');
    });

    // Listen for ack — route to the right callback by requestId
    _socket!.on('ack', (data) {
      final map = data as Map<String, dynamic>;
      final requestId = map['requestId'] as String?;
      final callback = _ackCallbacks.remove(requestId);
      if (callback != null) callback(map);
    });

    // Listen for loan_id:next broadcast (other terminals consumed)
    _socket!.on('loan_id:next', (data) {
      final map = data as Map<String, dynamic>;
      final loanId = map['data']?['loan_id'] as String?;
      if (loanId != null) _loanNextController.add(loanId);
    });

    // Listen for receipt_no:next broadcast (other terminals consumed)
    _socket!.on('receipt_no:next', (data) {
      final map = data as Map<String, dynamic>;
      final receiptNo = map['data']?['receipt_no'] as String?;
      if (receiptNo != null) _receiptNextController.add(receiptNo);
    });

    _socket!.onError((error) {
      print('[WS] Error: $error');
    });
  }

  void disconnect() {
    _socket?.disconnect();
    _socket?.dispose();
    _socket = null;
  }

  // ---- emit + register ack callback ----

  void _emit(String event, String requestId, Function(Map<String, dynamic>) onAck) {
    _ackCallbacks[requestId] = onAck;
    _socket?.emit(event, {'requestId': requestId});
  }

  void requestLoanIdNext({required String requestId, required Function(String loanId) onResult}) {
    _emit('loan_id:next', requestId, (ack) {
      final loanId = ack['data']?['loan_id'] as String?;
      if (loanId != null) onResult(loanId);
    });
  }

  void requestConsumeLoanId({
    required String requestId,
    required Function(String loanId, String nextLoanId) onResult,
  }) {
    _emit('loan_id:request', requestId, (ack) {
      final data = ack['data'] as Map<String, dynamic>?;
      final loanId = data?['loan_id'] as String?;
      final nextId = data?['next_loan_id'] as String?;
      if (loanId != null && nextId != null) onResult(loanId, nextId);
    });
  }

  void requestReceiptNoNext({required String requestId, required Function(String receiptNo) onResult}) {
    _emit('receipt_no:next', requestId, (ack) {
      final receiptNo = ack['data']?['receipt_no'] as String?;
      if (receiptNo != null) onResult(receiptNo);
    });
  }

  // DO NOT USE requestConsumeReceiptNo in normal flow.
  // Using receipt_no:request before PHP save causes number gaps.
  // Correct flow: peek → PHP save → PHP auto-broadcasts next via internal endpoint.

  void dispose() {
    _loanNextController.close();
    _receiptNextController.close();
    disconnect();
  }
}
```

---

## 3. New Loan Screen (ViewModel)

```dart
import 'dart:async';

class NewLoanScreen extends StatefulWidget {
  final String token;
  const NewLoanScreen({required this.token, Key? key}) : super(key: key);

  @override
  _NewLoanScreenState createState() => _NewLoanScreenState();
}

class _NewLoanScreenState extends State<NewLoanScreen> {
  final _ws = WebSocketService();

  String? _nextLoanId;
  String? _assignedLoanId;
  StreamSubscription? _loanSub;

  @override
  void initState() {
    super.initState();
    _ws.connect(token: widget.token);

    // Listen for broadcasts from other terminals
    _loanSub = _ws.loanNextStream.listen((loanId) {
      setState(() => _nextLoanId = loanId);
    });

    // Get initial next loan ID
    _fetchNext();
  }

  void _fetchNext() {
    _ws.requestLoanIdNext(
      requestId: 'loan_fetch_${DateTime.now().millisecondsSinceEpoch}',
      onResult: (loanId) {
        setState(() => _nextLoanId = loanId);
      },
    );
  }

  void _onSave() {
    // 1. Consume the loan ID via WebSocket
    _ws.requestConsumeLoanId(
      requestId: 'loan_save_${DateTime.now().millisecondsSinceEpoch}',
      onResult: (loanId, nextId) {
        setState(() {
          _assignedLoanId = loanId;
          _nextLoanId = nextId;
        });

        // 2. Now call the PHP API to actually save the loan
        _saveToApi(loanId);
      },
    );
  }

  Future<void> _saveToApi(String loanId) async {
    // POST to https://dayloanphp.agniplay.com/loan.php
    // Include loanId in the form data
    // On success, navigate away or show confirmation
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('New Loan')),
      body: Column(
        children: [
          // Show assigned loan ID (after save)
          if (_assignedLoanId != null)
            Container(
              padding: EdgeInsets.all(16),
              color: Colors.green.shade100,
              width: double.infinity,
              child: Text(
                'Assigned: $_assignedLoanId',
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),

          // Show next available loan ID
          if (_assignedLoanId == null)
            Container(
              padding: EdgeInsets.all(16),
              color: Colors.blue.shade50,
              width: double.infinity,
              child: Text(
                'Next: $_nextLoanId',
                style: TextStyle(fontSize: 18),
              ),
            ),

          // Your loan form fields here...
          Expanded(child: Container()),

          // Save button
          ElevatedButton(
            onPressed: _assignedLoanId == null ? _onSave : null,
            child: Text('Save Loan'),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _loanSub?.cancel();
    // Don't disconnect WS — other screens need it
    super.dispose();
  }
}
```

---

## 4. Receipt Screen (ViewModel)

```dart
import 'dart:async';

class ReceiptScreen extends StatefulWidget {
  final String token;
  const ReceiptScreen({required this.token, Key? key}) : super(key: key);

  @override
  _ReceiptScreenState createState() => _ReceiptScreenState();
}

class _ReceiptScreenState extends State<ReceiptScreen> {
  final _ws = WebSocketService();

  String? _nextReceiptNo;
  String? _assignedReceiptNo;
  StreamSubscription? _receiptSub;

  @override
  void initState() {
    super.initState();
    _ws.connect(token: widget.token);

    // Listen for broadcasts from other terminals
    _receiptSub = _ws.receiptNextStream.listen((receiptNo) {
      setState(() => _nextReceiptNo = receiptNo);
    });

    _fetchNext();
  }

  void _fetchNext() {
    _ws.requestReceiptNoNext(
      requestId: 'receipt_fetch_${DateTime.now().millisecondsSinceEpoch}',
      onResult: (receiptNo) {
        setState(() => _nextReceiptNo = receiptNo);
      },
    );
  }

  void _onSave() {
    // Use the peeked receipt_no directly — DO NOT call receipt_no:request
    final receiptNo = _nextReceiptNo;
    if (receiptNo == null) return;

    setState(() => _assignedReceiptNo = receiptNo);

    _saveToApi(receiptNo);
    // PHP receipt.php calls /internal/receipt-created after successful insert
    // which auto-broadcasts the next receipt_no to all terminals
  }

  Future<void> _saveToApi(String receiptNo) async {
    // POST to https://dayloanphp.agniplay.com/receipt.php
    // Include receiptNo in the form data
    // On success, PHP auto-broadcasts next receipt_no via internal endpoint
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Receipt')),
      body: Column(
        children: [
          if (_assignedReceiptNo != null)
            Container(
              padding: EdgeInsets.all(16),
              color: Colors.green.shade100,
              width: double.infinity,
              child: Text(
                'Receipt #$_assignedReceiptNo',
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),

          if (_assignedReceiptNo == null)
            Container(
              padding: EdgeInsets.all(16),
              color: Colors.blue.shade50,
              width: double.infinity,
              child: Text(
                'Next Receipt: $_nextReceiptNo',
                style: TextStyle(fontSize: 18),
              ),
            ),

          Expanded(child: Container()),

          ElevatedButton(
            onPressed: _assignedReceiptNo == null ? _onSave : null,
            child: Text('Save Receipt'),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _receiptSub?.cancel();
    super.dispose();
  }
}
```

---

## 5. App Startup (connect once)

```dart
void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // Connect once at app start
  final ws = WebSocketService();
  ws.connect(token: yourJwtToken);

  runApp(MyApp());
}
```

---

## 6. Event Flow Diagrams

### Loan ID

```
Screen Opens              WS Server                   Other Terminal
    |--- loan_id:next ---->|                             |
    |<-- ack {loan_id:} ---|                             |
    |                      |                             |
User taps Save             |                             |
    |--- loan_id:request ->|                             |
    |<-- ack {loan_id:     |-- loan_id:next {loan_id:} ->|
    |   "DL2026030",       |   "DL2026031"               |
    |   next:"DL2026031"}  |                             |
    |                      |                             |
    |--- POST /loan.php -->|                             |
    |   (save to DB)       |                             |
```

### Receipt No (CORRECT flow — no request before save)

```
Screen Opens              WS Server                   Other Terminal
    |--- receipt_no:next ->|                             |
    |<-- ack {receipt_no:  |                             |
    |   "066"}             |                             |
    |                      |                             |
User taps Save             |                             |
    |                      |                             |
    |--- POST /receipt.php>|                             |
    |   (save to DB)       |-- receipt_no:next ---------->|
    |                      |   "067"                      |
    |<-- broadcast --------|                              |
    |   "067"              |                              |
```

### Receipt No (WRONG flow — causes gaps, DO NOT USE)

```
Screen Opens              WS Server                   Other Terminal
    |--- receipt_no:next ->|                             |
    |<-- ack "066"         |                             |
    |                      |                             |
User taps Save             |                             |
    |--- receipt_no:request|                             |  ← consumes 066
    |<-- ack "066","067"   |-- receipt_no:next "067" --->|  ← broadcasts
    |                      |                             |
    |--- POST /receipt.php>|  ← if this FAILS, 066 is wasted!
    |   (save FAILS)       |     next peek returns 067, gap!
```

---

## 7. API Summary

### Events

| Event | Direction | Purpose |
|-------|-----------|---------|
| `loan_id:next` | Client → Server | Peek at next loan ID (read-only) |
| `loan_id:request` | Client → Server | Consume next loan ID |
| `loan_id:next` | Server → Client | Broadcast next ID after consumption |
| `receipt_no:next` | Client → Server | Peek at next receipt number |
| `receipt_no:request` | Client → Server | **DO NOT USE** — causes gaps when PHP save fails |
| `receipt_no:next` | Server → Client | Broadcast next number after PHP insert |

### Ack Response Format

```json
// loan_id:next (peek)
{
  "type": "loan_id:next",
  "requestId": "...",
  "data": { "loan_id": "DL2026031" }
}

// loan_id:request (consume)
{
  "type": "loan_id:request",
  "requestId": "...",
  "data": { "loan_id": "DL2026031", "next_loan_id": "DL2026032" }
}

// receipt_no:next (peek)
{
  "type": "receipt_no:next",
  "requestId": "...",
  "data": { "receipt_no": "019" }
}

// receipt_no:request — DO NOT USE (see Important Notes)
```

### Broadcast Format

```json
// loan_id:next broadcast
{
  "version": 1,
  "type": "loan_id:next",
  "timestamp": 1234567890,
  "data": { "loan_id": "DL2026031" }
}

// receipt_no:next broadcast
{
  "version": 1,
  "type": "receipt_no:next",
  "timestamp": 1234567890,
  "data": { "receipt_no": "019" }
}
```

---

## 8. Important Notes

| Topic | Detail |
|-------|--------|
| **Auth** | Pass JWT token via `socket.auth.token`. Same token for PHP REST API. |
| **Namespace** | Always connect to `/admin` — default namespace is rejected. |
| **Transport** | Force `websocket` only — `setTransports(['websocket'])`. |
| **One Socket** | Create singleton, connect once at app start. All screens share it. |
| **requestId** | Use unique ID per request (e.g., `DateTime.now().millisecondsSinceEpoch`). Server echoes it in ack. |
| **Broadcasts** | When ANY terminal consumes an ID, ALL other terminals in the same company get the updated next. |
| **Race Safety** | Server uses single-threaded JS event loop — no two requests get the same number. |
| **PHP Fallback** | PHP endpoints (`loan.php`, `receipt.php`) also trigger broadcasts via internal endpoint. No extra work needed. |
| **Token Expiry** | Tokens last 500 days. Re-login required after expiry. |
| **Screen Open** | Always fetch next ID when screen opens (`loan_id:next` / `receipt_no:next`). |
| **Screen Close** | Remove stream listeners but do NOT disconnect socket — other screens need it. |
| **Receipt Flow** | **CRITICAL**: Never call `receipt_no:request` before PHP save. Use peek only → PHP save → PHP auto-broadcasts next. This prevents number gaps. |

---

## 9. Testing

```bash
# Test loan_id peek
node -e "
const { io } = require('socket.io-client');
const jwt = require('jsonwebtoken');
const JWT_SECRET = 'YOUR_SECRET';
const token = jwt.sign({ data: { uid: 200, companyId: '119', company_id: '119', user_type: 'admin' } }, JWT_SECRET, { expiresIn: '1h' });
const s = io('https://dayloan.agniplay.com/admin', { path: '/ws', auth: { token }, transports: ['websocket'] });
s.on('connect', () => s.emit('loan_id:next', { requestId: 'test' }));
s.on('ack', (p) => { console.log(p.data); s.disconnect(); process.exit(0); });
setTimeout(() => process.exit(1), 5000);
"
```
