buyme_on_device 0.1.5
buyme_on_device: ^0.1.5 copied to clipboard
A BuyMe On-Device SDK for Flutter.
example/lib/main.dart
import 'dart:convert';
import 'dart:io';
import 'package:buyme_on_device/buyme_on_device.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BuyMe On-Device Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F2738)),
useMaterial3: true,
),
home: const ExampleHomePage(),
);
}
}
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({super.key, this.initialUploadResponses = const []});
/// Dữ liệu khởi tạo phục vụ demo/widget test; luồng thật tiếp tục nhận dữ
/// liệu trực tiếp từ callback onImageUploaded.
final List<Map<String, dynamic>> initialUploadResponses;
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
final List<_UploadedImageItem> _uploadedImages = [];
final List<CapturedPhotoData> _localPhotos = [];
// General Config
final _apiTokenController = TextEditingController(text: '');
final _documentIdController = TextEditingController(text: '');
final _documentNameController = TextEditingController(text: 'Test Claim');
final _customDomainController = TextEditingController(
text: 'https://stage.api.aicycle.ai',
);
BuyMeEnvironment _environment = BuyMeEnvironment.stage;
AiCycleOrg _organization = AiCycleOrg.aicycle;
bool _loggingEnabled = true;
bool _savePhoto = true;
bool _alwaysCache = false;
bool _debugMode = false;
// Car Information
final _companyNameController = TextEditingController(text: 'toyota');
final _modelNameController = TextEditingController(text: 'vios');
final _mYearController = TextEditingController(text: '2022');
final _vVersionController = TextEditingController(text: '1.5C');
final _lPlateController = TextEditingController(text: '30A12345');
final _vTypeController = TextEditingController(text: 'sedan');
final _colorController = TextEditingController(text: '#A2A8A1');
final _garageIdController = TextEditingController(text: '');
final _vehicleBrandIdController = TextEditingController(text: '');
// Model Config
// Ngưỡng riêng cho từng model (để trống = dùng mặc định của SDK).
final _carPartConfController = TextEditingController(text: '0.5');
final _carPartIouController = TextEditingController(text: '0.45');
final _carCornerConfController = TextEditingController(text: '0.3');
final _carQualityConfController = TextEditingController(text: '0.3');
final _licensePlateConfController = TextEditingController(text: '0.5');
final _blurThresholdController = TextEditingController(text: '30');
final _sharpnessGradeThresholdController = TextEditingController(text: '3');
final _blurScoreThresholdController = TextEditingController(text: '10');
final _brightThresholdController = TextEditingController(text: '85');
final _glareThresholdController = TextEditingController(text: '0.18');
// VBI Config (chỉ bắt buộc khi Organization = vbi)
final _vbiApiVersionCodeController = TextEditingController(text: 'v1');
final _vbiApiUploadBaseUrlController =
TextEditingController(text: 'https://uat-api-mobile.evbi.vn');
final _vbiAuthorityIdController = TextEditingController(text: '');
final _vbiSignatureKeyController = TextEditingController(text: '');
final _vbiSoIdController = TextEditingController(text: '');
final _vbiSoIdDtController = TextEditingController(text: '');
final _vbiExternalSessionIdController = TextEditingController(text: '');
final _vbiDirectionSlugController =
TextEditingController(text: '45-trai-truoc-C1xM02');
final _vbiExternalUsedCarController = TextEditingController(text: '');
final _vbiLocationController = TextEditingController(text: '');
bool _vbiIsValidate = true;
// Display Config
bool _showBackButton = true;
// Validate Config
CaptureAngleMode _angleMode = CaptureAngleMode.sixAngles;
bool _requireFullAnglePanoramicPhotos = false;
bool _requireLicensePlate = true;
final _minCarSpanController = TextEditingController(text: '0.35');
final _maxCarSpanController = TextEditingController(text: '0.85');
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_uploadedImages.addAll(
widget.initialUploadResponses.map(_UploadedImageItem.fromResponse),
);
_documentIdController.addListener(_onDocumentIdChanged);
_restoreLocalPhotosFromDisk();
}
void _onDocumentIdChanged() {
_restoreLocalPhotosFromDisk();
}
Future<void> _restoreLocalPhotosFromDisk() async {
final docId = _documentIdController.text.trim();
if (docId.isEmpty) {
if (_localPhotos.isNotEmpty && mounted) {
setState(_localPhotos.clear);
}
return;
}
try {
final photoPaths = await BuyMeOnDevice.getCapturedPhotos(docId);
if (!mounted) return;
if (photoPaths.isEmpty) {
if (_localPhotos.isNotEmpty) {
setState(_localPhotos.clear);
}
return;
}
final restored = <CapturedPhotoData>[];
for (final entry in photoPaths.entries) {
final angleId = entry.key;
for (var i = 0; i < entry.value.length; i++) {
final filePath = entry.value[i];
if (!File(filePath).existsSync()) continue;
restored.add(CapturedPhotoData(
documentId: docId,
angleId: angleId,
filePath: filePath,
thumbnailPath:
BuyMeOnDevice.thumbnailPathForPhotoPath(filePath),
photoIndex: i,
capturedAt: File(filePath).lastModifiedSync(),
));
}
}
if (mounted) {
setState(() {
_localPhotos.clear();
_localPhotos.addAll(restored);
});
}
} catch (_) {}
}
@override
void dispose() {
_documentIdController.removeListener(_onDocumentIdChanged);
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('BuyMe On-Device Settings'),
centerTitle: true,
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
bottom: TabBar(
controller: _tabController,
tabs: [
const Tab(icon: Icon(Icons.tune), text: 'Cấu hình'),
Tab(
icon: const Icon(Icons.photo_library_outlined),
text: _localPhotos.isEmpty
? 'Ảnh local'
: 'Ảnh local (${_localPhotos.length})',
),
Tab(
icon: const Icon(Icons.cloud_done_outlined),
text: _uploadedImages.isEmpty
? 'Ảnh đã tải'
: 'Ảnh đã tải (${_uploadedImages.length})',
),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('General Configuration'),
_textField('API Token', _apiTokenController, isRequired: true),
_textField(
'Document ID',
_documentIdController,
isRequired: true,
),
_textField('Document Name', _documentNameController),
_textField('Custom Domain', _customDomainController),
_dropdown<BuyMeEnvironment>(
'Environment',
_environment,
BuyMeEnvironment.values,
(val) => setState(() => _environment = val!),
),
_dropdown<AiCycleOrg>(
'Organization',
_organization,
AiCycleOrg.values,
(val) => setState(() => _organization = val!),
isRequired: true,
),
_switchTile(
'Enable Logging',
_loggingEnabled,
(val) => setState(() => _loggingEnabled = val),
),
_switchTile(
'Save Photo',
_savePhoto,
(val) => setState(() => _savePhoto = val),
),
_switchTile(
'Always Cache',
_alwaysCache,
(val) => setState(() => _alwaysCache = val),
),
_switchTile(
'Debug mode (gallery inject)',
_debugMode,
(val) => setState(() => _debugMode = val),
),
if (_organization == AiCycleOrg.vbi) ...[
const Divider(height: 32),
_sectionTitle('VBI Configuration'),
_textField(
'API Upload Base URL',
_vbiApiUploadBaseUrlController,
isRequired: true,
),
_textField(
'API Version Code',
_vbiApiVersionCodeController,
isRequired: true,
),
_textField(
'Authority ID',
_vbiAuthorityIdController,
),
_textField(
'Signature Key',
_vbiSignatureKeyController,
),
_textField(
'SoId (Mã hồ sơ)',
_vbiSoIdController,
),
_textField(
'SoIdDt (Mã đối tượng)',
_vbiSoIdDtController,
),
_textField(
'External Session ID',
_vbiExternalSessionIdController,
),
_textField(
'Default Direction Slug',
_vbiDirectionSlugController,
),
_switchTile(
'Is Validate',
_vbiIsValidate,
(val) => setState(() => _vbiIsValidate = val),
),
_textField(
'External Used Car',
_vbiExternalUsedCarController,
),
_textField(
'Location (lat,long)',
_vbiLocationController,
),
],
const Divider(height: 32),
_sectionTitle('Car Information'),
_textField('Company Name', _companyNameController),
_textField('Model Name', _modelNameController),
_textField(
'Manufacturing Year',
_mYearController,
isNumber: true,
),
_textField('Vehicle Version', _vVersionController),
_textField('License Plate', _lPlateController),
_textField('Vehicle Type', _vTypeController),
_textField('Color (Hex, e.g., #FFFFFF)', _colorController),
_textField('Garage ID', _garageIdController),
_textField('Vehicle Brand ID', _vehicleBrandIdController),
const Divider(height: 32),
_sectionTitle('Model Configuration'),
_textField(
'Car Part Conf Threshold (0.0 - 1.0)',
_carPartConfController,
isNumber: true,
),
_textField(
'Car Part IOU Threshold (0.0 - 1.0)',
_carPartIouController,
isNumber: true,
),
_textField(
'Car Corner Conf Threshold (0.0 - 1.0)',
_carCornerConfController,
isNumber: true,
),
_textField(
'Car Quality Conf Threshold (0.0 - 1.0)',
_carQualityConfController,
isNumber: true,
),
_textField(
'License Plate OCR Conf Threshold (0.0 - 1.0)',
_licensePlateConfController,
isNumber: true,
),
_textField(
'Laplacian Blur Threshold (>= 0.0)',
_blurThresholdController,
isNumber: true,
),
_textField(
'Sharpness Grade Threshold (Blur Advanced [0])',
_sharpnessGradeThresholdController,
isNumber: true,
),
_textField(
'Blur Score Threshold (Blur Advanced [1])',
_blurScoreThresholdController,
isNumber: true,
),
_textField(
'Bright Threshold (0.0 - 255.0)',
_brightThresholdController,
isNumber: true,
),
_textField(
'Glare Threshold (0.0 - 1.0)',
_glareThresholdController,
isNumber: true,
),
const Divider(height: 32),
_sectionTitle('Display Configuration'),
_switchTile(
'Show Back Button',
_showBackButton,
(val) => setState(() => _showBackButton = val),
),
const Divider(height: 32),
_sectionTitle('Validate Configuration'),
_dropdown<CaptureAngleMode>(
'Angle Mode (Chế độ góc chụp)',
_angleMode,
CaptureAngleMode.values,
(val) => setState(() => _angleMode = val!),
itemLabel: (item) {
switch (item) {
case CaptureAngleMode.sixAngles:
return '6 góc: Trước, Sau & 4 chéo';
case CaptureAngleMode.sixAnglesWithSides:
return '6 góc: Sườn T/P & 4 chéo';
case CaptureAngleMode.eightAngles:
return '8 góc: Trước, Sau, Sườn & 4 chéo';
}
},
),
_switchTile(
'Require full-angle panoramic photos (${_angleMode.totalSegments} góc)',
_requireFullAnglePanoramicPhotos,
(val) =>
setState(() => _requireFullAnglePanoramicPhotos = val),
),
_switchTile(
'Require license plate',
_requireLicensePlate,
(val) => setState(() => _requireLicensePlate = val),
),
_textField(
'Min Car Span Threshold (0.0 - 1.0)',
_minCarSpanController,
isNumber: true,
),
_textField(
'Max Car Span Threshold (0.0 - 1.0)',
_maxCarSpanController,
isNumber: true,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _startSdk,
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
backgroundColor: const Color(0xFF1F2738),
foregroundColor: Colors.white,
),
child: const Text('START AI INSPECTION'),
),
const SizedBox(height: 32),
],
),
),
_buildLocalPhotosTab(),
_buildUploadedImagesTab(),
],
),
);
}
void _handlePhotoCaptured(CapturedPhotoData photo) {
debugPrint(
'[AppHost] onPhotoCaptured callback -> absolutePath: ${photo.filePath} (angleId: ${photo.angleId}, photoIndex: ${photo.photoIndex})',
);
if (!mounted) return;
setState(() {
final index = _localPhotos.indexWhere(
(p) => p.angleId == photo.angleId && p.photoIndex == photo.photoIndex,
);
if (index >= 0) {
_localPhotos[index] = photo;
} else {
_localPhotos.add(photo);
}
});
}
String _angleNameOf(int angleId) {
if (_angleMode == CaptureAngleMode.sixAnglesWithSides) {
switch (angleId) {
case 0:
return 'Đầu phụ (Chéo trước phải)';
case 1:
return 'Sườn phải';
case 2:
return 'Đuôi phụ (Chéo sau phải)';
case 3:
return 'Đuôi lái (Chéo sau trái)';
case 4:
return 'Sườn trái';
case 5:
return 'Đầu lái (Chéo trước trái)';
default:
return 'Góc #$angleId';
}
}
if (_angleMode == CaptureAngleMode.eightAngles) {
switch (angleId) {
case 0:
return 'Đầu xe';
case 1:
return 'Đầu phụ';
case 2:
return 'Sườn phải';
case 3:
return 'Đuôi phụ';
case 4:
return 'Đuôi xe';
case 5:
return 'Đuôi lái';
case 6:
return 'Sườn trái';
case 7:
return 'Đầu lái';
default:
return 'Góc #$angleId';
}
}
switch (angleId) {
case 0:
return 'Đầu xe';
case 1:
return 'Đầu phụ';
case 2:
return 'Đuôi phụ';
case 3:
return 'Đuôi xe';
case 4:
return 'Đuôi lái';
case 5:
return 'Đầu lái';
default:
return 'Góc #$angleId';
}
}
void _confirmClearAllLocalPhotos() {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Xác nhận xoá tất cả'),
content: const Text(
'Hành động này sẽ xoá toàn bộ file ảnh local đã lưu trên máy của phiên này.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Huỷ'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () {
Navigator.of(ctx).pop();
for (final p in _localPhotos) {
try {
final f = File(p.filePath);
if (f.existsSync()) f.deleteSync();
final thumb = File(p.thumbnailPath);
if (thumb.existsSync()) thumb.deleteSync();
} catch (_) {}
}
setState(() => _localPhotos.clear());
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Đã xoá toàn bộ ảnh local')),
);
},
child: const Text('Xoá tất cả'),
),
],
),
);
}
Widget _buildLocalPhotosTab() {
if (_localPhotos.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.photo_library_outlined,
size: 56,
color: Colors.grey,
),
const SizedBox(height: 16),
const Text(
'Chưa có ảnh chụp local',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
const Text(
'Mỗi khi chụp xong một ảnh trong camera SDK, callback onPhotoCaptured sẽ gửi đường dẫn file về đây theo thời gian thực.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black54),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () => _startSdk(resumeFromLocal: false),
icon: const Icon(Icons.camera_alt),
label: const Text('Mở Camera (Chụp từ đầu)'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1F2738),
foregroundColor: Colors.white,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _restoreLocalPhotosFromDisk,
icon: const Icon(Icons.refresh),
label: const Text('Tải lại'),
),
],
),
],
),
),
);
}
final capturedAngles = _localPhotos.map((p) => p.angleId).toSet().length;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_localPhotos.length} ảnh local ($capturedAngles/${_angleMode.totalSegments} góc)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
const Text(
'Ảnh được lưu trên máy (App Host quản lý)',
style: TextStyle(fontSize: 12, color: Colors.black54),
),
],
),
),
FilledButton.icon(
onPressed: () => _startSdk(resumeFromLocal: true),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Chụp tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF1F2738),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
IconButton(
tooltip: 'Tải lại ảnh từ đĩa',
icon: const Icon(Icons.refresh),
onPressed: _restoreLocalPhotosFromDisk,
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Xoá tất cả ảnh local',
icon: const Icon(Icons.delete_sweep_outlined, color: Colors.red),
onPressed: _confirmClearAllLocalPhotos,
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth < 320 ? 1 : 2;
return GridView.builder(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.85,
),
itemCount: _localPhotos.length,
itemBuilder: (context, index) {
final photo = _localPhotos[index];
final fileExists = File(photo.filePath).existsSync();
final thumbExists = photo.thumbnailPath.isNotEmpty &&
File(photo.thumbnailPath).existsSync();
final displayFile =
thumbExists ? File(photo.thumbnailPath) : File(photo.filePath);
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
if (fileExists)
Image.file(
displayFile,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(Icons.broken_image, color: Colors.grey),
),
)
else
const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Colors.orange, size: 36),
SizedBox(height: 4),
Text('File đã bị xoá', style: TextStyle(fontSize: 11)),
],
),
),
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xA6000000),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_angleNameOf(photo.angleId)} (Góc ${photo.angleId})',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 15,
backgroundColor: const Color(0xD9F44336),
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 16,
icon: const Icon(Icons.delete, color: Colors.white),
onPressed: () {
try {
final f = File(photo.filePath);
if (f.existsSync()) f.deleteSync();
final thumb = File(photo.thumbnailPath);
if (thumb.existsSync()) thumb.deleteSync();
} catch (_) {}
setState(() => _localPhotos.removeAt(index));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Đã xoá ảnh ${_angleNameOf(photo.angleId)}',
),
duration: const Duration(seconds: 1),
),
);
},
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ảnh #${photo.photoIndex + 1} • ${_formatTime(photo.capturedAt)}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
photo.filePath.split('/').last,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10,
color: Colors.black45,
),
),
],
),
),
],
),
);
},
);
},
),
),
],
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
Widget _buildUploadedImagesTab() {
if (_uploadedImages.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_upload_outlined, size: 56, color: Colors.grey),
SizedBox(height: 16),
Text(
'Chưa có phản hồi upload',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'Ảnh tải thành công và lỗi từ onImageUploaded sẽ xuất hiện tại đây.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black54),
),
],
),
),
);
}
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
'${_uploadedImages.length} phản hồi upload',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
TextButton.icon(
onPressed: () => setState(_uploadedImages.clear),
icon: const Icon(Icons.delete_outline),
label: const Text('Xóa'),
),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth < 320 ? 1 : 2;
return GridView.builder(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.82,
),
itemCount: _uploadedImages.length,
itemBuilder: (context, index) =>
_buildUploadCard(_uploadedImages[index], index),
);
},
),
),
],
);
}
Widget _buildUploadCard(_UploadedImageItem item, int index) {
final imageUrl = item.imageUrl;
if (item.errorText.isNotEmpty) {
return Card(
margin: EdgeInsets.zero,
color: const Color(0xFFFFF2F1),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: imageUrl == null
? const Center(
child: Icon(
Icons.error_outline,
color: Colors.red,
size: 40,
),
)
: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) =>
progress == null
? child
: const Center(
child: CircularProgressIndicator(),
),
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Text(
'Không tải được ảnh từ URL',
textAlign: TextAlign.center,
),
),
),
const Positioned(
top: 8,
right: 8,
child: CircleAvatar(
radius: 11,
backgroundColor: Colors.red,
child: Icon(
Icons.priority_high,
size: 15,
color: Colors.white,
),
),
),
],
),
),
Container(
color: const Color(0xFFFFE3E1),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Text(
item.errorText,
textAlign: TextAlign.center,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFC62828),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
if (imageUrl == null) return const SizedBox.shrink();
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) => progress == null
? child
: const Center(child: CircularProgressIndicator()),
errorBuilder: (context, error, stackTrace) => const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Không tải được ảnh từ URL',
textAlign: TextAlign.center,
),
),
),
),
const Positioned(
top: 8,
right: 8,
child: CircleAvatar(
radius: 11,
backgroundColor: Color(0xFF22B968),
child: Icon(Icons.check, size: 15, color: Colors.white),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
child: Text(
item.label ?? 'Ảnh tải lên #${index + 1}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
),
);
}
void _handleImageUploaded(Map<String, dynamic> data) {
if (!mounted) return;
setState(() => _uploadedImages.add(_UploadedImageItem.fromResponse(data)));
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
);
}
Widget _textField(
String label,
TextEditingController controller, {
bool isNumber = false,
bool isRequired = false,
bool isDecimal = true,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: controller,
keyboardType: isNumber
? TextInputType.numberWithOptions(decimal: isDecimal)
: TextInputType.text,
inputFormatters: isNumber
? [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
TextInputFormatter.withFunction((oldValue, newValue) {
final newText = newValue.text.replaceAll(',', '.');
return newValue.copyWith(
text: newText,
selection: TextSelection.collapsed(
offset: newValue.selection.baseOffset,
),
);
}),
]
: null,
decoration: InputDecoration(
label: isRequired ? _richLabel(label) : Text(label),
border: const OutlineInputBorder(),
),
),
);
}
Widget _dropdown<T>(
String label,
T value,
List<T> items,
ValueChanged<T?> onChanged, {
bool isRequired = false,
String Function(T item)? itemLabel,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: DropdownButtonFormField<T>(
isExpanded: true,
initialValue: value,
decoration: InputDecoration(
label: isRequired ? _richLabel(label) : Text(label),
border: const OutlineInputBorder(),
),
items: items.map((T item) {
final text = itemLabel != null
? itemLabel(item)
: item.toString().split('.').last;
return DropdownMenuItem<T>(
value: item,
child: Text(
text,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
);
}).toList(),
onChanged: onChanged,
),
);
}
Widget _richLabel(String label) {
return RichText(
text: TextSpan(
text: label,
style: const TextStyle(color: Colors.black54, fontSize: 16),
children: const [
TextSpan(
text: ' *',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _switchTile(String title, bool value, ValueChanged<bool> onChanged) {
return SwitchListTile(
title: Text(title),
value: value,
onChanged: onChanged,
contentPadding: EdgeInsets.zero,
);
}
Future<void> _startSdk({bool resumeFromLocal = false}) async {
if (_apiTokenController.text.isEmpty ||
_documentIdController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fill all required fields')),
);
return;
}
Map<int, List<String>>? initialPhotos;
if (resumeFromLocal) {
final map = <int, List<String>>{};
for (final p in _localPhotos) {
if (File(p.filePath).existsSync()) {
map.putIfAbsent(p.angleId, () => []).add(p.filePath);
}
}
initialPhotos = map;
}
VBIConfig? vbiConfig;
if (_organization == AiCycleOrg.vbi) {
if (_vbiApiVersionCodeController.text.isEmpty ||
_vbiApiUploadBaseUrlController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Please fill required VBI fields (Base URL, Version)',
),
),
);
return;
}
vbiConfig = VBIConfig(
apiVersionCode: _vbiApiVersionCodeController.text.trim(),
apiUploadBaseUrl: _vbiApiUploadBaseUrlController.text.trim(),
authorityId: _vbiAuthorityIdController.text.trim().isEmpty
? null
: _vbiAuthorityIdController.text.trim(),
signatureKey: _vbiSignatureKeyController.text.trim().isEmpty
? null
: _vbiSignatureKeyController.text.trim(),
soId: _vbiSoIdController.text.trim().isEmpty
? null
: _vbiSoIdController.text.trim(),
soIdDt: _vbiSoIdDtController.text.trim().isEmpty
? null
: _vbiSoIdDtController.text.trim(),
externalSessionId: _vbiExternalSessionIdController.text.trim().isEmpty
? null
: _vbiExternalSessionIdController.text.trim(),
defaultDirectionSlug: _vbiDirectionSlugController.text.trim().isEmpty
? '45-trai-truoc-C1xM02'
: _vbiDirectionSlugController.text.trim(),
isValidate: _vbiIsValidate,
externalUsedCar: _vbiExternalUsedCarController.text.trim(),
location: _vbiLocationController.text.trim().isEmpty
? null
: _vbiLocationController.text.trim(),
);
}
final config = BuyMeConfig(
generalConfig: GeneralConfig(
apiToken: _apiTokenController.text,
documentId: _documentIdController.text,
documentName: _documentNameController.text,
environment: _environment,
organization: _organization,
loggingEnabled: _loggingEnabled,
savePhotoAfterShot: _savePhoto,
alwaysCache: _alwaysCache,
debugMode: _debugMode,
aicBaseUrl: _customDomainController.text.isEmpty
? null
: _customDomainController.text,
),
carInformation: CarInformation(
companyName: _companyNameController.text,
modelName: _modelNameController.text,
manufacturingYear: int.tryParse(_mYearController.text),
vehicleVersionName: _vVersionController.text,
licensePlate: _lPlateController.text,
vehicleType: _vTypeController.text,
color: _colorController.text,
garageId: _garageIdController.text,
vehicleBrandId: _vehicleBrandIdController.text,
),
modelConfig: ModelConfig(
carPartConfThreshold: double.tryParse(
_carPartConfController.text.replaceAll(',', '.'),
) ??
0.5,
carPartIouThreshold: double.tryParse(
_carPartIouController.text.replaceAll(',', '.'),
) ??
0.45,
carCornerConfThreshold: double.tryParse(
_carCornerConfController.text.replaceAll(',', '.'),
) ??
0.3,
carQualityConfThreshold: double.tryParse(
_carQualityConfController.text.replaceAll(',', '.'),
) ??
0.3,
licensePlateConfThreshold: double.tryParse(
_licensePlateConfController.text.replaceAll(',', '.'),
) ??
0.5,
blurThreshold: double.tryParse(
_blurThresholdController.text.replaceAll(',', '.'),
) ??
30.0,
blurAdvancedThresholds: [
double.tryParse(
_sharpnessGradeThresholdController.text.replaceAll(',', '.'),
) ??
3.0,
double.tryParse(
_blurScoreThresholdController.text.replaceAll(',', '.'),
) ??
10.0,
],
brightThreshold: double.tryParse(
_brightThresholdController.text.replaceAll(',', '.'),
) ??
85.0,
glareThreshold: double.tryParse(
_glareThresholdController.text.replaceAll(',', '.'),
) ??
0.18,
),
displayConfig: DisplayConfig(showBackButton: _showBackButton),
validateConfig: ValidateConfig(
angleMode: _angleMode,
requireFullAnglePanoramicPhotos: _requireFullAnglePanoramicPhotos,
requireLicensePlate: _requireLicensePlate,
minCarSpanThreshold: double.tryParse(
_minCarSpanController.text.replaceAll(',', '.'),
) ??
0.35,
maxCarSpanThreshold: double.tryParse(
_maxCarSpanController.text.replaceAll(',', '.'),
) ??
0.85,
),
vbiConfig: vbiConfig,
initialPhotos: initialPhotos,
);
final homeContext = context;
var completionRequested = false;
final cameraRoute = MaterialPageRoute<bool>(
builder: (cameraContext) => BuyMeOnDeviceCamera(
buyMeConfig: config,
onPhotoCaptured: _handlePhotoCaptured,
onImageUploaded: _handleImageUploaded,
onComplete: () {
if (!mounted || completionRequested) return;
completionRequested = true;
Navigator.of(cameraContext).pop(true);
},
onError: (error) {
if (!mounted) return;
ScaffoldMessenger.of(
homeContext,
).showSnackBar(SnackBar(content: Text('SDK Error: $error')));
},
),
);
final completed = await Navigator.of(homeContext).push<bool>(cameraRoute);
// Navigator.push hoàn tất ngay khi bắt đầu pop, còn Hero transition vẫn có
// thể đang chạy. Chờ route được tháo hẳn rồi mới hiện SnackBar để không tạo
// hai SnackBar Hero cùng tag giữa màn camera và màn chính..
await cameraRoute.completed;
if (!mounted) return;
if (completed == true) {
_tabController.animateTo(2);
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(const SnackBar(content: Text('Completed')));
} else if (_localPhotos.isNotEmpty) {
_tabController.animateTo(1);
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Text(
'Đã lưu ${_localPhotos.length} ảnh local. Bạn có thể chụp tiếp bất kỳ lúc nào.',
),
),
);
}
}
}
class _UploadedImageItem {
const _UploadedImageItem({
required this.imageUrl,
required this.errorText,
this.label,
});
final String? imageUrl;
final String errorText;
final String? label;
factory _UploadedImageItem.fromResponse(Map<String, dynamic> response) {
final snapshot = Map<String, dynamic>.from(response);
// VBI dùng data.imageRoot/imageAI; AICycle dùng result.imgUrl/imgDrawUrl.
// Luôn ưu tiên URL ảnh gốc để phản ánh đúng ảnh người dùng đã chụp.
final imageUrl =
_findString(snapshot, const {'imageroot'}) ??
_findString(snapshot, const {'imgurl'}) ??
_findString(snapshot, const {
'imageurl',
'imagedrawurl',
'urlimage',
'linkimage',
}) ??
_findString(snapshot, const {'imageai'}) ??
_findString(snapshot, const {'imgdrawurl'});
final imageId = _findString(snapshot, const {'imageid'});
final label =
_findString(snapshot, const {
'imagename',
'filename',
'label',
'partname',
'tenhangmuc',
'additionalcornerinfor',
}) ??
(imageId == null ? null : 'Ảnh #$imageId');
final hasError = _hasErrorStatus(snapshot) || imageUrl == null;
return _UploadedImageItem(
imageUrl: imageUrl,
label: label,
errorText: hasError ? _errorText(snapshot) : '',
);
}
static bool _hasErrorStatus(Map<String, dynamic> response) {
final typeResponse = _findString(response, const {
'typeresponse',
})?.toUpperCase();
if (typeResponse == 'ERROR' || typeResponse == 'FAILED') return true;
final errorLevel = _findString(response, const {
'errorlevel',
})?.toLowerCase();
if (errorLevel == 'error' ||
errorLevel == 'failed' ||
errorLevel == 'failure') {
return true;
}
final isPhotoValid = _findString(response, const {
'isphotovalid',
})?.toLowerCase();
if (isPhotoValid == 'false') return true;
final errorCode = int.tryParse(
_findString(response, const {'errorcodefromengine'}) ?? '',
);
if (errorCode != null && errorCode != 0) return true;
final statusText =
_findString(response, const {'status'}) ??
_findString(response, const {'statuscode'});
final normalizedStatus = statusText?.toLowerCase();
if (normalizedStatus == 'error' ||
normalizedStatus == 'failed' ||
normalizedStatus == 'failure') {
return true;
}
final numericStatus = int.tryParse(statusText ?? '');
return numericStatus != null &&
(numericStatus < 200 || numericStatus >= 300);
}
static String _errorText(Map<String, dynamic> response) {
final message =
_findValue(response, const {
'errormessage',
'error',
'message',
'description',
}) ??
_findValue(response, const {'errorcodefromengine'}) ??
_findValue(response, const {'rawbody', 'responsebody'});
final statusCode =
_findValue(response, const {'status'}) ??
_findValue(response, const {'statuscode'});
final prefix = statusCode == null ? '' : 'HTTP $statusCode: ';
if (message != null) return '$prefix${_displayValue(message)}';
return '$prefix${_displayValue(response)}';
}
static String? _findString(Object? node, Set<String> keys) {
final value = _findValue(node, keys);
if (value == null) return null;
final string = value.toString().trim();
return string.isEmpty ? null : string;
}
static Object? _findValue(Object? node, Set<String> keys) {
if (node is Map) {
for (final entry in node.entries) {
if (keys.contains(_normalizeKey(entry.key.toString()))) {
final value = entry.value;
if (value != null && value.toString().trim().isNotEmpty) return value;
}
}
for (final value in node.values) {
final found = _findValue(value, keys);
if (found != null) return found;
}
} else if (node is Iterable) {
for (final value in node) {
final found = _findValue(value, keys);
if (found != null) return found;
}
} else if (node is String) {
final value = node.trim();
final looksLikeJson =
(value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'));
if (looksLikeJson) {
try {
final decoded = jsonDecode(value);
if (decoded is Map || decoded is Iterable) {
return _findValue(decoded, keys);
}
} catch (_) {
// Đây có thể chỉ là text lỗi thông thường, không phải JSON lồng.
}
}
}
return null;
}
static String _normalizeKey(String key) =>
key.replaceAll(RegExp('[^a-zA-Z0-9]'), '').toLowerCase();
static String _displayValue(Object value) {
if (value is String) return value;
try {
return const JsonEncoder.withIndent(' ').convert(value);
} catch (_) {
return value.toString();
}
}
}