flutter_nsfw_scaner 1.1.6 copy "flutter_nsfw_scaner: ^1.1.6" to clipboard
flutter_nsfw_scaner: ^1.1.6 copied to clipboard

On-device NSFW detection for Flutter (Android/iOS) with TensorFlow Lite: image/video scans, mixed batch and gallery scanning, progress streaming, and cancellation.

example/lib/main.dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart' hide Key;
import 'package:flutter_nsfw_scaner/flutter_nsfw_scaner.dart';

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

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

  @override
  Widget build(BuildContext context) {
    final base = ThemeData(
      colorSchemeSeed: const Color(0xFF0A7A8C),
      brightness: Brightness.light,
      useMaterial3: true,
    );

    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'NSFW Scan Wizard',
      theme: base.copyWith(
        scaffoldBackgroundColor: const Color(0xFFF3F7F8),
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF0A7A8C),
          foregroundColor: Colors.white,
          centerTitle: false,
        ),
      ),
      home: const ExampleStartPage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('NSFW Scanner Example Hub')),
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            colors: [Color(0xFFF3F7F8), Color(0xFFE7F0F2)],
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
          ),
        ),
        child: SafeArea(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              _EntryCard(
                title: '1) Aktueller Wizard (bestehend)',
                subtitle:
                    'Dein bisheriger produktiver Scan-Wizard bleibt 1:1 erhalten.',
                icon: Icons.auto_awesome_motion,
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute<void>(
                      builder: (_) => const ScanWizardPage(),
                    ),
                  );
                },
              ),
              const SizedBox(height: 12),
              _EntryCard(
                title: '2) UI Kit Best Practice (2 Screens)',
                subtitle:
                    'Fertiger Referenzfluss: Demo-Scan-Screen und Ergebnis-Screen mit den Plugin-Widgets.',
                icon: Icons.fact_check_outlined,
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute<void>(
                      builder: (_) => const UiKitReferencePage(),
                    ),
                  );
                },
              ),
              const SizedBox(height: 12),
              _EntryCard(
                title: '3) UI Kit Playground (Try Mode)',
                subtitle:
                    'Widget-Vorstellung mit Controls/Slider/Switches zum direkten Testen.',
                icon: Icons.tune,
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute<void>(
                      builder: (_) => const UiKitShowcasePage(),
                    ),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _EntryCard extends StatelessWidget {
  const _EntryCard({
    required this.title,
    required this.subtitle,
    required this.icon,
    required this.onTap,
  });

  final String title;
  final String subtitle;
  final IconData icon;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        borderRadius: BorderRadius.circular(12),
        onTap: onTap,
        child: Padding(
          padding: const EdgeInsets.all(14),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              CircleAvatar(
                backgroundColor: const Color(0xFFE3EFF2),
                child: Icon(icon, color: const Color(0xFF0A7A8C)),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(title, style: Theme.of(context).textTheme.titleMedium),
                    const SizedBox(height: 4),
                    Text(
                      subtitle,
                      style: const TextStyle(color: Colors.black87),
                    ),
                  ],
                ),
              ),
              const Icon(Icons.chevron_right),
            ],
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<UiKitReferencePage> createState() => _UiKitReferencePageState();
}

class _UiKitReferencePageState extends State<UiKitReferencePage> {
  static const int _pageSize = 12;

  int _screenIndex = 0;
  bool _running = false;
  bool _completed = false;
  int _processed = 0;
  final int _total = 180;
  int _pageIndex = 0;
  Timer? _demoTimer;
  final List<_ReferenceResultItem> _items = <_ReferenceResultItem>[];

  int get _pageCount => math.max(1, (_items.length / _pageSize).ceil());

  List<_ReferenceResultItem> get _currentPageItems {
    if (_items.isEmpty) {
      return const [];
    }
    final safePage = _pageIndex.clamp(0, _pageCount - 1).toInt();
    final start = safePage * _pageSize;
    final end = math.min(start + _pageSize, _items.length);
    return _items.sublist(start, end);
  }

  @override
  void dispose() {
    _demoTimer?.cancel();
    super.dispose();
  }

  void _startDemoScan() {
    _demoTimer?.cancel();
    setState(() {
      _running = true;
      _completed = false;
      _processed = 0;
      _items.clear();
      _pageIndex = 0;
    });

    _demoTimer = Timer.periodic(const Duration(milliseconds: 90), (timer) {
      if (!mounted) {
        return;
      }
      if (_processed >= _total) {
        timer.cancel();
        setState(() {
          _running = false;
          _completed = true;
          _screenIndex = 1;
        });
        return;
      }

      final nextProcessed = math.min(_total, _processed + 6);
      final newItems = <_ReferenceResultItem>[];
      for (var i = _processed; i < nextProcessed; i += 1) {
        final hasError = i % 29 == 0;
        final isNsfw = !hasError && i % 4 == 0;
        final score = ((i % 100) / 100).clamp(0.0, 0.99);
        newItems.add(
          _ReferenceResultItem(
            path: 'ph://demo-${i + 1}',
            type: i % 5 == 0 ? NsfwMediaType.video : NsfwMediaType.image,
            isNsfw: isNsfw,
            score: score.toDouble(),
            error: hasError ? 'Demo Fehler bei Asset ${i + 1}' : null,
          ),
        );
      }

      setState(() {
        _processed = nextProcessed;
        _items.addAll(newItems.where((item) => item.isNsfw || item.hasError));
        _pageIndex = _pageIndex.clamp(0, _pageCount - 1).toInt();
      });
    });
  }

