image_quality_guard 1.1.0 copy "image_quality_guard: ^1.1.0" to clipboard
image_quality_guard: ^1.1.0 copied to clipboard

Detect blur, glare, brightness, and contrast issues in images with configurable thresholds and presets for reliable capture flows.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_quality_guard/image_quality_guard.dart';

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

class ImageQualityGuardExampleApp extends StatelessWidget {
  const ImageQualityGuardExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    const primary = Color(0xFF176B5B);

    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Image Quality Guard',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: primary,
          brightness: Brightness.light,
        ),
        scaffoldBackgroundColor: const Color(0xFFF5F7F6),
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.white,
          foregroundColor: Color(0xFF17201E),
          elevation: 0,
          scrolledUnderElevation: 1,
          centerTitle: false,
        ),
        cardTheme: CardThemeData(
          color: Colors.white,
          elevation: 0,
          margin: EdgeInsets.zero,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
            side: const BorderSide(color: Color(0xFFDDE5E2)),
          ),
        ),
        filledButtonTheme: FilledButtonThemeData(
          style: FilledButton.styleFrom(
            minimumSize: const Size(0, 48),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
        outlinedButtonTheme: OutlinedButtonThemeData(
          style: OutlinedButton.styleFrom(
            minimumSize: const Size(0, 48),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
      ),
      home: const QualityGuardPage(),
    );
  }
}

class QualityGuardPage extends StatefulWidget {
  const QualityGuardPage({super.key});

  @override
  State<QualityGuardPage> createState() => _QualityGuardPageState();
}

class _QualityGuardPageState extends State<QualityGuardPage> {
  static const _profiles = <QualityProfile>[
    QualityProfile(
      name: 'Default',
      description: 'Balanced checks for everyday images',
      icon: Icons.auto_awesome_outlined,
      config: ImageQualityConfig.mobile,
    ),
    QualityProfile(
      name: 'Card scan',
      description: 'IDs, bank cards, and licenses',
      icon: Icons.credit_card_outlined,
      config: ImageQualityConfig.cardScanning,
    ),
    QualityProfile(
      name: 'Document',
      description: 'Full-page forms, receipts, and printed text. The page '
          'must fill most of the frame; use Card scan or NID Capture for '
          'ID cards',
      icon: Icons.description_outlined,
      config: ImageQualityConfig.documentScanning,
    ),
    QualityProfile(
      name: 'Photo',
      description: 'High-quality photo capture',
      icon: Icons.photo_camera_outlined,
      config: ImageQualityConfig.photoCapture,
    ),
    QualityProfile(
      name: 'Relaxed',
      description: 'More forgiving quality limits',
      icon: Icons.sentiment_satisfied_alt_outlined,
      config: ImageQualityConfig.relaxed,
    ),
    QualityProfile(
      name: 'Strict',
      description: 'Higher quality requirements',
      icon: Icons.verified_outlined,
      config: ImageQualityConfig.strict,
    ),
    QualityProfile(
      name: 'Full resolution',
      description: 'Slowest, analyzes every pixel',
      icon: Icons.high_quality_outlined,
      config: ImageQualityConfig.fullResolution,
    ),
    QualityProfile(
      name: 'Custom',
      description: 'Set your own minimum quality thresholds',
      icon: Icons.tune,
      config: ImageQualityConfig.mobile,
      isCustom: true,
    ),
    QualityProfile(
      name: 'NID Capture',
      description: 'Farmer NID and identity document capture',
      icon: Icons.badge_outlined,
      config: ImageQualityConfig.nidCapture,
    ),
  ];

  final ImagePicker _picker = ImagePicker();

  Uint8List? _imageBytes;
  String? _fileName;
  ImageQualityResult? _result;
  int _profileIndex = 0;
  double _customSharpness = 100;
  double _customBrightness = 40;
  double _customContrast = 50;
  bool _isPicking = false;
  bool _isAnalyzing = false;
  String? _errorMessage;

  QualityProfile get _profile => _profiles[_profileIndex];

  ImageQualityConfig get _activeConfig {
    if (!_profile.isCustom) return _profile.config;
    return ImageQualityConfig(
      blurThreshold: _customSharpness,
      minBrightness: _customBrightness,
      minContrast: _customContrast,
    );
  }

