flutter_file_hash 0.0.4 copy "flutter_file_hash: ^0.0.4" to clipboard
flutter_file_hash: ^0.0.4 copied to clipboard

Native Flutter file and string hashing powered by Zig, with SHA, MD5, HMAC, BLAKE3, XXH3, cancellation, mmap, and Android content:// support.

example/lib/main.dart

import 'dart:async';
import 'dart:convert' as convert;
import 'dart:io';
import 'dart:math' as math;

import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_file_hash/flutter_file_hash.dart';

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

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

  @override
  Widget build(BuildContext context) {
    const seed = Color(0xff2563eb);

    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: seed),
        inputDecorationTheme: _inputTheme(Brightness.light),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xff7dd3fc),
          brightness: Brightness.dark,
        ),
        inputDecorationTheme: _inputTheme(Brightness.dark),
        useMaterial3: true,
      ),
      home: const HashDemoPage(),
    );
  }
}

InputDecorationTheme _inputTheme(Brightness brightness) {
  final isDark = brightness == Brightness.dark;
  final borderColor = isDark
      ? const Color(0xff1f242e)
      : const Color(0xffe5e7eb);
  final fillColor = isDark ? const Color(0xff11151d) : const Color(0xfff5f7fb);
  final border = OutlineInputBorder(
    borderRadius: BorderRadius.circular(8),
    borderSide: BorderSide(color: borderColor),
  );

  return InputDecorationTheme(
    border: border,
    enabledBorder: border,
    focusedBorder: border.copyWith(
      borderSide: BorderSide(
        color: isDark ? const Color(0xff7dd3fc) : const Color(0xff2563eb),
        width: 1.4,
      ),
    ),
    filled: true,
    fillColor: fillColor,
    isDense: true,
  );
}

const MethodChannel _androidFilePickerChannel = MethodChannel(
  'flutter_file_hash_example/file_picker',
);

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

  @override
  State<HashDemoPage> createState() => _HashDemoPageState();
}

class _HashDemoPageState extends State<HashDemoPage> {
  static const List<HashAlgorithm> _algorithms = [
    HashAlgorithm.md5,
    HashAlgorithm.sha1,
    HashAlgorithm.sha224,
    HashAlgorithm.sha256,
    HashAlgorithm.sha384,
    HashAlgorithm.sha512,
    HashAlgorithm.sha512_224,
    HashAlgorithm.sha512_256,
    HashAlgorithm.xxh3_64,
    HashAlgorithm.blake3,
    HashAlgorithm.hmacSha224,
    HashAlgorithm.hmacSha256,
    HashAlgorithm.hmacSha384,
    HashAlgorithm.hmacSha512,
    HashAlgorithm.hmacMd5,
    HashAlgorithm.hmacSha1,
  ];

  static const List<HashAlgorithm> _benchmarkAlgorithms = [
    HashAlgorithm.sha256,
    HashAlgorithm.md5,
    HashAlgorithm.sha1,
    HashAlgorithm.sha224,
    HashAlgorithm.sha384,
    HashAlgorithm.sha512,
    HashAlgorithm.sha512_224,
    HashAlgorithm.sha512_256,
    HashAlgorithm.hmacSha224,
    HashAlgorithm.hmacSha256,
    HashAlgorithm.hmacSha384,
    HashAlgorithm.hmacSha512,
    HashAlgorithm.hmacMd5,
    HashAlgorithm.hmacSha1,
    HashAlgorithm.blake3,
    HashAlgorithm.xxh3_64,
  ];

  static const String _benchmarkKey = 'react-native-file-hash-benchmark-key';

  final TextEditingController _textController = TextEditingController(
    text: 'hello world',
  );
  final TextEditingController _keyController = TextEditingController();
  final TextEditingController _seedLabelController = TextEditingController();
  final TextEditingController _seedValueController = TextEditingController();
  final TextEditingController _benchmarkSizeController = TextEditingController(
    text: '200',
  );
  final TextEditingController _benchmarkSamplesController =
      TextEditingController(text: '3');
  final TextEditingController _benchmarkWarmupsController =
      TextEditingController(text: '1');

  HashAlgorithm _selectedAlgorithm = HashAlgorithm.sha256;
  HashInputEncoding _textEncoding = HashInputEncoding.utf8;
  KeyEncoding _keyEncoding = KeyEncoding.utf8;
  _SeedInputMode _seedInputMode = _SeedInputMode.fromLabel;
  bool _useMmap = false;

  _PickedFile? _pickedFile;

  String _fileHash = '';
  String? _fileStatus;
  int? _fileElapsedMs;
  bool _fileRunning = false;
  HashCancellationController? _fileCancellation;