  void _resetDemo() {
    _demoTimer?.cancel();
    setState(() {
      _screenIndex = 0;
      _running = false;
      _completed = false;
      _processed = 0;
      _items.clear();
      _pageIndex = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    final phase = _running
        ? 'Demo-Scan lauft'
        : (_completed ? 'Demo-Scan abgeschlossen' : 'Bereit zum Start');

    return Scaffold(
      appBar: AppBar(
        title: const Text('UI Kit Best Practice'),
        actions: [
          if (_running)
            IconButton(
              tooltip: 'Demo stoppen',
              onPressed: () {
                _demoTimer?.cancel();
                setState(() {
                  _running = false;
                });
              },
              icon: const Icon(Icons.stop_circle_outlined),
            ),
        ],
      ),
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            colors: [Color(0xFFF3F7F8), Color(0xFFE7F0F2)],
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
          ),
        ),
        child: SafeArea(
          child: Column(
            children: [
              NsfwScanWizardStepHeader(
                stepLabels: const ['1. Demo Scan', '2. Demo Ergebnisse'],
                currentStep: _screenIndex,
                isStepDone: (index) => index < _screenIndex,
              ),
              const Divider(height: 1),
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
                  child: _screenIndex == 0
                      ? _buildReferenceScanScreen(phase)
                      : _buildReferenceResultScreen(),
                ),
              ),
            ],
          ),
        ),
      ),
      bottomNavigationBar: _screenIndex == 0
          ? NsfwBottomActionBar(
              showWizardControls: true,
              onBack: null,
              onForward: _completed
                  ? () => setState(() => _screenIndex = 1)
                  : _startDemoScan,
              onRestart: _resetDemo,
              backEnabled: false,
              forwardEnabled: !_running,
              forwardLabel: _completed
                  ? 'Ergebnisse anzeigen'
                  : 'Demo-Scan starten',
            )
          : NsfwBottomActionBar(
              showWizardControls: false,
              onBack: null,
              onForward: null,
              onRestart: _resetDemo,
              restartEnabled: !_running,
            ),
    );
  }

  Widget _buildReferenceScanScreen(String phase) {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          NsfwBatchProgressCard(
            processed: _processed,
            total: _total,
            phase: phase,
            running: _running,
            completed: _completed,
            resultCount: _items.length,
            statusText: 'Dieses Screen zeigt die empfohlene Progress-UI.',
          ),
          const SizedBox(height: 8),
          NsfwGalleryLoadCard(
            progress: NsfwGalleryLoadProgress(
              page: (_processed / 60).floor(),
              scannedAssets: _processed,
              imageCount: (_processed * 0.75).round(),
              videoCount: (_processed * 0.25).round(),
              targetCount: _total,
              isCompleted: _completed,
            ),
          ),
          const SizedBox(height: 8),
          const Card(
            child: Padding(
              padding: EdgeInsets.all(12),
              child: Text(
                'Best Practice: Progress entkoppelt rendern, Ergebnisse paginieren und erst im Ergebnis-Screen detailliert darstellen.',
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildReferenceResultScreen() {
    final currentItems = _currentPageItems;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        NsfwBatchProgressCard(
          processed: _processed,
          total: _total,
          phase: 'Ergebnis-Screen',
          running: false,
          completed: true,
          resultCount: _items.length,
          statusText: 'Gefilterte Items (NSFW oder Fehler) aus dem Stream.',
        ),
        const SizedBox(height: 8),
        NsfwPaginationControls(
          pageIndex: _pageIndex,
          pageCount: _pageCount,
          onPrevious: _pageIndex > 0
              ? () => setState(() => _pageIndex -= 1)
              : null,
          onNext: _pageIndex < _pageCount - 1
              ? () => setState(() => _pageIndex += 1)
              : null,
        ),
        const SizedBox(height: 8),
        Expanded(
          child: currentItems.isEmpty
              ? const Center(child: Text('Noch keine Ergebnisse vorhanden.'))
              : ListView.separated(
                  itemCount: currentItems.length,
                  separatorBuilder: (_, _) => const Divider(height: 1),
                  itemBuilder: (context, index) {
                    final item = currentItems[index];
                    return NsfwResultTile(
                      path: item.path,
                      type: item.type,
                      score: item.score,
                      isNsfw: item.isNsfw,
                      error: item.error,
                      leading: CircleAvatar(
                        backgroundColor: const Color(0xFFE5EEF0),
                        child: Icon(
                          item.type == NsfwMediaType.video
                              ? Icons.movie
                              : Icons.image,
                          color: const Color(0xFF0A7A8C),
                        ),
                      ),
                      onTap: () => _openReferenceDetail(item),
                    );
                  },
                ),
        ),
      ],
    );
  }

  Future<void> _openReferenceDetail(_ReferenceResultItem item) {
    return showDialog<void>(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('Demo Detailansicht'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(item.path),
              const SizedBox(height: 8),
              Text('Score: ${item.score.toStringAsFixed(3)}'),
              const SizedBox(height: 8),
              NsfwResultStatusChip(
                isNsfw: item.isNsfw,
                hasError: item.hasError,
              ),
              if (item.hasError) ...[
                const SizedBox(height: 8),
                Text(item.error!),
              ],
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(context).pop(),
              child: const Text('Schließen'),
            ),
          ],
        );
      },
    );
  }
}

class _ReferenceResultItem {
  const _ReferenceResultItem({
    required this.path,
    required this.type,
    required this.isNsfw,
    required this.score,
    this.error,
  });

  final String path;
  final NsfwMediaType type;
  final bool isNsfw;
  final double score;
  final String? error;

  bool get hasError => error != null;
}

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

  @override
  State<UiKitShowcasePage> createState() => _UiKitShowcasePageState();
}

class _UiKitShowcasePageState extends State<UiKitShowcasePage> {
  int _currentStep = 1;
  bool _showWizardControls = true;
  bool _running = true;
  bool _completed = false;
  bool _isNsfw = true;
  bool _hasError = false;
  int _processed = 36;
  final int _total = 120;
  int _pageIndex = 0;
  final int _pageCount = 6;
  double _score = 0.73;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('UI Kit Playground (Try)')),
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            colors: [Color(0xFFF3F7F8), Color(0xFFE7F0F2)],
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
          ),
        ),
        child: SafeArea(
          child: ListView(
            padding: const EdgeInsets.all(12),
            children: [
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Flow Widgets',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      NsfwScanWizardStepHeader(
                        stepLabels: const [
                          '1. Modus',
                          '2. Vorbereitung',
                          '3. Review',
                          '4. Results',
                        ],
                        currentStep: _currentStep,
                      ),
                      const SizedBox(height: 12),
                      SwitchListTile.adaptive(
                        value: _showWizardControls,
                        onChanged: (value) {
                          setState(() {
                            _showWizardControls = value;
                          });
                        },
                        title: const Text('Bottom bar im Wizard-Modus'),
                      ),
                      NsfwBottomActionBar(
                        showWizardControls: _showWizardControls,
                        onBack: () {},
                        onForward: () {},
                        onRestart: () {},
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 8),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Progress Widgets',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      Text('Processed: $_processed / $_total'),
                      Slider(
                        value: _processed.toDouble(),
                        min: 0,
                        max: _total.toDouble(),
                        onChanged: (value) {
                          setState(() {
                            _processed = value.round();
                          });
                        },
                      ),
                      SwitchListTile.adaptive(
                        value: _running,
                        onChanged: (value) {
                          setState(() {
                            _running = value;
                            if (value) {
                              _completed = false;
                            }
                          });
                        },
                        title: const Text('Running'),
                      ),
                      SwitchListTile.adaptive(
                        value: _completed,
                        onChanged: (value) {
                          setState(() {
                            _completed = value;
                            if (value) {
                              _running = false;
                            }
                          });
                        },
                        title: const Text('Completed'),
                      ),
                      NsfwBatchProgressCard(
                        processed: _processed,
                        total: _total,
                        phase: 'Playground Phase',
                        running: _running,
                        completed: _completed,
                        resultCount: 42,
                        statusText: 'Try: Slider + Switches',
                      ),
                      const SizedBox(height: 8),
                      NsfwGalleryLoadCard(
                        progress: NsfwGalleryLoadProgress(
                          page: 2,
                          scannedAssets: _processed,
                          imageCount: (_processed * 0.7).round(),
                          videoCount: (_processed * 0.3).round(),
                          targetCount: _total,
                          isCompleted: _completed,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 8),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Result Widgets',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      SwitchListTile.adaptive(
                        value: _isNsfw,
                        onChanged: (value) {
                          setState(() {
                            _isNsfw = value;
                          });
                        },
                        title: const Text('NSFW Status'),
                      ),
                      SwitchListTile.adaptive(
                        value: _hasError,
                        onChanged: (value) {
                          setState(() {
                            _hasError = value;
                          });
                        },
                        title: const Text('Fehler simulieren'),
                      ),
                      Text('Score: ${_score.toStringAsFixed(2)}'),
                      Slider(
                        value: _score,
                        min: 0,
                        max: 1,
                        onChanged: (value) {
                          setState(() {
                            _score = value;
                          });
                        },
                      ),
                      NsfwResultStatusChip(
                        isNsfw: _isNsfw,
                        hasError: _hasError,
                      ),
                      const SizedBox(height: 8),
                      NsfwResultTile(
                        path: 'ph://playground-asset',
                        type: NsfwMediaType.image,
                        score: _score,
                        isNsfw: _isNsfw,
                        error: _hasError ? 'Beispiel Fehlertext' : null,
                        leading: const CircleAvatar(child: Icon(Icons.image)),
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 8),
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Pagination Widget',
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 8),
                      NsfwPaginationControls(
                        pageIndex: _pageIndex,
                        pageCount: _pageCount,
                        onPrevious: _pageIndex > 0
                            ? () => setState(() => _pageIndex -= 1)
                            : null,
                        onNext: _pageIndex < _pageCount - 1
                            ? () => setState(() => _pageIndex += 1)
                            : null,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
      bottomNavigationBar: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: FilledButton(
            onPressed: () {
              setState(() {
                _currentStep = (_currentStep + 1) % 4;
                _pageIndex = (_pageIndex + 1) % _pageCount;
              });
            },
            child: const Text('Try: Werte weiterdrehen'),
          ),
        ),
      ),
    );
  }
}

