Skip to main content

buyme_on_device 0.1.5 copy "buyme_on_device: ^0.1.5" to clipboard
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();
    }
  }
}