  String _textHash = '';
  String? _textStatus;
  int? _textElapsedMs;
  bool _textRunning = false;
  HashCancellationController? _textCancellation;

  String? _benchmarkStatus;
  bool _benchmarkRunning = false;
  List<_BenchmarkResult> _benchmarkResults = const [];
  HashCancellationController? _benchmarkCancellation;

  @override
  void dispose() {
    _fileCancellation?.cancel();
    _textCancellation?.cancel();
    _benchmarkCancellation?.cancel();
    _textController.dispose();
    _keyController.dispose();
    _seedLabelController.dispose();
    _seedValueController.dispose();
    _benchmarkSizeController.dispose();
    _benchmarkSamplesController.dispose();
    _benchmarkWarmupsController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final palette = _Palette.of(context);

    return Scaffold(
      backgroundColor: palette.background,
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
          children: [
            _Header(palette: palette),
            const SizedBox(height: 12),
            _CardPanel(
              title: 'Benchmark',
              palette: palette,
              children: [
                LayoutBuilder(
                  builder: (context, constraints) {
                    final useColumns = constraints.maxWidth >= 520;
                    final children = [
                      _BenchmarkField(
                        controller: _benchmarkSizeController,
                        label: 'Size MiB',
                      ),
                      _BenchmarkField(
                        controller: _benchmarkSamplesController,
                        label: 'Samples',
                      ),
                      _BenchmarkField(
                        controller: _benchmarkWarmupsController,
                        label: 'Warmups',
                      ),
                    ];

                    if (!useColumns) {
                      return Column(
                        children: [
                          for (final child in children) ...[
                            child,
                            if (child != children.last)
                              const SizedBox(height: 8),
                          ],
                        ],
                      );
                    }

                    return Row(
                      children: [
                        for (final child in children) ...[
                          Expanded(child: child),
                          if (child != children.last) const SizedBox(width: 8),
                        ],
                      ],
                    );
                  },
                ),
                const SizedBox(height: 12),
                _ActionRow(
                  primary: FilledButton.icon(
                    onPressed: _benchmarkRunning ? null : _runBenchmark,
                    icon: _benchmarkRunning
                        ? const SizedBox.square(
                            dimension: 18,
                            child: CircularProgressIndicator(strokeWidth: 2),
                          )
                        : const Icon(Icons.speed),
                    label: const Text('Run benchmark'),
                  ),
                  secondary: _benchmarkRunning
                      ? OutlinedButton.icon(
                          onPressed: _cancelBenchmark,
                          icon: const Icon(Icons.cancel),
                          label: const Text('Cancel'),
                        )
                      : null,
                ),
                if (_benchmarkStatus != null) ...[
                  const SizedBox(height: 10),
                  Text(
                    _benchmarkStatus!,
                    style: TextStyle(color: palette.muted),
                  ),
                ],
                if (_benchmarkResults.isNotEmpty) ...[
                  const SizedBox(height: 12),
                  _BenchmarkResultsBox(
                    palette: palette,
                    results: _benchmarkResults,
                    onCopy: _copyBenchmarkResults,
                  ),
                ],
              ],
            ),
            const SizedBox(height: 12),
            _CardPanel(
              title: '1. Algorithm',
              palette: palette,
              children: [
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: [
                    for (final algorithm in _algorithms)
                      ChoiceChip(
                        label: Text(algorithm.label),
                        selected: _selectedAlgorithm == algorithm,
                        onSelected: (_) {
                          setState(() {
                            _selectedAlgorithm = algorithm;
                            _fileHash = '';
                            _textHash = '';
                            _fileElapsedMs = null;
                            _textElapsedMs = null;
                            _fileStatus = null;
                            _textStatus = null;
                          });
                        },
                      ),
                  ],
                ),
              ],
            ),
            const SizedBox(height: 12),
            _CardPanel(
              title: '2. Options',
              palette: palette,
              children: [
                if (_selectedAlgorithm.supportsSeed)
                  _SeedOptions(
                    mode: _seedInputMode,
                    labelController: _seedLabelController,
                    valueController: _seedValueController,
                    onModeChanged: (mode) {
                      setState(() {
                        _seedInputMode = mode;
                      });
                    },
                    seedPreview: _seedPreview(),
                  )
                else
                  _KeyOptions(
                    algorithm: _selectedAlgorithm,
                    controller: _keyController,
                    encoding: _keyEncoding,
                    onEncodingChanged: (encoding) {
                      setState(() {
                        _keyEncoding = encoding;
                      });
                    },
                  ),
                const SizedBox(height: 8),
                CheckboxListTile(
                  contentPadding: EdgeInsets.zero,
                  controlAffinity: ListTileControlAffinity.leading,
                  value: _useMmap,
                  onChanged: _fileRunning || _benchmarkRunning
                      ? null
                      : (value) {
                          setState(() {
                            _useMmap = value ?? false;
                            _fileHash = '';
                            _fileElapsedMs = null;
                            _fileStatus = null;
                            _benchmarkResults = const [];
                            _benchmarkStatus = null;
                          });
                        },
                  title: const Text('Use mmap for local files'),
                  subtitle: Text(
                    'Applies to Hash file and Benchmark. Use only stable local files; ignored for Android content:// URIs.',
                    style: TextStyle(color: palette.muted),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            _CardPanel(
              title: '3. Pick a file',
              palette: palette,
              children: [
                Align(
                  alignment: Alignment.centerLeft,
                  child: FilledButton.icon(
                    onPressed: _pickFile,
                    icon: const Icon(Icons.folder_open),
                    label: const Text('Choose file'),
                  ),
                ),
                const SizedBox(height: 10),
                if (_pickedFile == null)
                  Text(
                    'No file selected',
                    style: TextStyle(color: palette.muted),
                  )
                else
                  _FileDetails(file: _pickedFile!, palette: palette),
              ],
            ),
            const SizedBox(height: 12),
            _CardPanel(
              title: '4. Hash file',
              palette: palette,
              children: [
                _ActionRow(
                  primary: FilledButton.icon(
                    onPressed: _pickedFile == null || _fileRunning
                        ? null
                        : _hashFile,
                    icon: _fileRunning
                        ? const SizedBox.square(
                            dimension: 18,
                            child: CircularProgressIndicator(strokeWidth: 2),
                          )
                        : const Icon(Icons.insert_drive_file),
                    label: const Text('Hash file'),
                  ),
                  secondary: _fileRunning
                      ? OutlinedButton.icon(
                          onPressed: _cancelFileHash,
                          icon: const Icon(Icons.cancel),
                          label: const Text('Cancel'),
                        )
                      : null,
                ),
                const SizedBox(height: 10),
                if (_fileHash.isNotEmpty)
                  _DigestResult(
                    digest: _fileHash,
                    elapsedMs: _fileElapsedMs,
                    palette: palette,
                  )
                else
                  Text(
                    _fileStatus ?? 'Waiting for file hash',
                    style: TextStyle(color: palette.muted),
                  ),
              ],
            ),
            const SizedBox(height: 12),
            _CardPanel(
              title: '5. Hash string',
              palette: palette,
              children: [
                TextField(
                  controller: _textController,
                  minLines: 3,
                  maxLines: 6,
                  decoration: const InputDecoration(hintText: 'hello world'),
                ),
                const SizedBox(height: 10),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: [
                    for (final encoding in HashInputEncoding.values)
                      ChoiceChip(
                        label: Text(encoding.label.toUpperCase()),
                        selected: _textEncoding == encoding,
                        onSelected: (_) {
                          setState(() {
                            _textEncoding = encoding;
                            _textHash = '';
                            _textElapsedMs = null;
                            _textStatus = null;
                          });
                        },
                      ),
                  ],
                ),
                const SizedBox(height: 12),
                _ActionRow(
                  primary: FilledButton.icon(
                    onPressed: _textRunning ? null : _hashString,
                    icon: _textRunning
                        ? const SizedBox.square(
                            dimension: 18,
                            child: CircularProgressIndicator(strokeWidth: 2),
                          )
                        : const Icon(Icons.tag),
                    label: const Text('Hash string'),
                  ),
                  secondary: _textRunning
                      ? OutlinedButton.icon(
                          onPressed: _cancelStringHash,
                          icon: const Icon(Icons.cancel),
                          label: const Text('Cancel'),
                        )
                      : null,
                ),
                const SizedBox(height: 10),
                if (_textHash.isNotEmpty)
                  _DigestResult(
                    digest: _textHash,
                    elapsedMs: _textElapsedMs,
                    palette: palette,
                  )
                else
                  Text(
                    _textStatus ?? 'Waiting for string hash',
                    style: TextStyle(color: palette.muted),
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  String _seedPreview() {
    try {
      final seed = _buildSeed();
      return seed == null ? '' : seed.toString();
    } on FormatException catch (error) {
      return error.message;
    }
  }

  HashOptions? _buildHashOptions([HashAlgorithm? algorithm]) {
    final target = algorithm ?? _selectedAlgorithm;

    if (target.supportsSeed) {
      final seed = _buildSeed();
      return seed == null ? null : HashOptions(seed: seed);
    }

    if (target.requiresKey) {
      return HashOptions(key: _keyController.text, keyEncoding: _keyEncoding);
    }

    if (target.supportsKey && _keyController.text.isNotEmpty) {
      return HashOptions(key: _keyController.text, keyEncoding: _keyEncoding);
    }

    return null;
  }

  HashOptions? _buildBenchmarkOptions(HashAlgorithm algorithm) {
    if (!algorithm.requiresKey) {
      return null;
    }

    return const HashOptions(key: _benchmarkKey, keyEncoding: KeyEncoding.utf8);
  }

  Object? _buildSeed() {
    final raw = switch (_seedInputMode) {
      _SeedInputMode.fromLabel => _seedLabelController.text.trim(),
      _SeedInputMode.decimal => _seedValueController.text.trim(),
      _SeedInputMode.hex => _seedValueController.text.trim(),
    };

    if (raw.isEmpty) {
      return null;
    }

    return switch (_seedInputMode) {
      _SeedInputMode.fromLabel => xxh3SeedFromLabel(raw),
      _SeedInputMode.decimal => raw,
      _SeedInputMode.hex => raw.toLowerCase().startsWith('0x') ? raw : '0x$raw',
    };
  }

  Future<_PickedFile?> _pickFileSelectorFile() async {
    final file = await openFile();
    if (file == null) {
      return null;
    }

    final size = await file.length();
    final name = file.name.isEmpty ? _basename(file.path) : file.name;

    return _PickedFile(
      name: name,
      hashPath: file.path,
      displayPath: file.path,
      size: size,
    );
  }

  Future<_PickedFile?> _pickAndroidFile() async {
    final result = await _androidFilePickerChannel
        .invokeMapMethod<String, Object?>('pickFile');
    if (result == null) {
      return null;
    }

    final uri = result['uri'] as String?;
    if (uri == null || uri.isEmpty) {
      _showMessage('Picker did not return a readable URI');
      return null;
    }

    final name = result['name'] as String?;
    final size = result['size'];

    return _PickedFile(
      name: name == null || name.isEmpty ? uri : name,
      hashPath: uri,
      displayPath: uri,
      size: size is int && size > 0 ? size : 0,
    );
  }

  Future<void> _pickFile() async {
    try {
      final pickedFile = Platform.isAndroid
          ? await _pickAndroidFile()
          : await _pickFileSelectorFile();

      if (!mounted || pickedFile == null) {
        return;
      }

      setState(() {
        _pickedFile = pickedFile;
        _fileHash = '';
        _fileElapsedMs = null;
        _fileStatus = null;
      });
    } catch (error) {
      if (!mounted) {
        return;
      }
      _showMessage(_errorText(error));
    }
  }

  Future<void> _hashFile() async {
    final pickedFile = _pickedFile;
    if (pickedFile == null) {
      return;
    }

    final controller = HashCancellationController();
    _fileCancellation = controller;

    setState(() {
      _fileRunning = true;
      _fileHash = '';
      _fileElapsedMs = null;
      _fileStatus = 'Hashing file...';
    });

    try {
      final started = DateTime.now();
      final digest = await fileHash(
        pickedFile.hashPath,
        algorithm: _selectedAlgorithm,
        hashOptions: _buildHashOptions(),
        useMmap: _useMmap,
        cancellationToken: controller.token,
      );
      final elapsed = DateTime.now().difference(started).inMilliseconds;

      if (!mounted) {
        return;
      }

      setState(() {
        _fileHash = digest;
        _fileElapsedMs = elapsed;
        _fileStatus = null;
      });
    } on FlutterFileHashCancelledException {
      if (mounted) {
        setState(() {
          _fileStatus = 'Cancelled';
        });
      }
    } catch (error) {
      if (mounted) {
        setState(() {
          _fileStatus = 'Failed';
        });
        _showMessage(_errorText(error));
      }
    } finally {
      if (identical(_fileCancellation, controller)) {
        _fileCancellation = null;
      }
      if (mounted) {
        setState(() {
          _fileRunning = false;
        });
      }
    }
  }

  Future<void> _hashString() async {
    final controller = HashCancellationController();
    _textCancellation = controller;

    setState(() {
      _textRunning = true;
      _textHash = '';
      _textElapsedMs = null;
      _textStatus = 'Hashing string...';
    });

    try {
      final started = DateTime.now();
      final digest = await Future<String>(() {
        return stringHash(
          _textController.text,
          algorithm: _selectedAlgorithm,
          encoding: _textEncoding,
          hashOptions: _buildHashOptions(),
          cancellationToken: controller.token,
        );
      });
      final elapsed = DateTime.now().difference(started).inMilliseconds;

      if (!mounted) {
        return;
      }

      setState(() {
        _textHash = digest;
        _textElapsedMs = elapsed;
        _textStatus = null;
      });
    } on FlutterFileHashCancelledException {
      if (mounted) {
        setState(() {
          _textStatus = 'Cancelled';
        });
      }
    } catch (error) {
      if (mounted) {
        setState(() {
          _textStatus = 'Failed';
        });
        _showMessage(_errorText(error));
      }
    } finally {
      if (identical(_textCancellation, controller)) {
        _textCancellation = null;
      }
      if (mounted) {
        setState(() {
          _textRunning = false;
        });
      }
    }
  }

  Future<void> _runBenchmark() async {
    final sizeMiB = _parseBoundedInt(
      _benchmarkSizeController.text,
      fallback: 200,
      min: 1,
      max: 4096,
    );
    final samples = _parseBoundedInt(
      _benchmarkSamplesController.text,
      fallback: 3,
      min: 1,
      max: 20,
    );
    final warmups = _parseBoundedInt(
      _benchmarkWarmupsController.text,
      fallback: 1,
      min: 0,
      max: 5,
    );
    final useMmap = _useMmap;
    final sizeBytes = sizeMiB * 1024 * 1024;
    final controller = HashCancellationController();
    final results = <_BenchmarkResult>[];
    Directory? tempDir;

    _benchmarkCancellation = controller;
    setState(() {
      _benchmarkRunning = true;
      _benchmarkResults = const [];
      _benchmarkStatus =
          'Preparing $sizeMiB MiB file (${useMmap ? 'mmap' : 'stream'} I/O)...';
    });

    try {
      tempDir = await Directory.systemTemp.createTemp(
        'flutter_file_hash_benchmark_',
      );
      final file = File.fromUri(tempDir.uri.resolve('benchmark.bin'));
      await _writeBenchmarkFile(file, sizeBytes, controller.token);

      for (final algorithm in _benchmarkAlgorithms) {
        controller.token.throwIfCancelled();
        if (!mounted) {
          return;
        }

        setState(() {
          _benchmarkStatus = 'Benchmarking ${algorithm.label}...';
        });

        try {
          final samplesMs = <int>[];
          String? digestPrefix;
          final totalRuns = samples + warmups;

          for (var run = 0; run < totalRuns; run += 1) {
            controller.token.throwIfCancelled();
            final started = DateTime.now();
            final digest = await fileHash(
              file.path,
              algorithm: algorithm,
              hashOptions: _buildBenchmarkOptions(algorithm),
              useMmap: useMmap,
              cancellationToken: controller.token,
            );
            final elapsed = DateTime.now().difference(started).inMilliseconds;
            digestPrefix = digest.substring(0, math.min(16, digest.length));

            if (run >= warmups) {
              samplesMs.add(elapsed);
            }
          }

          results.add(
            _BenchmarkResult(
              algorithm: algorithm,
              samplesMs: samplesMs,
              digestPrefix: digestPrefix,
            ),
          );
        } catch (error) {
          controller.token.throwIfCancelled();
          results.add(
            _BenchmarkResult(
              algorithm: algorithm,
              samplesMs: const [],
              error: _errorText(error),
            ),
          );
        }

        if (mounted) {
          setState(() {
            _benchmarkResults = List<_BenchmarkResult>.of(results);
          });
        }
      }

      final payload = {
        'version': 1,
        'platform': Platform.operatingSystem,
        'engine': 'zig',
        'sizeBytes': sizeBytes,
        'sizeMiB': sizeMiB,
        'samples': samples,
        'warmups': warmups,
        'useMmap': useMmap,
        'algorithms': [
          for (final algorithm in _benchmarkAlgorithms) algorithm.label,
        ],
        'results': [for (final result in results) result.toJson()],
        'createdAt': DateTime.now().toUtc().toIso8601String(),
      };
      debugPrint('ZFH_BENCHMARK_RESULT ${convert.jsonEncode(payload)}');

      if (mounted) {
        setState(() {
          _benchmarkStatus =
              'Benchmark complete (${useMmap ? 'mmap' : 'stream'} I/O)';
        });
      }
    } on FlutterFileHashCancelledException {
      debugPrint('ZFH_BENCHMARK_CANCELLED');
      if (mounted) {
        setState(() {
          _benchmarkStatus = 'Benchmark cancelled';
        });
      }
    } catch (error) {
      debugPrint('ZFH_BENCHMARK_FAILED ${_errorText(error)}');
      if (mounted) {
        setState(() {
          _benchmarkStatus = 'Benchmark failed';
        });
        _showMessage(_errorText(error));
      }
    } finally {
      try {
        await tempDir?.delete(recursive: true);
      } on FileSystemException {
        // Temporary benchmark files can already be removed by the platform.
      }

      if (identical(_benchmarkCancellation, controller)) {
        _benchmarkCancellation = null;
      }
      if (mounted) {
        setState(() {
          _benchmarkRunning = false;
        });
      }
    }
  }

  Future<void> _writeBenchmarkFile(
    File file,
    int sizeBytes,
    HashCancellationToken cancellationToken,
  ) async {
    final chunk = Uint8List(1024 * 1024);
    for (var i = 0; i < chunk.length; i += 1) {
      chunk[i] = i & 0xff;
    }

    final sink = file.openWrite();
    var remaining = sizeBytes;

    try {
      while (remaining > 0) {
        cancellationToken.throwIfCancelled();
        final length = math.min(chunk.length, remaining);
        sink.add(Uint8List.sublistView(chunk, 0, length));
        remaining -= length;
        if (remaining % (16 * 1024 * 1024) == 0) {
          await sink.flush();
        }
      }
    } finally {
      await sink.close();
    }
  }

  void _cancelFileHash() {
    setState(() {
      _fileStatus = 'Cancelling...';
    });
    _fileCancellation?.cancel('Cancelled from example');
  }

  void _cancelStringHash() {
    setState(() {
      _textStatus = 'Cancelling...';
    });
    _textCancellation?.cancel('Cancelled from example');
  }

  void _cancelBenchmark() {
    setState(() {
      _benchmarkStatus = 'Cancelling benchmark...';
    });
    _benchmarkCancellation?.cancel('Cancelled from example');
  }

  Future<void> _copyBenchmarkResults() async {
    final text = _benchmarkResults.map((result) => result.summary).join('\n');
    if (text.isEmpty) {
      return;
    }

    await Clipboard.setData(ClipboardData(text: text));
    if (mounted) {
      _showMessage('Benchmark results copied');
    }
  }

  void _showMessage(String message) {
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(SnackBar(content: Text(message)));
  }
}

class _Header extends StatelessWidget {
  const _Header({required this.palette});

  final _Palette palette;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'File Hash',
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
              color: palette.text,
              fontWeight: FontWeight.w800,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            'Native streaming hash (MD5 / SHA / XXH3 / BLAKE3)',
            style: Theme.of(
              context,
            ).textTheme.bodyMedium?.copyWith(color: palette.muted),
          ),
        ],
      ),
    );
  }
}

class _CardPanel extends StatelessWidget {
  const _CardPanel({
    required this.title,
    required this.palette,
    required this.children,
  });

  final String title;
  final _Palette palette;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: palette.card,
        border: Border.all(color: palette.border),
        borderRadius: BorderRadius.circular(8),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.06),
            blurRadius: 10,
            offset: const Offset(0, 6),
          ),
        ],
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: Theme.of(context).textTheme.titleMedium?.copyWith(
                color: palette.text,
                fontWeight: FontWeight.w700,
              ),
            ),
            const SizedBox(height: 10),
            ...children,
          ],
        ),
      ),
    );
  }
}