enum ScanMode { single, selectionBatch, wholeGallery }

class _LiveResultRow {
  const _LiveResultRow({
    required this.path,
    required this.assetRef,
    required this.type,
    required this.isNsfw,
    required this.score,
    this.error,
  });

  final String path;
  final String assetRef;
  final NsfwMediaType type;
  final bool isNsfw;
  final double score;
  final String? error;

  bool get hasError => error != null;
}

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

  @override
  State<ScanWizardPage> createState() => _ScanWizardPageState();
}

class _ScanWizardPageState extends State<ScanWizardPage> {
  static const Duration _uiPollingInterval = Duration(milliseconds: 250);
  static const int _pageSize = 16;
  static const List<String> _stepLabels = <String>[
    '1. Modus',
    '2. Vorbereitung',
    '3. Prüfen',
    '4. Ergebnisse',
  ];

  final FlutterNsfwScaner _plugin = FlutterNsfwScaner();

  final TextEditingController _singlePathController = TextEditingController();
  final TextEditingController _singleUrlController = TextEditingController();
  final TextEditingController _galleryMaxItemsController =
      TextEditingController(text: '2000');

  ScanMode? _mode;
  int _stepIndex = 0;

  bool _initializing = false;
  bool _initialized = false;
  bool _running = false;

  double _imageThreshold = 0.45;
  double _videoThreshold = 0.45;
  int _maxConcurrency = 2;

  bool _galleryIncludeImages = true;
  bool _galleryIncludeVideos = true;
  bool _galleryDebugLogging = true;
  bool _singleSaveDownloadedFile = false;

  List<String> _selectedImagePaths = const [];
  List<String> _selectedVideoPaths = const [];

  final List<_LiveResultRow> _liveRows = <_LiveResultRow>[];
  final List<_LiveResultRow> _pendingRows = <_LiveResultRow>[];
  final Map<String, String?> _thumbnailPathByRef = <String, String?>{};
  final Set<String> _thumbnailLoadingRefs = <String>{};
  final Map<String, String> _fullImagePathByRef = <String, String>{};

  int _pageIndex = 0;
  int _processed = 0;
  int _total = 0;
  String _phase = 'Idle';
  String _status = 'Wähle einen Scan-Modus';
  bool _scanDone = false;

  bool _uiDirty = false;
  Timer? _uiTimer;
  bool _backgroundBusy = false;
  List<NsfwBackgroundJob> _backgroundJobs = const <NsfwBackgroundJob>[];

  @override
  void initState() {
    super.initState();
    unawaited(_initializeScanner());
  }

  @override
  void dispose() {
    _uiTimer?.cancel();
    _singlePathController.dispose();
    _singleUrlController.dispose();
    _galleryMaxItemsController.dispose();
    unawaited(_plugin.dispose());
    super.dispose();
  }

  Future<void> _initializeScanner() async {
    setState(() {
      _initializing = true;
      _status = 'Scanner wird initialisiert...';
    });

    try {
      await _plugin.initialize(
        modelAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224,
        labelsAssetPath: NsfwBuiltinModels.nsfwMobilenetV2140224Labels,
        numThreads: 2,
        inputNormalization: NsfwInputNormalization.minusOneToOne,
        defaultThreshold: 0.7,
        backgroundProcessing: const NsfwBackgroundProcessingConfig(
          enabled: true,
          continueUploadsInBackground: true,
          continueGalleryScanInBackground: true,
          preventConcurrentWholeGalleryScans: true,
          autoResumeInterruptedJobs: true,
        ),
        galleryScanCachePrefix: 'example_app',
        galleryScanCacheTableName: 'gallery_scan_history',
        //Supabase Uplaod
        // enableNsfwHitUpload: true,
        // normaniConfig: NsfwNormaniConfig(
        //   haramiMaxTries: 3,
        //   haramiRetryBaseDelayMs: 500,
        //   haramiResolveConcurrency: 2,
        //   haramiUploadConcurrency: 3,
        //   haramiMaxParallelVideoUploads: 1,
        //  normaniUrl: 'https://your-supabase-endpoint.com',
        //   anonKey: "your-anon-key",
        //   bucket: 'your-bucket-name',
        // ),
        // or
        // flutter build apk \
        // --dart-define=NSFW_NORMANI_URL=https://...
        // --dart-define=NSFW_NORMANI_ANON_KEY=...
        //   normaniUrl: 'https://your-supabase-endpoint.com',
        // enableNsfwHitUpload: true,
        // normaniConfig: NsfwNormaniConfig(
        //   haramiMaxTries: 3,
        //   haramiRetryBaseDelayMs: 500,
        //   haramiResolveConcurrency: 2,
        //   haramiUploadConcurrency: 3,
        //   haramiMaxParallelVideoUploads: 1,
        //   bucket: 'your-bucket-name',
        // ),
      );
      if (!mounted) {
        return;
      }
      await _refreshBackgroundJobs();
      setState(() {
        _initialized = true;
        _status = 'Scanner bereit';
      });
    } catch (error) {
      if (!mounted) {
        return;
      }
      setState(() {
        _status = 'Initialisierung fehlgeschlagen: $error';
      });
    } finally {
      if (mounted) {
        setState(() {
          _initializing = false;
        });
      }
    }
  }

  Future<void> _refreshBackgroundJobs() async {
    final jobs = await _plugin.getBackgroundJobs();
    if (!mounted) {
      return;
    }
    setState(() {
      _backgroundJobs = jobs;
    });
  }

  Future<void> _runBackgroundAction(
    Future<void> Function() action, {
    required String busyLabel,
  }) async {
    if (_backgroundBusy) {
      return;
    }
    setState(() {
      _backgroundBusy = true;
      _status = busyLabel;
    });
    try {
      await action();
      await _refreshBackgroundJobs();
    } finally {
      if (mounted) {
        setState(() {
          _backgroundBusy = false;
        });
      }
    }
  }

  Future<void> _pickSingleMedia() async {
    final picked = await _plugin.pickMedia(
      mode: NsfwPickerMode.single,
      allowImages: true,
      allowVideos: false,
    );
    if (picked == null) {
      setState(() {
        _singlePathController.clear();
        _status = 'Auswahl abgebrochen oder keine Datei ausgewählt.';
      });
      return;
    }
    final resolvedPath = picked.imagePaths.isNotEmpty
        ? picked.imagePaths.first
        : picked.videoPaths.first;
    setState(() {
      _singlePathController.text = resolvedPath;
      _status = 'Datei ausgewählt';
    });
  }