  Future<void> _pickImage(ImageSource source) async {
    if (_isPicking || _isAnalyzing) return;

    setState(() {
      _isPicking = true;
      _errorMessage = null;
    });

    try {
      final file = await _picker.pickImage(source: source);
      if (file == null || !mounted) return;

      final bytes = await file.readAsBytes();
      if (!mounted) return;

      setState(() {
        _imageBytes = bytes;
        _fileName = file.name;
        _result = null;
      });
    } on PlatformException catch (error) {
      if (!mounted) return;
      setState(() {
        _errorMessage = _pickerErrorMessage(error);
      });
    } on Object catch (_) {
      if (!mounted) return;
      setState(() {
        _errorMessage = 'Could not open that image. Please try another one.';
      });
    } finally {
      if (mounted) {
        setState(() {
          _isPicking = false;
        });
      }
    }
  }

  String _pickerErrorMessage(PlatformException error) {
    if (error.code.contains('camera_access_denied')) {
      return 'Camera access is disabled. Allow it in device settings.';
    }
    if (error.code.contains('photo_access_denied')) {
      return 'Photo access is disabled. Allow it in device settings.';
    }
    return error.message ?? 'Could not open the image picker.';
  }

  /// Starts a background isolate analysis and keeps the UI responsive.
  ///
  /// No isolate is created here: [ImageQualityGuard.analyze] handles the
  /// payload, the isolate and the result reconstruction internally. The
  /// `await` therefore never blocks the Flutter event loop, which is what the
  /// frame counter in [_ResponsivenessProbe] demonstrates.
  Future<void> _analyze() async {
    final bytes = _imageBytes;
    if (bytes == null || _isAnalyzing) return;

    setState(() {
      _isAnalyzing = true;
      _errorMessage = null;
      _result = null;
    });

    try {
      final result = await ImageQualityGuard.analyze(
        bytes,
        config: _activeConfig,
      );
      if (!mounted) return;
      setState(() {
        _result = result;
      });
    } on ImageDecodeException {
      if (!mounted) return;
      setState(() {
        _errorMessage = 'This file is not a supported image.';
      });
    } on ImageQualityException catch (error) {
      if (!mounted) return;
      setState(() {
        _errorMessage = error.message;
      });
    } on Object catch (_) {
      if (!mounted) return;
      setState(() {
        _errorMessage = 'The image could not be analyzed. Please try again.';
      });
    } finally {
      if (mounted) {
        setState(() {
          _isAnalyzing = false;
        });
      }
    }
  }

  void _changeProfile(int? index) {
    if (index == null || index == _profileIndex) return;
    setState(() {
      _profileIndex = index;
      _result = null;
      _errorMessage = null;
    });
  }

  void _changeCustomSharpness(double value) {
    setState(() {
      _customSharpness = value;
      _result = null;
    });
  }

  void _changeCustomBrightness(double value) {
    setState(() {
      _customBrightness = value;
      _result = null;
    });
  }

  void _changeCustomContrast(double value) {
    setState(() {
      _customContrast = value;
      _result = null;
    });
  }

  void _clearImage() {
    setState(() {
      _imageBytes = null;
      _fileName = null;
      _result = null;
      _errorMessage = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            _BrandMark(),
            SizedBox(width: 12),
            Text(
              'Image Quality Guard',
              style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
            ),
          ],
        ),
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.fromLTRB(20, 24, 20, 40),
          child: Center(
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 1040),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Check image quality',
                    style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                          fontWeight: FontWeight.w800,
                          color: const Color(0xFF17201E),
                        ),
                  ),
                  const SizedBox(height: 6),
                  Text(
                    'Choose an image and check it for blur, lighting, and contrast.',
                    style: Theme.of(context)
                        .textTheme
                        .bodyLarge
                        ?.copyWith(color: const Color(0xFF64706D)),
                  ),
                  const SizedBox(height: 24),
                  LayoutBuilder(
                    builder: (context, constraints) {
                      final isWide = constraints.maxWidth >= 820;
                      final imagePanel = _ImagePanel(
                        bytes: _imageBytes,
                        fileName: _fileName,
                        isPicking: _isPicking,
                        onGallery: () => _pickImage(ImageSource.gallery),
                        onCamera: () => _pickImage(ImageSource.camera),
                        onClear: _clearImage,
                      );
                      final actionPanel = _ActionPanel(
                        profiles: _profiles,
                        profileIndex: _profileIndex,
                        hasImage: _imageBytes != null,
                        isAnalyzing: _isAnalyzing,
                        result: _result,
                        errorMessage: _errorMessage,
                        customSharpness: _customSharpness,
                        customBrightness: _customBrightness,
                        customContrast: _customContrast,
                        onProfileChanged: _changeProfile,
                        onSharpnessChanged: _changeCustomSharpness,
                        onBrightnessChanged: _changeCustomBrightness,
                        onContrastChanged: _changeCustomContrast,
                        onAnalyze: _analyze,
                      );

                      if (!isWide) {
                        return Column(
                          children: [
                            imagePanel,
                            const SizedBox(height: 16),
                            actionPanel,
                          ],
                        );
                      }

                      return Row(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Expanded(flex: 11, child: imagePanel),
                          const SizedBox(width: 20),
                          Expanded(flex: 9, child: actionPanel),
                        ],
                      );
                    },
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _BrandMark extends StatelessWidget {
  const _BrandMark();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 34,
      height: 34,
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.primary,
        borderRadius: BorderRadius.circular(8),
      ),
      alignment: Alignment.center,
      child: const Icon(Icons.image_search, color: Colors.white, size: 20),
    );
  }
}