class _ActionRow extends StatelessWidget {
  const _ActionRow({required this.primary, this.secondary});

  final Widget primary;
  final Widget? secondary;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 360 || secondary == null) {
          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              primary,
              if (secondary != null) ...[const SizedBox(height: 8), secondary!],
            ],
          );
        }

        return Row(
          children: [
            Expanded(child: primary),
            const SizedBox(width: 8),
            secondary!,
          ],
        );
      },
    );
  }
}

class _BenchmarkField extends StatelessWidget {
  const _BenchmarkField({required this.controller, required this.label});

  final TextEditingController controller;
  final String label;

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      decoration: InputDecoration(labelText: label),
      keyboardType: TextInputType.number,
      selectAllOnFocus: true,
    );
  }
}

class _BenchmarkResultsBox extends StatelessWidget {
  const _BenchmarkResultsBox({
    required this.palette,
    required this.results,
    required this.onCopy,
  });

  final _Palette palette;
  final List<_BenchmarkResult> results;
  final VoidCallback onCopy;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        _ResultBox(
          palette: palette,
          child: Padding(
            padding: const EdgeInsets.only(right: 42),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                for (final result in results)
                  Padding(
                    padding: const EdgeInsets.only(bottom: 6),
                    child: SelectableText(
                      result.summary,
                      style: _monoStyle(context, palette),
                    ),
                  ),
              ],
            ),
          ),
        ),
        Positioned(
          top: 6,
          right: 6,
          child: Tooltip(
            message: 'Copy benchmark results',
            child: IconButton(
              onPressed: onCopy,
              icon: const Icon(Icons.copy_rounded, size: 18),
              color: palette.muted,
              visualDensity: VisualDensity.compact,
              style: IconButton.styleFrom(
                minimumSize: const Size.square(34),
                tapTargetSize: MaterialTapTargetSize.shrinkWrap,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

class _SeedOptions extends StatelessWidget {
  const _SeedOptions({
    required this.mode,
    required this.labelController,
    required this.valueController,
    required this.onModeChanged,
    required this.seedPreview,
  });

  final _SeedInputMode mode;
  final TextEditingController labelController;
  final TextEditingController valueController;
  final ValueChanged<_SeedInputMode> onModeChanged;
  final String seedPreview;

  @override
  Widget build(BuildContext context) {
    final palette = _Palette.of(context);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: [
            for (final value in _SeedInputMode.values)
              ChoiceChip(
                label: Text(value.title),
                selected: mode == value,
                onSelected: (_) => onModeChanged(value),
              ),
          ],
        ),
        const SizedBox(height: 10),
        if (mode == _SeedInputMode.fromLabel)
          TextField(
            controller: labelController,
            decoration: const InputDecoration(labelText: 'XXH3 seed label'),
          )
        else
          TextField(
            controller: valueController,
            decoration: InputDecoration(labelText: mode.inputLabel),
            keyboardType: TextInputType.text,
            selectAllOnFocus: true,
          ),
        if (seedPreview.isNotEmpty) ...[
          const SizedBox(height: 8),
          SelectableText(
            'seed: $seedPreview',
            style: _monoStyle(context, palette),
          ),
        ],
      ],
    );
  }
}

class _KeyOptions extends StatelessWidget {
  const _KeyOptions({
    required this.algorithm,
    required this.controller,
    required this.encoding,
    required this.onEncodingChanged,
  });

  final HashAlgorithm algorithm;
  final TextEditingController controller;
  final KeyEncoding encoding;
  final ValueChanged<KeyEncoding> onEncodingChanged;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: controller,
          minLines: 2,
          maxLines: 4,
          decoration: InputDecoration(hintText: _placeholder),
        ),
        const SizedBox(height: 10),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: [
            for (final value in KeyEncoding.values)
              ChoiceChip(
                label: Text(value.label.toUpperCase()),
                selected: encoding == value,
                onSelected: (_) => onEncodingChanged(value),
              ),
          ],
        ),
      ],
    );
  }

  String get _placeholder {
    if (algorithm.requiresKey) {
      return 'HMAC key';
    }
    if (algorithm == HashAlgorithm.blake3) {
      return 'BLAKE3 keyed mode, 32 bytes';
    }
    return 'Key is not used for this algorithm';
  }
}

