crossplatform_flutter_vidvdockit 1.0.7 copy "crossplatform_flutter_vidvdockit: ^1.0.7" to clipboard
crossplatform_flutter_vidvdockit: ^1.0.7 copied to clipboard

VIDVDockitSdk Flutter plugin

example/lib/main.dart

import 'dart:convert';
import 'dart:io' show Platform;
import 'package:crossplatform_flutter_vidvdockit/crossplatform_flutter_vidvdockit.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;

import 'server_config.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'DocKit Plugin Test',
    theme: ThemeData(
      primarySwatch: Colors.blue,
      useMaterial3: true,
    ),
    home: const HomePage(),
  );
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  String _dockitResult = 'No result yet';
  bool _isLoading = false;

  /// Last parsed result, kept so the transaction-ID dialog can inspect it.
  VIDVDocKitResult? _lastResult;

  // ─────────────────────────────────────────────────────────────────────────────
  // Server Configuration — presets mirror the native demo's ServerConfig.kt
  // ─────────────────────────────────────────────────────────────────────────────
  ServerConfig _selectedServer = ServerConfig.stage;

  /// Custom credentials entered via the dialog, restored from disk on launch.
  ServerConfig? _customServer;

  /// Which dropdown entry is active: a preset name, or ServerConfig.customLabel.
  String _selectedServerLabel = ServerConfig.stage.name;

  // ─────────────────────────────────────────────────────────────────────────────
  // Document Type
  // ─────────────────────────────────────────────────────────────────────────────
  String _selectedDocument = 'passport';

  // ─────────────────────────────────────────────────────────────────────────────
  // Language
  // ─────────────────────────────────────────────────────────────────────────────
  String _selectedLanguage = 'en';

  // ─────────────────────────────────────────────────────────────────────────────
  // Capture Mode
  // ─────────────────────────────────────────────────────────────────────────────
  String _captureMode = 'automatic';
  final TextEditingController _autoAfterSecondsController =
  TextEditingController(text: '6');

  // ─────────────────────────────────────────────────────────────────────────────
  // Basic Options
  // ─────────────────────────────────────────────────────────────────────────────
  bool _reviewData = true;
  bool _collectUserInfo = false;
  bool _previewCapturedImage = false;

  // ─────────────────────────────────────────────────────────────────────────────
  // Capture Only Mode
  // ─────────────────────────────────────────────────────────────────────────────
  bool _captureOnlyMode = false;

  // ─────────────────────────────────────────────────────────────────────────────
  // Advanced Options (Extras)
  // ─────────────────────────────────────────────────────────────────────────────
  bool _advancedConfidence = false;
  bool _professionAnalysis = false;
  bool _documentVerificationPlus = false;
  bool _documentLiveness = false;

  // ─────────────────────────────────────────────────────────────────────────────
  // UI Customization
  // ─────────────────────────────────────────────────────────────────────────────
  bool _includePrimaryColor = false;
  final TextEditingController _primaryColorController =
  TextEditingController(text: '#FFA500');
  bool _disableLogo = false;

  // ─────────────────────────────────────────────────────────────────────────────
  // Headers
  // ─────────────────────────────────────────────────────────────────────────────
  bool _includeReferenceUserId = false;
  final TextEditingController _referenceUserIdController =
  TextEditingController(text: '');

  @override
  void dispose() {
    _autoAfterSecondsController.dispose();
    _primaryColorController.dispose();
    _referenceUserIdController.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    // Restore any custom credentials the user saved previously, matching the
    // native demo's pre-fill behaviour.
    ServerConfig.loadCustom().then((saved) {
      if (!mounted) return;
      if (saved.isComplete) setState(() => _customServer = saved);
    });
  }

  Future<String?> _getToken() async {
    final server = _selectedServer;
    final url = '${server.baseUrl}/api/o/token/';
    final resp = await http.post(
      Uri.parse(url),
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: 'username=${Uri.encodeQueryComponent(server.username)}'
          '&password=${Uri.encodeQueryComponent(server.password)}'
          '&client_id=${Uri.encodeQueryComponent(server.clientId)}'
          '&client_secret=${Uri.encodeQueryComponent(server.clientSecret)}'
          '&grant_type=password',
    );
    if (resp.statusCode == 200) {
      return json.decode(resp.body)['access_token'] as String;
    }
    debugPrint('Token request failed (${resp.statusCode}): ${resp.body}');
    return null;
  }

  Map<String, dynamic> _buildArgs(String token) {
    final args = <String, dynamic>{
      // ─── Required ───
      "base_url": _selectedServer.baseUrl,
      "access_token": token,
      "bundle_key": _selectedServer.bundleKey,

      // ─── Language ───
      "language": _selectedLanguage,

      // ─── Document Type ───
      "document_type": _selectedDocument,

      // ─── Capture Mode ───
      "capture_mode": _captureMode,
      if (_captureMode == 'auto_after')
        "auto_after_seconds":
        int.tryParse(_autoAfterSecondsController.text) ?? 6,

      // ─── Basic Options ───
      "review_data": _reviewData,
      "collect_user_info": _collectUserInfo,
      "preview_captured_image": _previewCapturedImage,

      // ─── Capture Only Mode ───
      "capture_only_mode": _captureOnlyMode,

      // ─── Advanced Options (Extras) ───
      "advanced_confidence": _advancedConfidence,
      "profession_analysis": _professionAnalysis,
      "document_verification_plus": _documentVerificationPlus,
      "document_liveness": _documentLiveness,

      // ─── UI Customization ───
      "disable_logo": _disableLogo,
      if (_includePrimaryColor) "primary_color": _primaryColorController.text,

      // ─── Headers ───
      if (_includeReferenceUserId &&
          _referenceUserIdController.text.isNotEmpty)
        "headers": {
          "X-Valify-reference-userid": _referenceUserIdController.text,
        },
    };

    return args;
  }

  Future<void> _startDocKit() async {
    setState(() {
      _dockitResult = 'Loading…';
      _isLoading = true;
    });

    try {
      final token = await _getToken();
      if (token == null) {
        setState(() {
          _dockitResult = 'Failed to get token';
          _isLoading = false;
        });
        return;
      }

      final args = _buildArgs(token);

      // Log the args for debugging
      debugPrint('DocKit Args: ${const JsonEncoder.withIndent('  ').convert(args)}');

      final result = await VIDVDocKit().startSession(args);
      if (result == null) {
        setState(() {
          _dockitResult = 'No result';
          _isLoading = false;
        });
        return;
      }

      debugPrint('DocKit transactionId: ${result.transactionId}');

      // Truncate base64 captures so the payload stays readable on screen.
      final truncated = result.captures.map((key, value) {
        if (value != null && value.length > 10) {
          return MapEntry(
            key,
            '${value.substring(0, 10)}.... [Base64 truncated for display]',
          );
        }
        return MapEntry(key, value);
      });

      final display = <String, dynamic>{
        'state': result.state.name,
        'code': result.code,
        'message': result.message,
        'step': result.step,
        'sessionID': result.sessionId,
        'deviceID': result.deviceId,
        'transactionId': result.transactionId,
        'trialsRemaining': result.trialsRemaining,
        'captures': truncated,
        'extractedData': result.extractedData,
      };

      setState(() {
        _dockitResult = const JsonEncoder.withIndent('  ').convert(display);
        _lastResult = result;
        _isLoading = false;
      });
    } on PlatformException catch (e) {
      setState(() {
        _dockitResult = 'Platform error: ${e.code} ${e.message}';
        _isLoading = false;
      });
    } catch (e, st) {
      debugPrint('Parse error: $e\n$st');
      setState(() {
        _dockitResult = 'Parse error: $e';
        _isLoading = false;
      });
    }
  }

  /// Applies a dropdown selection. Choosing "Custom Creds" opens the entry dialog;
  /// as in the native demo, cancelling or submitting an incomplete form falls back to QA.
  void _onServerSelected(String? label) {
    if (label == null) return;

    if (label == ServerConfig.customLabel) {
      _showCustomCredsDialog();
      return;
    }

    setState(() {
      _selectedServerLabel = label;
      _selectedServer =
          ServerConfig.presets.firstWhere((preset) => preset.name == label);
    });
  }

  void _fallbackToQa() {
    setState(() {
      _selectedServerLabel = ServerConfig.qa.name;
      _selectedServer = ServerConfig.qa;
    });
  }

  Future<void> _showCustomCredsDialog() async {
    final existing = _customServer;
    final controllers = <String, TextEditingController>{
      'Base URL': TextEditingController(text: existing?.baseUrl ?? ''),
      'Username': TextEditingController(text: existing?.username ?? ''),
      'Password': TextEditingController(text: existing?.password ?? ''),
      'Client ID': TextEditingController(text: existing?.clientId ?? ''),
      'Client Secret': TextEditingController(text: existing?.clientSecret ?? ''),
      'Bundle Key': TextEditingController(text: existing?.bundleKey ?? ''),
    };

    final saved = await showDialog<bool>(
      context: context,
      builder: (ctx) => StatefulBuilder(
        builder: (ctx, setDialogState) => AlertDialog(
          title: const Text('Enter Custom Credentials'),
          content: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                for (final entry in controllers.entries)
                  Padding(
                    padding: const EdgeInsets.only(bottom: 8),
                    child: TextField(
                      controller: entry.value,
                      decoration: InputDecoration(
                        labelText: entry.key,
                        isDense: true,
                        border: const OutlineInputBorder(),
                      ),
                      style: const TextStyle(fontSize: 13),
                    ),
                  ),
                Align(
                  alignment: Alignment.centerLeft,
                  child: TextButton.icon(
                    onPressed: () => setDialogState(() {
                      for (final c in controllers.values) {
                        c.clear();
                      }
                    }),
                    icon: const Icon(Icons.clear_all, size: 18),
                    label: const Text('Clear fields'),
                  ),
                ),
              ],
            ),
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(ctx, false),
              child: const Text('Cancel'),
            ),
            TextButton(
              onPressed: () => Navigator.pop(ctx, true),
              child: const Text('Save'),
            ),
          ],
        ),
      ),
    );

    final config = ServerConfig(
      name: ServerConfig.customLabel,
      baseUrl: controllers['Base URL']!.text.trim(),
      username: controllers['Username']!.text.trim(),
      password: controllers['Password']!.text.trim(),
      clientId: controllers['Client ID']!.text.trim(),
      clientSecret: controllers['Client Secret']!.text.trim(),
      bundleKey: controllers['Bundle Key']!.text.trim(),
    );

    for (final c in controllers.values) {
      c.dispose();
    }

    if (saved != true) {
      _fallbackToQa();
      return;
    }

    if (!config.isComplete) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('All fields are required')),
        );
      }
      _fallbackToQa();
      return;
    }

    await config.saveAsCustom();
    if (!mounted) return;
    setState(() {
      _customServer = config;
      _selectedServer = config;
      _selectedServerLabel = ServerConfig.customLabel;
    });
  }

  /// Shows the transaction ID plus the envelope's structural fingerprint.
  ///
  /// The key lists at the bottom are the point of this dialog: run the same flow on Android and
  /// iOS and they must match exactly. If they don't, the platforms have drifted apart again.
  void _showTransactionId() {
    final result = _lastResult;
    final platform = Platform.isAndroid ? 'Android' : (Platform.isIOS ? 'iOS' : 'Other');

    if (result == null) {
      showDialog(
        context: context,
        builder: (ctx) => AlertDialog(
          title: const Text('No result yet'),
          content: const Text('Run "Start DocKit" first, then reopen this dialog.'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(ctx),
              child: const Text('Close'),
            ),
          ],
        ),
      );
      return;
    }

    // Read the envelope's shape straight from the raw JSON rather than from the parsed model,
    // so a platform difference the model papers over is still visible here.
    Map<String, dynamic> envelope = const {};
    Map<String, dynamic> resultSection = const {};
    try {
      envelope = json.decode(result.rawJson) as Map<String, dynamic>;
      resultSection =
          (envelope['result'] as Map?)?.cast<String, dynamic>() ?? const {};
    } catch (_) {
      // Fall through with empty maps; the dialog still shows the parsed values.
    }

    final txId = result.transactionId;

    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: Row(
          children: [
            Icon(
              txId != null ? Icons.check_circle : Icons.info_outline,
              color: txId != null ? Colors.green : Colors.orange,
            ),
            const SizedBox(width: 8),
            const Text('Transaction ID'),
          ],
        ),
        content: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: txId != null ? Colors.green[50] : Colors.orange[50],
                  borderRadius: BorderRadius.circular(8),
                  border: Border.all(
                    color: txId != null ? Colors.green[200]! : Colors.orange[200]!,
                  ),
                ),
                child: SelectableText(
                  txId ?? 'null',
                  style: const TextStyle(
                    fontFamily: 'monospace',
                    fontSize: 15,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              if (txId == null) ...[
                const SizedBox(height: 8),
                Text(
                  'Expected when using capture-only mode, the NFC/passport flow, '
                  'or when the flow did not reach OCR.',
                  style: TextStyle(fontSize: 12, color: Colors.grey[700]),
                ),
              ],
              const SizedBox(height: 16),
              _dialogRow('Platform', platform),
              _dialogRow('Environment', _selectedServerLabel),
              _dialogRow('state', result.state.name),
              _dialogRow('code', '${result.code}'),
              _dialogRow('message', '${result.message}'),
              _dialogRow('step', '${result.step}'),
              _dialogRow('sessionID', result.sessionId ?? 'null'),
              _dialogRow('deviceID', result.deviceId ?? 'null'),
              _dialogRow('trialsRemaining', '${result.trialsRemaining}'),
              _dialogRow('captures', result.captures.keys.join(', ')),
              const Divider(height: 24),
              Text(
                'Envelope shape — must be identical on both platforms',
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.bold,
                  color: Colors.grey[800],
                ),
              ),
              const SizedBox(height: 8),
              _dialogRow('top-level', envelope.keys.join(', ')),
              _dialogRow('result.*', resultSection.keys.join(', ')),
              _dialogRow(
                'extractedData.*',
                (result.extractedData?.keys.join(', ') ?? 'null'),
              ),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () {
              Clipboard.setData(ClipboardData(text: result.rawJson));
              Navigator.pop(ctx);
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('Raw JSON copied')),
              );
            },
            child: const Text('Copy raw JSON'),
          ),
          if (txId != null)
            TextButton(
              onPressed: () {
                Clipboard.setData(ClipboardData(text: txId));
                Navigator.pop(ctx);
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('Transaction ID copied')),
                );
              },
              child: const Text('Copy ID'),
            ),
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  Widget _credRow(String label, String value) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(
          width: 84,
          child: Text(
            label,
            style: TextStyle(
              fontSize: 11,
              color: Colors.blueGrey[700],
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
        Expanded(
          child: Text(
            value.isEmpty ? '—' : value,
            style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
            overflow: TextOverflow.ellipsis,
          ),
        ),
      ],
    ),
  );

  Widget _dialogRow(String label, String value) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 3),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(
          width: 110,
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12,
              color: Colors.grey[700],
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
        Expanded(
          child: SelectableText(
            value.isEmpty ? '—' : value,
            style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
          ),
        ),
      ],
    ),
  );

  void _showSampleConfig() {
    const sampleConfig = '''
// ═══════════════════════════════════════════════════════════════════════════════
// VIDV DocKit Flutter Plugin - Complete Configuration Reference
// ═══════════════════════════════════════════════════════════════════════════════

final args = <String, dynamic>{
  // ─────────────────────────────────────────────────────────────────────────────
  // REQUIRED PARAMETERS
  // ─────────────────────────────────────────────────────────────────────────────
  
  "base_url": "https://api.example.com",      // API base URL
  "access_token": "your_oauth_token",         // OAuth access token
  "bundle_key": "your_bundle_key",            // Bundle key from dashboard
  
  // ─────────────────────────────────────────────────────────────────────────────
  // LANGUAGE
  // ─────────────────────────────────────────────────────────────────────────────
  
  "language": "en",                           // "en" | "ar" | "fr"
  
  // ─────────────────────────────────────────────────────────────────────────────
  // DOCUMENT TYPE
  // ─────────────────────────────────────────────────────────────────────────────
  
  "document_type": "passport",                // "passport" | "egyNID" | "tunNID" | "dzaNID"
  
  // ─────────────────────────────────────────────────────────────────────────────
  // CAPTURE MODE
  // ─────────────────────────────────────────────────────────────────────────────
  
  "capture_mode": "automatic",                // "manual" | "automatic" | "auto_after"
  "auto_after_seconds": 6,                    // Required when capture_mode is "auto_after"
                                              // Minimum value: 1
  
  // ─────────────────────────────────────────────────────────────────────────────
  // BASIC OPTIONS
  // ─────────────────────────────────────────────────────────────────────────────
  
  "review_data": true,                        // Show review screen after capture
  "collect_user_info": false,                 // Collect additional user information
  "preview_captured_image": false,            // Show preview of captured image
  
  // ─────────────────────────────────────────────────────────────────────────────
  // CAPTURE ONLY MODE
  // ─────────────────────────────────────────────────────────────────────────────
  
  "capture_only_mode": false,                 // Only capture, skip OCR processing
  
  // ─────────────────────────────────────────────────────────────────────────────
  // ADVANCED OPTIONS (EXTRAS)
  // ─────────────────────────────────────────────────────────────────────────────
  
  "advanced_confidence": false,               // Enable advanced confidence scoring
  "profession_analysis": false,               // Enable profession analysis
  "document_verification_plus": false,        // Enable enhanced document verification
  "document_liveness": false,                 // Enable document liveness detection
  
  // Alternative: Pass extras as a map
  "extras": {
    "advancedConfidence": true,
    "professionAnalysis": true,
    "documentVerificationPlus": true,
    "documentLiveness": true,
    "customKey": "customValue",               // Any additional custom extras
  },
  
  // ─────────────────────────────────────────────────────────────────────────────
  // UI CUSTOMIZATION
  // ─────────────────────────────────────────────────────────────────────────────
  
  "primary_color": "#FFA500",                 // Hex color string (e.g., "#FF5722")
  "disable_logo": false,                      // Hide the logo
  "custom_logo": "base64_encoded_image",      // Custom logo as base64 string
  
  // ─────────────────────────────────────────────────────────────────────────────
  // HEADERS
  // ─────────────────────────────────────────────────────────────────────────────
  
  "headers": {
    "X-Valify-reference-userid": "user123",   // Reference user ID
    "Custom-Header": "value",                 // Any custom headers
  },
  
  // ─────────────────────────────────────────────────────────────────────────────
  // SSL CERTIFICATE (Optional)
  // ─────────────────────────────────────────────────────────────────────────────
  
  "ssl_certificate": "base64_encoded_cert",   // SSL certificate as base64 PEM/DER
};

// ─────────────────────────────────────────────────────────────────────────────
// USAGE
// ─────────────────────────────────────────────────────────────────────────────

final result = await VIDVDocKit().start(args);

// ─────────────────────────────────────────────────────────────────────────────
// RESPONSE STRUCTURE
// ─────────────────────────────────────────────────────────────────────────────

// Android and iOS emit an identical envelope. Every key is always present;
// unused ones are null. Prefer VIDVDocKit().startSession(), which parses this
// into a typed VIDVDocKitResult.

// Success Response:
{
  "state": "SUCCESS",
  "code": null,
  "message": null,
  "step": null,
  "result": {
    "sessionID": "…",
    "deviceID": "…",
    "transactionId": "…",       // promoted from extractedData.transaction_id
    "hmacData": null,           // iOS only
    "captures": {
      "front": "base64_image...",
      "back": "base64_image..."
    },
    "extractedData": {
      "result": {               // the document fields live here
        "first_name": "John",
        "last_name": "Doe",
        "birth_date": "1990-01-01"
        // ... other fields
      },
      "transaction_id": "…",
      "trials_remaining": 3
    }
  }
}

// Exit Response (user cancelled) — result may be null if the user quit early:
{
  "state": "EXIT",
  "code": null,
  "message": null,
  "step": "capturing",
  "result": { ... }
}

// Error Response (SDK misconfigured):
{
  "state": "ERROR",
  "code": 1001,
  "message": "Error description",
  "step": null,
  "result": null
}

// Service Failure Response:
{
  "state": "FAILURE",
  "code": 500,
  "message": "Service failure description",
  "step": null,
  "result": { ... }
}
''';

    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Configuration Reference'),
        content: SizedBox(
          width: double.maxFinite,
          child: SingleChildScrollView(
            child: SelectableText(
              sampleConfig,
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 11,
              ),
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () {
              Clipboard.setData(ClipboardData(text: sampleConfig));
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('Copied to clipboard!')),
              );
            },
            child: const Text('Copy'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext ctx) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DocKit Plugin Test'),
        actions: [
          IconButton(
            icon: const Icon(Icons.code),
            tooltip: 'Show Sample Config',
            onPressed: _showSampleConfig,
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // ═══════════════════════════════════════════════════════════════════
            // CREDENTIALS
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Credentials'),
            DropdownButtonFormField<String>(
              initialValue: _selectedServerLabel,
              decoration: const InputDecoration(
                labelText: 'Environment',
                isDense: true,
                border: OutlineInputBorder(),
              ),
              items: [
                for (final preset in ServerConfig.presets)
                  DropdownMenuItem(value: preset.name, child: Text(preset.name)),
                const DropdownMenuItem(
                  value: ServerConfig.customLabel,
                  child: Text(ServerConfig.customLabel),
                ),
              ],
              onChanged: _onServerSelected,
            ),
            const SizedBox(height: 8),
            Container(
              width: double.infinity,
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: Colors.blue[50],
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.blue[100]!),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _credRow('base_url', _selectedServer.baseUrl),
                  _credRow('bundle_key', _selectedServer.bundleKey),
                  _credRow('username', _selectedServer.username),
                ],
              ),
            ),
            if (_selectedServerLabel == ServerConfig.customLabel)
              Align(
                alignment: Alignment.centerLeft,
                child: TextButton.icon(
                  onPressed: _showCustomCredsDialog,
                  icon: const Icon(Icons.edit, size: 18),
                  label: const Text('Edit custom credentials'),
                ),
              ),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // DOCUMENT TYPE
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Document Type'),
            _buildRadioOption('Passport', 'passport', _selectedDocument,
                    (val) => setState(() => _selectedDocument = val!)),
            _buildRadioOption('Egyptian NID', 'egyNID', _selectedDocument,
                    (val) => setState(() => _selectedDocument = val!)),
            _buildRadioOption('Tunisian NID', 'tunNID', _selectedDocument,
                    (val) => setState(() => _selectedDocument = val!)),
            _buildRadioOption('Algerian NID', 'dzaNID', _selectedDocument,
                    (val) => setState(() => _selectedDocument = val!)),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // LANGUAGE
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Language'),
            _buildRadioOption('English', 'en', _selectedLanguage,
                    (val) => setState(() => _selectedLanguage = val!)),
            _buildRadioOption('Arabic', 'ar', _selectedLanguage,
                    (val) => setState(() => _selectedLanguage = val!)),
            _buildRadioOption('French', 'fr', _selectedLanguage,
                    (val) => setState(() => _selectedLanguage = val!)),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // CAPTURE MODE
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Capture Mode'),
            _buildRadioOption('Manual', 'manual', _captureMode,
                    (val) => setState(() => _captureMode = val!)),
            _buildRadioOption('Automatic', 'automatic', _captureMode,
                    (val) => setState(() => _captureMode = val!)),
            _buildRadioOption('Auto After (seconds)', 'auto_after', _captureMode,
                    (val) => setState(() => _captureMode = val!)),
            if (_captureMode == 'auto_after')
              Padding(
                padding: const EdgeInsets.only(left: 48, right: 16, bottom: 8),
                child: TextField(
                  controller: _autoAfterSecondsController,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(
                    labelText: 'Seconds',
                    hintText: 'e.g., 6',
                    border: OutlineInputBorder(),
                    isDense: true,
                  ),
                ),
              ),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // BASIC OPTIONS
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Basic Options'),
            _buildCheckbox('Review Data', _reviewData,
                    (val) => setState(() => _reviewData = val!)),
            _buildCheckbox('Collect User Info', _collectUserInfo,
                    (val) => setState(() => _collectUserInfo = val!)),
            _buildCheckbox('Preview Captured Image', _previewCapturedImage,
                    (val) => setState(() => _previewCapturedImage = val!)),
            _buildCheckbox('Capture Only Mode', _captureOnlyMode,
                    (val) => setState(() => _captureOnlyMode = val!)),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // ADVANCED OPTIONS (EXTRAS)
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Advanced Options (Extras)'),
            _buildCheckbox('Advanced Confidence', _advancedConfidence,
                    (val) => setState(() => _advancedConfidence = val!)),
            _buildCheckbox('Profession Analysis', _professionAnalysis,
                    (val) => setState(() => _professionAnalysis = val!)),
            _buildCheckbox('Document Verification Plus', _documentVerificationPlus,
                    (val) => setState(() => _documentVerificationPlus = val!)),
            _buildCheckbox('Document Liveness', _documentLiveness,
                    (val) => setState(() => _documentLiveness = val!)),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // UI CUSTOMIZATION
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('UI Customization'),
            _buildCheckbox('Disable Logo', _disableLogo,
                    (val) => setState(() => _disableLogo = val!)),
            _buildCheckbox('Custom Primary Color', _includePrimaryColor,
                    (val) => setState(() => _includePrimaryColor = val!)),
            if (_includePrimaryColor)
              Padding(
                padding: const EdgeInsets.only(left: 48, right: 16, bottom: 8),
                child: TextField(
                  controller: _primaryColorController,
                  decoration: const InputDecoration(
                    labelText: 'Color Hex',
                    hintText: '#FFA500',
                    border: OutlineInputBorder(),
                    isDense: true,
                  ),
                ),
              ),

            const Divider(height: 32),

            // ═══════════════════════════════════════════════════════════════════
            // HEADERS
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Headers'),
            _buildCheckbox('Include Reference User ID', _includeReferenceUserId,
                    (val) => setState(() => _includeReferenceUserId = val!)),
            if (_includeReferenceUserId)
              Padding(
                padding: const EdgeInsets.only(left: 48, right: 16, bottom: 8),
                child: TextField(
                  controller: _referenceUserIdController,
                  decoration: const InputDecoration(
                    labelText: 'Reference User ID',
                    hintText: 'user123',
                    border: OutlineInputBorder(),
                    isDense: true,
                  ),
                ),
              ),

            const SizedBox(height: 24),

            // ═══════════════════════════════════════════════════════════════════
            // ACTION BUTTONS
            // ═══════════════════════════════════════════════════════════════════
            Row(
              children: [
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: _isLoading ? null : _startDocKit,
                    icon: _isLoading
                        ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                        : const Icon(Icons.play_arrow),
                    label: Text(_isLoading ? 'Loading...' : 'Start DocKit'),
                    style: ElevatedButton.styleFrom(
                      padding: const EdgeInsets.symmetric(vertical: 16),
                    ),
                  ),
                ),
                const SizedBox(width: 12),
                ElevatedButton.icon(
                  onPressed: _showSampleConfig,
                  icon: const Icon(Icons.description),
                  label: const Text('Docs'),
                  style: ElevatedButton.styleFrom(
                    padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
                  ),
                ),
              ],
            ),

            const SizedBox(height: 12),

            SizedBox(
              width: double.infinity,
              child: ElevatedButton.icon(
                onPressed: _showTransactionId,
                icon: const Icon(Icons.receipt_long),
                label: Text(
                  _lastResult == null
                      ? 'Show Transaction ID'
                      : 'Show Transaction ID  •  ${_lastResult!.transactionId ?? "null"}',
                  overflow: TextOverflow.ellipsis,
                ),
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  backgroundColor: Colors.indigo,
                  foregroundColor: Colors.white,
                ),
              ),
            ),

            const SizedBox(height: 24),

            // ═══════════════════════════════════════════════════════════════════
            // RESULT
            // ═══════════════════════════════════════════════════════════════════
            _buildSectionHeader('Result'),
            Container(
              width: double.infinity,
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.grey[100],
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.grey[300]!),
              ),
              child: SelectableText(
                _dockitResult,
                style: const TextStyle(
                  fontFamily: 'monospace',
                  fontSize: 12,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSectionHeader(String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Text(
        title,
        style: const TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.bold,
          color: Colors.blue,
        ),
      ),
    );
  }

  Widget _buildRadioOption<T>(
      String title,
      T value,
      T groupValue,
      ValueChanged<T?> onChanged,
      ) {
    return RadioListTile<T>(
      title: Text(title),
      value: value,
      groupValue: groupValue,
      onChanged: onChanged,
      dense: true,
      contentPadding: EdgeInsets.zero,
    );
  }

  Widget _buildCheckbox(
      String title,
      bool value,
      ValueChanged<bool?> onChanged,
      ) {
    return CheckboxListTile(
      title: Text(title),
      value: value,
      onChanged: onChanged,
      dense: true,
      contentPadding: EdgeInsets.zero,
      controlAffinity: ListTileControlAffinity.leading,
    );
  }
}