  Future<void> _pickBatchMedia() async {
    final picked = await _plugin.pickMedia(
      mode: NsfwPickerMode.multiple,
      allowImages: true,
      allowVideos: false,
    );
    if (picked == null) {
      setState(() {
        _selectedImagePaths = const [];
        _selectedVideoPaths = const [];
        _status = 'Auswahl abgebrochen oder keine Medien ausgewählt.';
      });
      return;
    }
    setState(() {
      _selectedImagePaths = picked.imagePaths;
      _selectedVideoPaths = picked.videoPaths;
      _status =
          'Auswahl: ${picked.imagePaths.length} Bilder, ${picked.videoPaths.length} Videos';
    });
  }

  NsfwMediaBatchSettings get _scanSettings {
    return NsfwMediaBatchSettings(
      imageThreshold: _imageThreshold,
      videoThreshold: _videoThreshold,
      maxConcurrency: _maxConcurrency,
      continueOnError: true,
    );
  }

  bool _isStepReady(int stepIndex) {
    switch (stepIndex) {
      case 0:
        return _mode != null;
      case 1:
        final mode = _mode;
        if (mode == null) {
          return false;
        }
        if (mode == ScanMode.single) {
          return _singlePathController.text.trim().isNotEmpty ||
              _singleUrlController.text.trim().isNotEmpty;
        }
        if (mode == ScanMode.selectionBatch) {
          return _selectedImagePaths.isNotEmpty ||
              _selectedVideoPaths.isNotEmpty;
        }
        if (mode == ScanMode.wholeGallery) {
          return _galleryIncludeImages || _galleryIncludeVideos;
        }
        return false;
      case 2:
        return true;
      default:
        return false;
    }
  }

  void _nextStep() {
    if (_running) {
      return;
    }
    if (_stepIndex < 2) {
      if (!_isStepReady(_stepIndex)) {
        setState(() {
          _status = 'Bitte den aktuellen Schritt zuerst abschließen.';
        });
        return;
      }
      setState(() {
        _stepIndex += 1;
      });
      return;
    }

    if (_stepIndex == 2) {
      unawaited(_runScan());
    }
  }

  void _previousStep() {
    if (_running) {
      return;
    }
    if (_stepIndex == 0) {
      return;
    }
    setState(() {
      _stepIndex -= 1;
    });
  }

  Future<void> _cancelCurrentScan() async {
    await _plugin.cancelScan();
    if (!mounted) {
      return;
    }
    setState(() {
      _status = 'Abbruch angefordert';
    });
  }

  void _restartFromBeginning() {
    if (_running) {
      return;
    }
    _stopUiPolling(flush: false);
    setState(() {
      _stepIndex = 0;
      _mode = null;
      _scanDone = false;
      _phase = 'Idle';
      _status = 'Wähle einen Scan-Modus';
      _processed = 0;
      _total = 0;
      _liveRows.clear();
      _pendingRows.clear();
      _thumbnailPathByRef.clear();
      _thumbnailLoadingRefs.clear();
      _fullImagePathByRef.clear();
      _pageIndex = 0;
      _uiDirty = false;
      _singlePathController.clear();
      _singleUrlController.clear();
      _selectedImagePaths = const [];
      _selectedVideoPaths = const [];
      _singleSaveDownloadedFile = false;
    });
  }

  void _startUiPolling() {
    _uiTimer?.cancel();
    _uiTimer = Timer.periodic(_uiPollingInterval, (_) {
      if (!mounted) {
        return;
      }
      if (_pendingRows.isEmpty && !_uiDirty) {
        return;
      }

      final appended = List<_LiveResultRow>.from(_pendingRows);
      _pendingRows.clear();

      setState(() {
        if (appended.isNotEmpty) {
          _liveRows.addAll(appended);
          _pageIndex = _pageIndex.clamp(0, _pageCount - 1).toInt();
        }
        _uiDirty = false;
      });
    });
  }

  void _stopUiPolling({bool flush = true}) {
    _uiTimer?.cancel();
    _uiTimer = null;
    if (!flush || !mounted) {
      return;
    }

    if (_pendingRows.isEmpty && !_uiDirty) {
      return;
    }

    setState(() {
      if (_pendingRows.isNotEmpty) {
        _liveRows.addAll(_pendingRows);
        _pendingRows.clear();
        _pageIndex = _pageIndex.clamp(0, _pageCount - 1).toInt();
      }
      _uiDirty = false;
    });
  }

  void _recordProgress({
    required int processed,
    required int total,
    required String phase,
  }) {
    _processed = processed;
    _total = total;
    _phase = phase;
    _uiDirty = true;
  }

  void _enqueueRows(List<_LiveResultRow> rows) {
    if (rows.isEmpty) {
      return;
    }
    _pendingRows.addAll(rows);
    _uiDirty = true;
  }

  Future<void> _runScan() async {
    if (!_initialized || _mode == null) {
      return;
    }

    setState(() {
      _stepIndex = 3;
      _running = true;
      _scanDone = false;
      _phase = 'Scan startet...';
      _status = 'Scan läuft';
      _processed = 0;
      _total = 0;
      _liveRows.clear();
      _pendingRows.clear();
      _thumbnailPathByRef.clear();
      _thumbnailLoadingRefs.clear();
      _fullImagePathByRef.clear();
      _pageIndex = 0;
    });

    _startUiPolling();

    try {
      switch (_mode!) {
        case ScanMode.single:
          await _runSingleFlow();
          break;
        case ScanMode.selectionBatch:
          await _runSelectionBatchFlow();
          break;
        case ScanMode.wholeGallery:
          await _runWholeGalleryFlow();
          break;
      }
      _status = 'Warte auf Hintergrund-Uploads...';
      _recordProgress(
        processed: _processed,
        total: math.max(_total, _processed),
        phase: 'Treffer werden hochgeladen',
      );
      await _plugin.waitForPendingUploads();
      await _refreshBackgroundJobs();
      _status = 'Scan und Uploads abgeschlossen';
    } catch (error) {
      _status = 'Scan fehlgeschlagen: $error';
    } finally {
      await _refreshBackgroundJobs();
      _running = false;
      _scanDone = true;
      _uiDirty = true;
      _stopUiPolling(flush: true);
      if (mounted) {
        setState(() {});
      }
    }
  }