class _FileDetails extends StatelessWidget {
  const _FileDetails({required this.file, required this.palette});

  final _PickedFile file;
  final _Palette palette;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          file.name,
          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
            color: palette.text,
            fontWeight: FontWeight.w600,
          ),
        ),
        const SizedBox(height: 4),
        SelectableText(
          '${_fileSizeLabel(file.size)} - ${file.displayPath}',
          style: Theme.of(
            context,
          ).textTheme.bodySmall?.copyWith(color: palette.muted),
        ),
      ],
    );
  }
}

class _DigestResult extends StatelessWidget {
  const _DigestResult({
    required this.digest,
    required this.elapsedMs,
    required this.palette,
  });

  final String digest;
  final int? elapsedMs;
  final _Palette palette;

  @override
  Widget build(BuildContext context) {
    return _ResultBox(
      palette: palette,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'Result',
            style: Theme.of(
              context,
            ).textTheme.labelSmall?.copyWith(color: palette.muted),
          ),
          const SizedBox(height: 6),
          SelectableText(digest, style: _monoStyle(context, palette)),
          if (elapsedMs != null) ...[
            const SizedBox(height: 8),
            Text(
              _formatMs(elapsedMs),
              style: Theme.of(
                context,
              ).textTheme.labelSmall?.copyWith(color: palette.muted),
            ),
          ],
        ],
      ),
    );
  }
}