class _ImagePanel extends StatelessWidget {
  const _ImagePanel({
    required this.bytes,
    required this.fileName,
    required this.isPicking,
    required this.onGallery,
    required this.onCamera,
    required this.onClear,
  });

  final Uint8List? bytes;
  final String? fileName;
  final bool isPicking;
  final VoidCallback onGallery;
  final VoidCallback onCamera;
  final VoidCallback onClear;

  @override
  Widget build(BuildContext context) {
    final hasImage = bytes != null;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(18),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                const Expanded(
                  child: Text(
                    'Image',
                    style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
                  ),
                ),
                if (hasImage)
                  IconButton(
                    onPressed: onClear,
                    tooltip: 'Remove image',
                    icon: const Icon(Icons.delete_outline),
                  ),
              ],
            ),
            const SizedBox(height: 12),
            ClipRRect(
              borderRadius: BorderRadius.circular(6),
              child: AspectRatio(
                aspectRatio: 4 / 3,
                child: Container(
                  color: const Color(0xFFEAF0EE),
                  child: hasImage
                      ? Image.memory(
                          bytes!,
                          fit: BoxFit.contain,
                          gaplessPlayback: true,
                        )
                      : const _EmptyImageState(),
                ),
              ),
            ),
            if (fileName != null) ...[
              const SizedBox(height: 10),
              Row(
                children: [
                  const Icon(
                    Icons.image_outlined,
                    size: 17,
                    color: Color(0xFF64706D),
                  ),
                  const SizedBox(width: 7),
                  Expanded(
                    child: Text(
                      fileName!,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(color: Color(0xFF64706D)),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Text(
                    _formatBytes(bytes!.length),
                    style: const TextStyle(color: Color(0xFF64706D)),
                  ),
                ],
              ),
            ],
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: FilledButton.icon(
                    onPressed: isPicking ? null : onGallery,
                    icon: const Icon(Icons.photo_library_outlined),
                    label: const Text('Gallery'),
                  ),
                ),
                const SizedBox(width: 10),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: isPicking ? null : onCamera,
                    icon: const Icon(Icons.photo_camera_outlined),
                    label: const Text('Camera'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  String _formatBytes(int value) {
    if (value >= 1024 * 1024) {
      return '${(value / (1024 * 1024)).toStringAsFixed(1)} MB';
    }
    return '${(value / 1024).toStringAsFixed(0)} KB';
  }
}

class _EmptyImageState extends StatelessWidget {
  const _EmptyImageState();

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(
            Icons.add_photo_alternate_outlined,
            size: 46,
            color: Color(0xFF71807C),
          ),
          SizedBox(height: 10),
          Text(
            'No image selected',
            style: TextStyle(
              color: Color(0xFF42504C),
              fontWeight: FontWeight.w600,
            ),
          ),
        ],
      ),
    );
  }
}

class _ActionPanel extends StatelessWidget {
  const _ActionPanel({
    required this.profiles,
    required this.profileIndex,
    required this.hasImage,
    required this.isAnalyzing,
    required this.result,
    required this.errorMessage,
    required this.customSharpness,
    required this.customBrightness,
    required this.customContrast,
    required this.onProfileChanged,
    required this.onSharpnessChanged,
    required this.onBrightnessChanged,
    required this.onContrastChanged,
    required this.onAnalyze,
  });