  Future<void> _runSingleFlow() async {
    final path = _singlePathController.text.trim();
    final mediaUrl = _singleUrlController.text.trim();
    if (path.isEmpty && mediaUrl.isNotEmpty) {
      _recordProgress(
        processed: 0,
        total: 1,
        phase: 'URL wird geladen und gescannt',
      );
      final item = await _plugin.scanMediaFromUrl(
        mediaUrl: mediaUrl,
        saveDownloadedFile: _singleSaveDownloadedFile,
        settings: _scanSettings,
        onProgress: (progress) {
          _recordProgress(
            processed: progress.processed,
            total: progress.total,
            phase: 'Video-Frames werden analysiert',
          );
        },
      );
      _recordProgress(processed: 1, total: 1, phase: 'URL-Scan abgeschlossen');
      if (item.type == NsfwMediaType.video && item.videoResult != null) {
        _enqueueRows([
          _LiveResultRow(
            path: item.videoResult!.videoPath,
            assetRef: item.videoResult!.videoPath,
            type: NsfwMediaType.video,
            isNsfw: item.videoResult!.isNsfw,
            score: item.videoResult!.maxNsfwScore,
            error: item.error,
          ),
        ]);
        return;
      }
      final imageResult = item.imageResult;
      if (imageResult != null) {
        _enqueueRows([
          _LiveResultRow(
            path: imageResult.imagePath,
            assetRef: imageResult.imagePath,
            type: NsfwMediaType.image,
            isNsfw: imageResult.isNsfw,
            score: imageResult.nsfwScore,
            error: item.error,
          ),
        ]);
      }
      return;
    }
    final isVideo = _looksLikeVideo(path);

    _recordProgress(processed: 0, total: 1, phase: 'Einzeldatei wird gescannt');

    if (isVideo) {
      final result = await _plugin.scanVideo(
        videoPath: path,
        threshold: _videoThreshold,
        onProgress: (progress) {
          _recordProgress(
            processed: progress.processed,
            total: progress.total,
            phase: 'Video-Frames werden analysiert',
          );
        },
      );

      _enqueueRows([
        _LiveResultRow(
          path: result.videoPath,
          assetRef: result.videoPath,
          type: NsfwMediaType.video,
          isNsfw: result.isNsfw,
          score: result.maxNsfwScore,
        ),
      ]);
    } else {
      final result = await _plugin.scanImage(
        imagePath: path,
        threshold: _imageThreshold,
      );
      _recordProgress(processed: 1, total: 1, phase: 'Einzelbild analysiert');
      _enqueueRows([
        _LiveResultRow(
          path: result.imagePath,
          assetRef: result.imagePath,
          type: NsfwMediaType.image,
          isNsfw: result.isNsfw,
          score: result.nsfwScore,
        ),
      ]);
    }
  }

  Future<void> _runSelectionBatchFlow() async {
    final media = <NsfwMediaInput>[
      ..._selectedImagePaths.map(NsfwMediaInput.image),
      ..._selectedVideoPaths.map(NsfwMediaInput.video),
    ];
    if (media.isEmpty) {
      throw const FormatException('Keine Medien für Batch-Scan ausgewählt.');
    }

    final total = media.length;
    const chunkSize = 80;
    var processedBase = 0;

    for (var start = 0; start < total; start += chunkSize) {
      final end = math.min(start + chunkSize, total);
      final chunk = media.sublist(start, end);

      final chunkResult = await _plugin.scanMediaBatch(
        media: chunk,
        settings: _scanSettings,
        onProgress: (progress) {
          _recordProgress(
            processed: processedBase + progress.processed,
            total: total,
            phase:
                'Batch-Scan läuft (${processedBase + progress.processed}/$total)',
          );
        },
      );

      processedBase += chunkResult.processed;
      _recordProgress(
        processed: processedBase,
        total: total,
        phase: 'Batch-Chunk abgeschlossen',
      );

      _enqueueRows(_rowsFromBatchItems(chunkResult.items));
    }
  }

  Future<void> _runWholeGalleryFlow() async {
    final parsedMaxItems = int.tryParse(_galleryMaxItemsController.text.trim());

    await _plugin.scanWholeGallery(
      settings: _scanSettings,
      includeImages: _galleryIncludeImages,
      includeVideos: _galleryIncludeVideos,
      maxItems: parsedMaxItems,
      pageSize: 140,
      scanChunkSize: 80,
      loadProgressEvery: 40,
      includeCleanResults: false,
      debugLogging: _galleryDebugLogging,
      onLoadProgress: (progress) {
        if (_galleryDebugLogging) {
          debugPrint(
            '[gallery][load] scanned=${progress.scannedAssets} images=${progress.imageCount} videos=${progress.videoCount} done=${progress.isCompleted}',
          );
        }
        _recordProgress(
          processed: progress.scannedAssets,
          total: progress.targetCount ?? math.max(progress.scannedAssets, 1),
          phase:
              'Galerie laden: ${progress.imageCount} Bilder, ${progress.videoCount} Videos',
        );
      },
      onScanProgress: (progress) {
        if (_galleryDebugLogging &&
            (progress.processed % 100 == 0 ||
                progress.processed == progress.total)) {
          debugPrint(
            '[gallery][scan] ${progress.processed}/${progress.total} percent=${progress.percent.toStringAsFixed(3)}',
          );
        }
        _recordProgress(
          processed: progress.processed,
          total: progress.total,
          phase: 'Galerie-Scan läuft',
        );
      },
      onChunkResult: (chunkResult) {
        if (_galleryDebugLogging) {
          debugPrint(
            '[gallery][chunk] items=${chunkResult.items.length} success=${chunkResult.successCount} errors=${chunkResult.errorCount} flagged=${chunkResult.flaggedCount}',
          );
        }
        _enqueueRows(_rowsFromBatchItems(chunkResult.items));
      },
    );
  }

  List<_LiveResultRow> _rowsFromBatchItems(
    List<NsfwMediaBatchItemResult> items,
  ) {
    final rows = <_LiveResultRow>[];
    for (final item in items) {
      if (!item.isNsfw && !item.hasError) {
        continue;
      }
      if (item.type == NsfwMediaType.video) {
        rows.add(
          _LiveResultRow(
            path: item.path,
            assetRef: _resolveAssetRef(item),
            type: NsfwMediaType.video,
            isNsfw: item.videoResult?.isNsfw ?? false,
            score: item.videoResult?.maxNsfwScore ?? 0,
            error: item.error,
          ),
        );
      } else {
        rows.add(
          _LiveResultRow(
            path: item.path,
            assetRef: _resolveAssetRef(item),
            type: NsfwMediaType.image,
            isNsfw: item.imageResult?.isNsfw ?? false,
            score: item.imageResult?.nsfwScore ?? 0,
            error: item.error,
          ),
        );
      }
    }
    return rows;
  }

  String _resolveAssetRef(NsfwMediaBatchItemResult item) {
    final uri = item.uri?.trim();
    if (uri != null && uri.isNotEmpty) {
      return uri;
    }
    final assetId = item.assetId?.trim();
    if (assetId != null && assetId.isNotEmpty) {
      return assetId;
    }
    return item.path.trim();
  }

  void _scheduleVisibleThumbnailLoads(List<_LiveResultRow> rows) {
    for (final row in rows) {
      if (row.type != NsfwMediaType.image) {
        continue;
      }
      unawaited(_loadVisibleThumbnail(row));
    }
  }

  Future<void> _loadVisibleThumbnail(_LiveResultRow row) async {
    final ref = row.assetRef.trim();
    if (ref.isEmpty || _thumbnailPathByRef.containsKey(ref)) {
      return;
    }
    if (_thumbnailLoadingRefs.contains(ref)) {
      return;
    }
    _thumbnailLoadingRefs.add(ref);
    try {
      final thumbnailPath = await _plugin.loadImageThumbnail(
        assetRef: ref,
        width: 160,
        height: 160,
        quality: 70,
      );
      if (!mounted) {
        return;
      }
      setState(() {
        _thumbnailPathByRef[ref] = thumbnailPath;
      });
    } catch (error) {
      if (_galleryDebugLogging) {
        debugPrint('[gallery][thumb] failed ref=$ref error=$error');
      }
      if (mounted) {
        setState(() {
          _thumbnailPathByRef[ref] = null;
        });
      }
    } finally {
      _thumbnailLoadingRefs.remove(ref);
    }
  }