class _ResultBox extends StatelessWidget {
  const _ResultBox({required this.palette, required this.child});

  final _Palette palette;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: palette.resultBackground,
        border: Border.all(color: palette.border),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Padding(padding: const EdgeInsets.all(12), child: child),
    );
  }
}

class _Palette {
  const _Palette({
    required this.background,
    required this.card,
    required this.text,
    required this.muted,
    required this.border,
    required this.resultBackground,
  });

  final Color background;
  final Color card;
  final Color text;
  final Color muted;
  final Color border;
  final Color resultBackground;

  static _Palette of(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;

    if (isDark) {
      return const _Palette(
        background: Color(0xff0f1115),
        card: Color(0xff171a20),
        text: Color(0xfff2f4f8),
        muted: Color(0xff8a93a3),
        border: Color(0xff1f242e),
        resultBackground: Color(0xff11151d),
      );
    }

    return const _Palette(
      background: Color(0xfff5f7fb),
      card: Color(0xffffffff),
      text: Color(0xff0f172a),
      muted: Color(0xff55607a),
      border: Color(0xffe5e7eb),
      resultBackground: Color(0xfff8fafc),
    );
  }
}

class _PickedFile {
  const _PickedFile({
    required this.name,
    required this.hashPath,
    required this.displayPath,
    required this.size,
  });