  final List<QualityProfile> profiles;
  final int profileIndex;
  final bool hasImage;
  final bool isAnalyzing;
  final ImageQualityResult? result;
  final String? errorMessage;
  final double customSharpness;
  final double customBrightness;
  final double customContrast;
  final ValueChanged<int?> onProfileChanged;
  final ValueChanged<double> onSharpnessChanged;
  final ValueChanged<double> onBrightnessChanged;
  final ValueChanged<double> onContrastChanged;
  final VoidCallback onAnalyze;

  @override
  Widget build(BuildContext context) {
    final profile = profiles[profileIndex];

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(18),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text(
              'Quality profile',
              style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
            ),
            const SizedBox(height: 12),
            DropdownButtonFormField<int>(
              key: const Key('quality-profile-dropdown'),
              initialValue: profileIndex,
              decoration: const InputDecoration(
                border: OutlineInputBorder(),
                contentPadding: EdgeInsets.symmetric(
                  horizontal: 14,
                  vertical: 12,
                ),
              ),
              items: [
                for (var index = 0; index < profiles.length; index++)
                  DropdownMenuItem(
                    value: index,
                    child: Row(
                      children: [
                        Icon(profiles[index].icon, size: 20),
                        const SizedBox(width: 10),
                        Text(profiles[index].name),
                      ],
                    ),
                  ),
              ],
              onChanged: isAnalyzing ? null : onProfileChanged,
            ),
            const SizedBox(height: 8),
            Text(
              profile.description,
              style: const TextStyle(color: Color(0xFF64706D)),
            ),
            if (profile.isCustom) ...[
              const SizedBox(height: 16),
              const Divider(height: 1),
              const SizedBox(height: 12),
              _ThresholdSlider(
                label: 'Minimum sharpness',
                value: customSharpness,
                min: 10,
                max: 500,
                divisions: 49,
                onChanged: isAnalyzing ? null : onSharpnessChanged,
              ),
              _ThresholdSlider(
                label: 'Minimum brightness',
                value: customBrightness,
                min: 0,
                max: 200,
                divisions: 40,
                onChanged: isAnalyzing ? null : onBrightnessChanged,
              ),
              _ThresholdSlider(
                label: 'Minimum contrast',
                value: customContrast,
                min: 0,
                max: 100,
                divisions: 20,
                onChanged: isAnalyzing ? null : onContrastChanged,
              ),
            ],
            const SizedBox(height: 18),
            FilledButton.icon(
              onPressed: hasImage && !isAnalyzing ? onAnalyze : null,
              icon: isAnalyzing
                  ? const SizedBox.square(
                      dimension: 18,
                      child: CircularProgressIndicator(
                        strokeWidth: 2,
                        color: Colors.white,
                      ),
                    )
                  : const Icon(Icons.auto_fix_high_outlined),
              label: Text(isAnalyzing ? 'Analyzing...' : 'Analyze image'),
            ),
            if (isAnalyzing) ...[
              const SizedBox(height: 14),
              const _ResponsivenessProbe(),
            ],
            if (errorMessage != null) ...[
              const SizedBox(height: 16),
              _ErrorNotice(message: errorMessage!),
            ],
            if (result != null) ...[
              const SizedBox(height: 20),
              _ResultView(result: result!),
            ] else if (errorMessage == null) ...[
              const SizedBox(height: 24),
              _WaitingState(hasImage: hasImage),
            ],
          ],
        ),
      ),
    );
  }
}

class _ThresholdSlider extends StatelessWidget {
  const _ThresholdSlider({
    required this.label,
    required this.value,
    required this.min,
    required this.max,
    required this.divisions,
    required this.onChanged,
  });

  final String label;
  final double value;
  final double min;
  final double max;
  final int divisions;
  final ValueChanged<double>? onChanged;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Row(
            children: [
              Expanded(
                child: Text(
                  label,
                  style: const TextStyle(fontWeight: FontWeight.w600),
                ),
              ),
              Container(
                constraints: const BoxConstraints(minWidth: 44),
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: const Color(0xFFEAF0EE),
                  borderRadius: BorderRadius.circular(6),
                ),
                alignment: Alignment.center,
                child: Text(
                  value.toStringAsFixed(0),
                  style: const TextStyle(
                    color: Color(0xFF30423D),
                    fontWeight: FontWeight.w700,
                    fontFeatures: [FontFeature.tabularFigures()],
                  ),
                ),
              ),
            ],
          ),
          Slider(
            value: value,
            min: min,
            max: max,
            divisions: divisions,
            label: value.toStringAsFixed(0),
            onChanged: onChanged,
          ),
        ],
      ),
    );
  }
}