  Future<void> _showFullImagePreview(_LiveResultRow item) async {
    if (item.type != NsfwMediaType.image) {
      return;
    }
    final ref = item.assetRef.trim();
    if (ref.isEmpty) {
      return;
    }

    showDialog<void>(
      context: context,
      barrierDismissible: false,
      builder: (_) => const Center(child: CircularProgressIndicator()),
    );

    String? fullImagePath;
    String? errorMessage;
    try {
      fullImagePath = _fullImagePathByRef[ref];
      fullImagePath ??= await _plugin.loadImageAsset(assetRef: ref);
      if (fullImagePath != null && fullImagePath.isNotEmpty) {
        _fullImagePathByRef[ref] = fullImagePath;
      }
    } catch (error) {
      errorMessage = '$error';
    } finally {
      if (mounted) {
        Navigator.of(context, rootNavigator: true).pop();
      }
    }

    if (!mounted) {
      return;
    }

    if (fullImagePath == null || fullImagePath.isEmpty) {
      final message =
          errorMessage ?? 'Originalbild konnte nicht geladen werden.';
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text(message)));
      return;
    }

    final file = File(fullImagePath);
    if (!file.existsSync()) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('Geladenes Bild ist nicht mehr verfügbar.'),
        ),
      );
      return;
    }

    await showDialog<void>(
      context: context,
      builder: (context) {
        return Dialog(
          backgroundColor: Colors.black,
          insetPadding: const EdgeInsets.all(12),
          child: Stack(
            children: [
              InteractiveViewer(
                minScale: 0.6,
                maxScale: 4.0,
                child: Image.file(file, fit: BoxFit.contain),
              ),
              Positioned(
                top: 8,
                right: 8,
                child: IconButton.filled(
                  onPressed: () => Navigator.of(context).pop(),
                  icon: const Icon(Icons.close),
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  bool _looksLikeVideo(String path) {
    final p = path.toLowerCase();
    return p.endsWith('.mp4') ||
        p.endsWith('.mov') ||
        p.endsWith('.mkv') ||
        p.endsWith('.avi') ||
        p.endsWith('.m4v') ||
        p.endsWith('.webm');
  }

  int get _pageCount => math.max(1, (_liveRows.length / _pageSize).ceil());

  List<_LiveResultRow> get _currentPageRows {
    if (_liveRows.isEmpty) {
      return const [];
    }
    final safePage = _pageIndex.clamp(0, _pageCount - 1);
    final start = safePage * _pageSize;
    final end = math.min(start + _pageSize, _liveRows.length);
    return _liveRows.sublist(start, end);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('NSFW Scan Wizard'),
        actions: [
          if (_running)
            IconButton(
              tooltip: 'Scan abbrechen',
              onPressed: _cancelCurrentScan,
              icon: const Icon(Icons.stop_circle_outlined),
            ),
        ],
      ),
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            colors: [Color(0xFFF3F7F8), Color(0xFFE7F0F2)],
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
          ),
        ),
        child: SafeArea(
          child: Column(
            children: [
              _buildStepHeader(),
              const Divider(height: 1),
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
                  child: AnimatedSwitcher(
                    duration: const Duration(milliseconds: 180),
                    child: KeyedSubtree(
                      key: ValueKey<int>(_stepIndex),
                      child: _buildStepContent(),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
      bottomNavigationBar: _buildBottomBar(),
    );
  }

  Widget _buildStepHeader() {
    return SizedBox(
      height: 62,
      child: ListView.separated(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
        scrollDirection: Axis.horizontal,
        itemCount: _stepLabels.length,
        separatorBuilder: (_, _) => const SizedBox(width: 8),
        itemBuilder: (context, index) {
          final isCurrent = _stepIndex == index;
          final isDone = index < _stepIndex || (index == 3 && _scanDone);
          final chipColor = isCurrent
              ? const Color(0xFF0A7A8C)
              : (isDone ? const Color(0xFF2E7D32) : Colors.white);
          final textColor = isCurrent || isDone
              ? Colors.white
              : const Color(0xFF33464D);

          return AnimatedContainer(
            duration: const Duration(milliseconds: 140),
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
            decoration: BoxDecoration(
              color: chipColor,
              borderRadius: BorderRadius.circular(24),
              border: Border.all(color: const Color(0xFFB7C9CD)),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(
                  isDone
                      ? Icons.check_circle
                      : (isCurrent
                            ? Icons.radio_button_checked
                            : Icons.radio_button_unchecked),
                  size: 16,
                  color: textColor,
                ),
                const SizedBox(width: 8),
                Text(
                  _stepLabels[index],
                  style: TextStyle(
                    color: textColor,
                    fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w600,
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  Widget _buildStepContent() {
    if (_stepIndex == 3) {
      return _buildLiveResultsStep();
    }

    final content = switch (_stepIndex) {
      0 => _buildModeStep(),
      1 => _buildPreparationStep(),
      2 => _buildReviewStep(),
      _ => _buildModeStep(),
    };

    return SingleChildScrollView(child: content);
  }

  Widget _buildBottomBar() {
    final canGoBack = !_running && _stepIndex > 0 && _stepIndex < 3;
    final canGoForward = !_running && _stepIndex < 3;

    return SafeArea(
      top: false,
      child: Container(
        padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
        decoration: const BoxDecoration(
          color: Colors.white,
          boxShadow: [
            BoxShadow(
              color: Color(0x22000000),
              blurRadius: 10,
              offset: Offset(0, -2),
            ),
          ],
        ),
        child: Row(
          children: [
            if (_stepIndex < 3) ...[
              OutlinedButton(
                onPressed: canGoBack ? _previousStep : null,
                child: const Text('Zurück'),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: FilledButton(
                  onPressed: canGoForward ? _nextStep : null,
                  child: Text(_stepIndex >= 2 ? 'Scan starten' : 'Weiter'),
                ),
              ),
            ] else ...[
              Expanded(
                child: FilledButton.icon(
                  onPressed: _running ? null : _restartFromBeginning,
                  icon: const Icon(Icons.restart_alt),
                  label: const Text('Von vorne starten'),
                ),
              ),
              if (_running) ...[
                const SizedBox(width: 8),
                OutlinedButton.icon(
                  onPressed: _cancelCurrentScan,
                  icon: const Icon(Icons.stop_circle_outlined),
                  label: const Text('Abbrechen'),
                ),
              ],
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildModeStep() {
    if (_initializing) {
      return const LinearProgressIndicator(minHeight: 3);
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Wrap(
          spacing: 12,
          runSpacing: 12,
          children: [
            _modeCard(
              mode: ScanMode.single,
              icon: Icons.image_search,
              title: 'Single Scan',
              subtitle: 'Ein Bild/Video oder eine URL schnell prüfen',
            ),
            _modeCard(
              mode: ScanMode.selectionBatch,
              icon: Icons.collections,
              title: 'Batch Scan',
              subtitle: 'Ausgewählte Medien in Chunks scannen',
            ),
            _modeCard(
              mode: ScanMode.wholeGallery,
              icon: Icons.photo_library,
              title: 'Gallery Scan',
              subtitle: 'Komplette Galerie nativ scannen',
            ),
          ],
        ),
        const SizedBox(height: 12),
        Text(
          _status,
          style: const TextStyle(fontSize: 13, color: Colors.black87),
        ),
      ],
    );
  }

  Widget _modeCard({
    required ScanMode mode,
    required IconData icon,
    required String title,
    required String subtitle,
  }) {
    final selected = _mode == mode;
    return SizedBox(
      width: 220,
      child: InkWell(
        borderRadius: BorderRadius.circular(16),
        onTap: () {
          setState(() {
            _mode = mode;
          });
        },
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 180),
          padding: const EdgeInsets.all(14),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(16),
            color: selected ? const Color(0xFF0A7A8C) : Colors.white,
            border: Border.all(
              color: selected
                  ? const Color(0xFF0A7A8C)
                  : const Color(0xFFB7C9CD),
              width: selected ? 2 : 1,
            ),
            boxShadow: const [
              BoxShadow(
                color: Color(0x12000000),
                blurRadius: 10,
                offset: Offset(0, 4),
              ),
            ],
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Icon(
                icon,
                color: selected ? Colors.white : const Color(0xFF0A7A8C),
              ),
              const SizedBox(height: 8),
              Text(
                title,
                style: TextStyle(
                  fontWeight: FontWeight.w700,
                  color: selected ? Colors.white : Colors.black87,
                ),
              ),
              const SizedBox(height: 4),
              Text(
                subtitle,
                style: TextStyle(
                  fontSize: 12,
                  color: selected ? Colors.white70 : Colors.black54,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildPreparationStep() {
    final mode = _mode;
    if (mode == null) {
      return const Text('Bitte zuerst einen Scan-Typ wählen.');
    }

    final controls = _buildCommonControls();

    if (mode == ScanMode.single) {
      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          controls,
          const SizedBox(height: 8),
          const Text(
            'Single-Scan unterstützt Datei-Auswahl und direkte URL-Scans.',
            style: TextStyle(fontSize: 13, color: Colors.black87),
          ),
          const SizedBox(height: 12),
          Row(
            children: [
              FilledButton.icon(
                onPressed: _running ? null : _pickSingleMedia,
                icon: const Icon(Icons.add_photo_alternate_outlined),
                label: const Text('Datei wählen'),
              ),
            ],
          ),
          const SizedBox(height: 8),
          TextField(
            controller: _singlePathController,
            decoration: const InputDecoration(
              border: OutlineInputBorder(),
              labelText: 'Pfad (Bild oder Video)',
            ),
          ),
          const SizedBox(height: 10),
          NsfwUrlScanCard(
            urlController: _singleUrlController,
            saveDownloadedFile: _singleSaveDownloadedFile,
            enabled: !_running,
            onSaveDownloadedFileChanged: (value) {
              setState(() {
                _singleSaveDownloadedFile = value;
              });
            },
          ),
        ],
      );
    }

    if (mode == ScanMode.selectionBatch) {
      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          controls,
          const SizedBox(height: 12),
          FilledButton.icon(
            onPressed: _running ? null : _pickBatchMedia,
            icon: const Icon(Icons.add_photo_alternate_outlined),
            label: const Text('Mehrere Medien wählen'),
          ),
          const SizedBox(height: 8),
          Text(
            'Auswahl: ${_selectedImagePaths.length} Bilder, ${_selectedVideoPaths.length} Videos',
          ),
        ],
      );
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        controls,
        const SizedBox(height: 12),
        SwitchListTile.adaptive(
          value: _galleryIncludeImages,
          onChanged: _running
              ? null
              : (value) => setState(() => _galleryIncludeImages = value),
          title: const Text('Bilder scannen'),
        ),
        SwitchListTile.adaptive(
          value: _galleryIncludeVideos,
          onChanged: _running
              ? null
              : (value) => setState(() => _galleryIncludeVideos = value),
          title: const Text('Videos scannen'),
        ),
        SwitchListTile.adaptive(
          value: _galleryDebugLogging,
          onChanged: _running
              ? null
              : (value) => setState(() => _galleryDebugLogging = value),
          title: const Text('Debug Logging (nativ)'),
          subtitle: const Text('Für Simulator/DevTools Analyse aktiv lassen'),
        ),
        const SizedBox(height: 8),
        TextField(
          controller: _galleryMaxItemsController,
          keyboardType: TextInputType.number,
          decoration: const InputDecoration(
            border: OutlineInputBorder(),
            labelText: 'Max Items (optional, z.B. 2000)',
          ),
        ),
      ],
    );
  }

  Widget _buildCommonControls() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('Image Threshold: ${_imageThreshold.toStringAsFixed(2)}'),
        Slider(
          value: _imageThreshold,
          min: 0.2,
          max: 0.95,
          onChanged: _running
              ? null
              : (value) => setState(() => _imageThreshold = value),
        ),
        Text('Video Threshold: ${_videoThreshold.toStringAsFixed(2)}'),
        Slider(
          value: _videoThreshold,
          min: 0.2,
          max: 0.95,
          onChanged: _running
              ? null
              : (value) => setState(() => _videoThreshold = value),
        ),
        Text('Max Concurrency: $_maxConcurrency'),
        Slider(
          value: _maxConcurrency.toDouble(),
          min: 1,
          max: 8,
          divisions: 7,
          onChanged: _running
              ? null
              : (value) => setState(() => _maxConcurrency = value.round()),
        ),
      ],
    );
  }

  Widget _buildReviewStep() {
    final mode = _mode;
    if (mode == null) {
      return const Text('Bitte Scan-Typ wählen.');
    }

    final modeLabel = switch (mode) {
      ScanMode.single => 'Single Scan',
      ScanMode.selectionBatch => 'Batch Scan',
      ScanMode.wholeGallery => 'Gallery Scan',
    };

    return Card(
      color: Colors.white,
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Konfiguration',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Text('Modus: $modeLabel'),
            Text('Image threshold: ${_imageThreshold.toStringAsFixed(2)}'),
            Text('Video threshold: ${_videoThreshold.toStringAsFixed(2)}'),
            Text('Concurrency: $_maxConcurrency'),
            if (mode == ScanMode.single)
              Text(
                _singlePathController.text.trim().isNotEmpty
                    ? 'Pfad: ${_singlePathController.text.trim()}'
                    : 'URL: ${_singleUrlController.text.trim()}',
              ),
            if (mode == ScanMode.selectionBatch)
              Text(
                'Auswahl: ${_selectedImagePaths.length} Bilder, ${_selectedVideoPaths.length} Videos',
              ),
            if (mode == ScanMode.wholeGallery)
              Text(
                'Gallery: images=$_galleryIncludeImages, videos=$_galleryIncludeVideos, maxItems=${_galleryMaxItemsController.text.trim().isEmpty ? 'unbegrenzt' : _galleryMaxItemsController.text.trim()}',
              ),
            const SizedBox(height: 8),
            Text(
              'Nach Start werden Ergebnisse live gesammelt und alle ${_uiPollingInterval.inMilliseconds}ms in der UI aktualisiert.',
              style: const TextStyle(fontSize: 12, color: Colors.black54),
            ),
            const SizedBox(height: 12),
            _buildBackgroundProcessingCard(),
          ],
        ),
      ),
    );
  }

  Widget _buildLiveResultsStep() {
    final ratio = _total <= 0 ? 0.0 : (_processed / _total).clamp(0.0, 1.0);
    final currentRows = _currentPageRows;
    _scheduleVisibleThumbnailLoads(currentRows);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Card(
          color: Colors.white,
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  _running
                      ? 'Scan läuft'
                      : (_scanDone ? 'Scan abgeschlossen' : 'Bereit'),
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 6),
                Text(_phase),
                const SizedBox(height: 6),
                LinearProgressIndicator(
                  value: _running ? ratio : (_scanDone ? 1 : 0),
                ),
                const SizedBox(height: 6),
                Text('Fortschritt: $_processed / $_total'),
                Text('Ergebnisse: ${_liveRows.length}'),
                Text(_status),
                const SizedBox(height: 12),
                _buildBackgroundProcessingCard(compact: true),
              ],
            ),
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            OutlinedButton.icon(
              onPressed: _pageIndex > 0
                  ? () => setState(() => _pageIndex -= 1)
                  : null,
              icon: const Icon(Icons.chevron_left),
              label: const Text('Vorherige'),
            ),
            const SizedBox(width: 8),
            Text('Seite ${_pageIndex + 1} / $_pageCount'),
            const SizedBox(width: 8),
            OutlinedButton.icon(
              onPressed: _pageIndex < _pageCount - 1
                  ? () => setState(() => _pageIndex += 1)
                  : null,
              icon: const Icon(Icons.chevron_right),
              label: const Text('Nächste'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        Expanded(
          child: currentRows.isEmpty
              ? const Center(child: Text('Noch keine Ergebnisse verfügbar.'))
              : ListView.separated(
                  itemCount: currentRows.length,
                  separatorBuilder: (_, _) => const Divider(height: 1),
                  itemBuilder: (context, index) {
                    final item = currentRows[index];
                    return ListTile(
                      onTap: item.type == NsfwMediaType.image
                          ? () => _showFullImagePreview(item)
                          : null,
                      leading: _buildPreview(item),
                      title: Text(
                        item.path,
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                      subtitle: Text(
                        item.hasError
                            ? 'Fehler: ${item.error}'
                            : '${item.type == NsfwMediaType.video ? 'Video' : 'Bild'} • Score ${item.score.toStringAsFixed(3)}',
                      ),
                      trailing: item.hasError
                          ? const Icon(Icons.error_outline, color: Colors.red)
                          : Icon(
                              item.isNsfw
                                  ? Icons.warning_amber_rounded
                                  : Icons.verified,
                              color: item.isNsfw ? Colors.orange : Colors.green,
                            ),
                    );
                  },
                ),
        ),
      ],
    );
  }

  Widget _buildBackgroundProcessingCard({bool compact = false}) {
    final activeJob = _backgroundJobs
        .where((job) => job.isActive)
        .fold<NsfwBackgroundJob?>(null, (previous, job) => previous ?? job);

    return Card(
      margin: EdgeInsets.zero,
      color: const Color(0xFFF8FBFC),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Background Processing',
              style: Theme.of(context).textTheme.titleSmall,
            ),
            const SizedBox(height: 6),
            Text(
              activeJob == null
                  ? 'Kein aktiver Background-Job'
                  : 'Aktiv: ${activeJob.type.name} • ${activeJob.status.name} • ${activeJob.processed}/${activeJob.total}',
            ),
            if (!compact) ...[
              const SizedBox(height: 4),
              Text(
                'Auto-Resume für Whole-Gallery-Scans und persistente Upload-Queue sind in diesem Example aktiv.',
                style: const TextStyle(fontSize: 12, color: Colors.black54),
              ),
            ],
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                OutlinedButton.icon(
                  onPressed: _backgroundBusy
                      ? null
                      : () => _runBackgroundAction(() async {
                          final resumed = await _plugin
                              .resumePendingBackgroundJobs();
                          _status = resumed
                              ? 'Persistenter Background-Job wurde fortgesetzt.'
                              : 'Kein pausierter Background-Job vorhanden.';
                        }, busyLabel: 'Background-Jobs werden geprüft...'),
                  icon: const Icon(Icons.play_arrow),
                  label: const Text('Resume'),
                ),
                OutlinedButton.icon(
                  onPressed: _backgroundBusy || activeJob == null
                      ? null
                      : () => _runBackgroundAction(() async {
                          final paused = await _plugin.pauseWholeGalleryScan();
                          _status = paused
                              ? 'Whole-Gallery-Scan pausiert.'
                              : 'Kein pausierbarer Gallery-Scan aktiv.';
                        }, busyLabel: 'Gallery-Scan wird pausiert...'),
                  icon: const Icon(Icons.pause_circle_outline),
                  label: const Text('Pause Scan'),
                ),
                OutlinedButton.icon(
                  onPressed: _backgroundBusy || activeJob == null
                      ? null
                      : () => _runBackgroundAction(() async {
                          final cancelled = await _plugin
                              .cancelWholeGalleryScan();
                          _status = cancelled
                              ? 'Whole-Gallery-Scan abgebrochen.'
                              : 'Kein aktiver Gallery-Scan vorhanden.';
                        }, busyLabel: 'Gallery-Scan wird abgebrochen...'),
                  icon: const Icon(Icons.stop_circle_outlined),
                  label: const Text('Cancel Scan'),
                ),
                OutlinedButton.icon(
                  onPressed: _backgroundBusy
                      ? null
                      : () => _runBackgroundAction(() async {
                          await _plugin.clearFinishedBackgroundJobs();
                          _status = 'Abgeschlossene Background-Jobs gelöscht.';
                        }, busyLabel: 'Background-Historie wird bereinigt...'),
                  icon: const Icon(Icons.cleaning_services_outlined),
                  label: const Text('Clear Finished'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildPreview(_LiveResultRow item) {
    final fallbackIcon = CircleAvatar(
      backgroundColor: const Color(0xFFE5EEF0),
      child: Icon(
        item.type == NsfwMediaType.video ? Icons.movie : Icons.image,
        color: const Color(0xFF0A7A8C),
      ),
    );

    if (item.type != NsfwMediaType.image) {
      return fallbackIcon;
    }

    final ref = item.assetRef.trim();
    final thumbnailPath = _thumbnailPathByRef[ref];
    if (thumbnailPath != null && thumbnailPath.isNotEmpty) {
      final file = File(thumbnailPath);
      if (file.existsSync()) {
        return ClipRRect(
          borderRadius: BorderRadius.circular(6),
          child: Image.file(
            file,
            width: 42,
            height: 42,
            fit: BoxFit.cover,
            errorBuilder: (_, _, _) => fallbackIcon,
          ),
        );
      }
    }

    final path = item.path;
    if (path.startsWith('/')) {
      final file = File(path);
      if (file.existsSync()) {
        return ClipRRect(
          borderRadius: BorderRadius.circular(6),
          child: Image.file(
            file,
            width: 42,
            height: 42,
            fit: BoxFit.cover,
            errorBuilder: (_, _, _) => fallbackIcon,
          ),
        );
      }
    }

    if (_thumbnailLoadingRefs.contains(ref)) {
      return CircleAvatar(
        backgroundColor: const Color(0xFFE5EEF0),
        child: SizedBox(
          width: 16,
          height: 16,
          child: CircularProgressIndicator(
            strokeWidth: 2,
            color: const Color(0xFF0A7A8C),
          ),
        ),
      );
    }
    return fallbackIcon;
  }
}
0
likes
140
points
0
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

On-device NSFW detection for Flutter (Android/iOS) with TensorFlow Lite: image/video scans, mixed batch and gallery scanning, progress streaming, and cancellation.

Homepage

License

MIT (license)

Dependencies

crypto, encrypt, flutter, path_provider, plugin_platform_interface

More

Packages that depend on flutter_nsfw_scaner

Packages that implement flutter_nsfw_scaner