  final String name;
  final String hashPath;
  final String displayPath;
  final int size;
}

class _BenchmarkResult {
  const _BenchmarkResult({
    required this.algorithm,
    required this.samplesMs,
    this.digestPrefix,
    this.error,
  });

  final HashAlgorithm algorithm;
  final List<int> samplesMs;
  final String? digestPrefix;
  final String? error;

  double? get medianMs => _median(samplesMs);
  int? get minMs => samplesMs.isEmpty ? null : samplesMs.reduce(math.min);
  int? get maxMs => samplesMs.isEmpty ? null : samplesMs.reduce(math.max);

  String get summary {
    if (error != null) {
      return '${algorithm.label}: $error';
    }

    return '${algorithm.label}: ${_formatMs(medianMs)} '
        '(min ${_formatMs(minMs)}, max ${_formatMs(maxMs)}) '
        '${digestPrefix ?? ''}';
  }

  Map<String, Object?> toJson() {
    return {
      'algorithm': algorithm.label,
      'samplesMs': samplesMs,
      'medianMs': medianMs,
      'minMs': minMs,
      'maxMs': maxMs,
      'digestPrefix': digestPrefix,
      'error': error,
    };
  }
}

enum _SeedInputMode {
  fromLabel('LABEL', 'XXH3 seed label'),
  decimal('NUMBER', 'XXH3 seed'),
  hex('HEX', 'XXH3 seed');

