crossplatform_flutter_vidvdockit 1.0.7
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,
);
}
}