class _WaitingState extends StatelessWidget {
  const _WaitingState({required this.hasImage});

  final bool hasImage;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22),
      decoration: BoxDecoration(
        color: const Color(0xFFF3F6F5),
        borderRadius: BorderRadius.circular(6),
      ),
      child: Column(
        children: [
          Icon(
            hasImage ? Icons.fact_check_outlined : Icons.image_search,
            size: 34,
            color: const Color(0xFF71807C),
          ),
          const SizedBox(height: 10),
          Text(
            hasImage ? 'Ready to analyze' : 'Select an image to begin',
            textAlign: TextAlign.center,
            style: const TextStyle(
              color: Color(0xFF42504C),
              fontWeight: FontWeight.w600,
            ),
          ),
        ],
      ),
    );
  }
}

/// Proves that the UI isolate keeps rendering while the analysis runs.
///
/// The widget runs a repeating animation and counts the frames the framework
/// actually renders. A blocked UI isolate would stop the counter, because no
/// further frames would be scheduled.
class _ResponsivenessProbe extends StatefulWidget {
  const _ResponsivenessProbe();

  @override
  State<_ResponsivenessProbe> createState() => _ResponsivenessProbeState();
}

class _ResponsivenessProbeState extends State<_ResponsivenessProbe>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 900),
  )..repeat();

  var _frames = 0;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback(_countFrame);
  }

  void _countFrame(Duration _) {
    if (!mounted) return;
    // The repeating controller keeps scheduling frames, so this callback runs
    // once per rendered frame. No setState is needed: the AnimatedBuilder below
    // rebuilds on every frame of the controller anyway.
    _frames++;
    WidgetsBinding.instance.addPostFrameCallback(_countFrame);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, _) => Container(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
        decoration: BoxDecoration(
          color: const Color(0xFFEAF0EE),
          borderRadius: BorderRadius.circular(6),
        ),
        child: Row(
          children: [
            RotationTransition(
              turns: _controller,
              child: const Icon(
                Icons.sync,
                size: 20,
                color: Color(0xFF176B5B),
              ),
            ),
            const SizedBox(width: 11),
            Expanded(
              child: Text(
                'Analyzing on a background isolate - $_frames UI frames rendered',
                style: const TextStyle(
                  color: Color(0xFF30423D),
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _ResultView extends StatelessWidget {
  const _ResultView({required this.result});

  final ImageQualityResult result;

  @override
  Widget build(BuildContext context) {
    final passed = result.isValid;
    final statusColor =
        passed ? const Color(0xFF167052) : const Color(0xFFB5472D);
    final statusBackground =
        passed ? const Color(0xFFE7F5EE) : const Color(0xFFFFEDE7);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Container(
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: statusBackground,
            borderRadius: BorderRadius.circular(6),
          ),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Icon(
                passed ? Icons.check_circle : Icons.error,
                color: statusColor,
                size: 26,
              ),
              const SizedBox(width: 11),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      passed ? 'Quality check passed' : 'Needs another photo',
                      style: TextStyle(
                        color: statusColor,
                        fontSize: 16,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                    if (!passed) ...[
                      const SizedBox(height: 4),
                      Text(
                        _friendlyIssues(result),
                        style: const TextStyle(color: Color(0xFF68463D)),
                      ),
                    ],
                  ],
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 18),
        const Text(
          'Results',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
        ),
        const SizedBox(height: 8),
        _CheckRow(
          icon: Icons.center_focus_strong_outlined,
          label: 'Sharpness (Laplacian variance)',
          value: result.sharpnessScore.toStringAsFixed(1),
          passed: !result.isOutOfFocus,
        ),
        if (result.config.minSharpTileRatio > 0) ...[
          const Divider(height: 1),
          _CheckRow(
            icon: Icons.crop_free,
            label: 'Sharp area',
            value: '${result.sharpTilePercentage.toStringAsFixed(0)}% '
                '(min ${(result.config.minSharpTileRatio * 100).toStringAsFixed(0)}%)',
            passed: !result.hasLowSharpCoverage,
          ),
        ],
        const Divider(height: 1),
        _CheckRow(
          icon: Icons.light_mode_outlined,
          label: 'Brightness',
          value: result.brightness.toStringAsFixed(1),
          passed: result.isBrightnessOptimal,
        ),
        const Divider(height: 1),
        _CheckRow(
          icon: Icons.contrast_outlined,
          label: 'Contrast',
          value: result.contrast.toStringAsFixed(1),
          passed: result.hasGoodContrast,
        ),
        const Divider(height: 1),
        _CheckRow(
          icon: Icons.flare_outlined,
          label: 'Glare (blown out pixels)',
          value: '${(result.glareRatio * 100).toStringAsFixed(1)}%',
          passed: !result.hasGlare,
        ),
        const SizedBox(height: 18),
        const Text(
          'Processing',
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
        ),
        const SizedBox(height: 6),
        _MetadataRow(
          label: 'Original resolution',
          value: '${result.originalWidth} x ${result.originalHeight}',
        ),
        _MetadataRow(
          label: 'Analyzed resolution',
          value: '${result.analyzedWidth} x ${result.analyzedHeight}'
              '${result.wasDownsampled ? '  (downsampled)' : ''}',
        ),
        _MetadataRow(
          label: 'Processing time',
          value: '${result.processingTimeMs} ms',
        ),
        _MetadataRow(
          label: 'Run on',
          value: 'Background isolate',
        ),
      ],
    );
  }

  String _friendlyIssues(ImageQualityResult value) {
    final issues = <String>[];
    if (value.isOutOfFocus) issues.add('Hold the camera steady');
    if (value.hasLowSharpCoverage) {
      issues.add('Move closer so the page fills the frame');
    }
    switch (value.brightnessLevel) {
      case BrightnessLevel.tooDark:
        issues.add('Use more light');
      case BrightnessLevel.tooBright:
        issues.add('Reduce glare or light');
      case BrightnessLevel.optimal:
        break;
    }
    if (!value.hasGoodContrast) {
      issues.add('Use a clearer background');
    }
    if (value.hasGlare) {
      issues.add('Tilt the card to avoid reflections');
    }
    return issues.join(' | ');
  }
}

class _MetadataRow extends StatelessWidget {
  const _MetadataRow({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 34,
      child: Row(
        children: [
          Expanded(
            child: Text(
              label,
              style: const TextStyle(color: Color(0xFF53625E)),
            ),
          ),
          Text(
            value,
            style: const TextStyle(
              color: Color(0xFF30423D),
              fontWeight: FontWeight.w600,
              fontFeatures: [FontFeature.tabularFigures()],
            ),
          ),
        ],
      ),
    );
  }
}

class _CheckRow extends StatelessWidget {
  const _CheckRow({
    required this.icon,
    required this.label,
    required this.value,
    required this.passed,
  });

  final IconData icon;
  final String label;
  final String value;
  final bool passed;

  @override
  Widget build(BuildContext context) {
    final color = passed ? const Color(0xFF167052) : const Color(0xFFB5472D);

    return SizedBox(
      height: 58,
      child: Row(
        children: [
          Icon(icon, size: 21, color: const Color(0xFF53625E)),
          const SizedBox(width: 11),
          Expanded(
            child: Text(
              label,
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
          ),
          Text(
            value,
            style: const TextStyle(
              color: Color(0xFF64706D),
              fontFeatures: [FontFeature.tabularFigures()],
            ),
          ),
          const SizedBox(width: 10),
          Icon(
            passed ? Icons.check_circle : Icons.cancel,
            color: color,
            size: 20,
          ),
        ],
      ),
    );
  }
}

class _ErrorNotice extends StatelessWidget {
  const _ErrorNotice({required this.message});

  final String message;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: const Color(0xFFFFEDE7),
        borderRadius: BorderRadius.circular(6),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Icon(Icons.error_outline, color: Color(0xFFB5472D)),
          const SizedBox(width: 10),
          Expanded(child: Text(message)),
        ],
      ),
    );
  }
}

class QualityProfile {
  const QualityProfile({
    required this.name,
    required this.description,
    required this.icon,
    required this.config,
    this.isCustom = false,
  });

  final String name;
  final String description;
  final IconData icon;
  final ImageQualityConfig config;
  final bool isCustom;
}
1
likes
160
points
462
downloads

Documentation

API reference

Publisher

verified publishertanvirdev.com

Weekly Downloads

Detect blur, glare, brightness, and contrast issues in images with configurable thresholds and presets for reliable capture flows.

Repository (GitHub)
View/report issues

Topics

#image-processing #image-quality #blur-detection #validation #computer-vision

License

BSD-3-Clause (license)

Dependencies

image

More

Packages that depend on image_quality_guard