  const _SeedInputMode(this.title, this.inputLabel);

  final String title;
  final String inputLabel;
}

TextStyle _monoStyle(BuildContext context, _Palette palette) {
  return Theme.of(context).textTheme.bodySmall?.copyWith(
        color: palette.text,
        fontFamily: Platform.isIOS || Platform.isMacOS ? 'Menlo' : 'monospace',
        height: 1.45,
      ) ??
      TextStyle(color: palette.text, fontFamily: 'monospace');
}

String _formatBytes(int size) {
  if (size <= 0) {
    return '0 B';
  }

  const units = ['B', 'KB', 'MB', 'GB'];
  final index = math.min(
    (math.log(size) / math.log(1024)).floor(),
    units.length - 1,
  );
  final value = size / math.pow(1024, index);
  final precision = value >= 10 || index == 0 ? 0 : 1;
  return '${value.toStringAsFixed(precision)} ${units[index]}';
}

String _fileSizeLabel(int size) =>
    size > 0 ? _formatBytes(size) : 'unknown size';

String _basename(String path) {
  final separatorIndex = path.lastIndexOf(Platform.pathSeparator);
  if (separatorIndex < 0 || separatorIndex == path.length - 1) {
    return path;
  }
  return path.substring(separatorIndex + 1);
}

String _formatMs(num? value) {
  if (value == null) {
    return 'n/a';
  }

  final precision = value >= 100 ? 0 : 1;
  return '${value.toStringAsFixed(precision)} ms';
}

int _parseBoundedInt(
  String value, {
  required int fallback,
  required int min,
  required int max,
}) {
  final parsed = int.tryParse(value.trim());
  if (parsed == null) {
    return fallback;
  }

  return parsed.clamp(min, max);
}

double? _median(List<int> values) {
  if (values.isEmpty) {
    return null;
  }

  final sorted = List<int>.of(values)..sort();
  final middle = sorted.length ~/ 2;
  if (sorted.length.isOdd) {
    return sorted[middle].toDouble();
  }

  return (sorted[middle - 1] + sorted[middle]) / 2;
}

String _errorText(Object error) {
  if (error is FlutterFileHashException) {
    return error.message;
  }
  if (error is FormatException) {
    return error.message;
  }
  return error.toString();
}
1
likes
160
points
329
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Native Flutter file and string hashing powered by Zig, with SHA, MD5, HMAC, BLAKE3, XXH3, cancellation, mmap, and Android content:// support.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

code_assets, ffi, flutter, hooks

More

Packages that depend on flutter_file_hash

Packages that implement flutter_file_hash