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

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

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;

  _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: 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(),
        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 sizeBytes = sizeMiB * 1024 * 1024;
    final controller = HashCancellationController();
    final results = <_BenchmarkResult>[];
    Directory? tempDir;

    _benchmarkCancellation = controller;
    setState(() {
      _benchmarkRunning = true;
      _benchmarkResults = const [];
      _benchmarkStatus = 'Preparing $sizeMiB MiB file...';
    });

    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),
              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,
        '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';
        });
      }
    } 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
0
points
329
downloads

Publisher

unverified uploader

Weekly Downloads

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

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

code_assets, ffi, flutter, hooks

More

Packages that depend on flutter_file_hash

Packages that implement flutter_file_hash