Skip to main content

flutter_phonetics 0.1.2 copy "flutter_phonetics: ^0.1.2" to clipboard
flutter_phonetics: ^0.1.2 copied to clipboard

Finds sound-alike spellings of Bangla and English words with a match percentage. Runs on a background thread on every Flutter platform.

example/lib/main.dart

/// Author: Zakaria Hossain
/// Description: Demo application showcasing phonetic variant generation and stress testing.
library;

import 'package:flutter/material.dart';
import 'package:flutter_phonetics/flutter_phonetics.dart';

void main() => runApp(const PhoneticsDemoApp());

enum PhoneticsLanguageChoice {
  bangla(
    label: 'Bangla (বাংলা)',
    className: 'BanglaPhonetics',
    defaultSearch: 'মাসুদ',
    inputLabel: 'বাংলা শব্দ (Bangla word)',
    heavyQuery: 'মাছুদ শরিফ রহীম',
    sampleWords: ['মাসুদ', 'শরীফ', 'রহিম', 'নাজনীন', 'সৈয়দ', 'করিম'],
    nameParts: ['মাসুদ', 'শরীফ', 'রহিম', 'করিম', 'নাজনীন', 'জসীম', 'সৈয়দ'],
    defaultPair: ('মাসুদ', 'মাছুদ'),
    samplePairs: [('মাসুদ', 'মাছুদ'), ('শরীফ', 'শরিফ'), ('রহিম', 'রহীম')],
  ),
  english(
    label: 'English',
    className: 'EnglishPhonetics',
    defaultSearch: 'Philip',
    inputLabel: 'English word',
    heavyQuery: 'Stefen Smyth Filip',
    sampleWords: ['Philip', 'Stephen', 'Jackson', 'Smith', 'Sean', 'Catherine'],
    nameParts: [
      'Stephen',
      'Smith',
      'Katherine',
      'Philip',
      'Jackson',
      'Sean',
      'Bryan',
    ],
    defaultPair: ('Stephen', 'Stefen'),
    samplePairs: [
      ('Stephen', 'Stefen'),
      ('Philip', 'Filip'),
      ('Smith', 'Smyth'),
      ('Jackson', 'Jaxon'),
      ('Catherine', 'Katherine'),
    ],
  );

  const PhoneticsLanguageChoice({
    required this.label,
    required this.className,
    required this.defaultSearch,
    required this.inputLabel,
    required this.heavyQuery,
    required this.sampleWords,
    required this.nameParts,
    required this.defaultPair,
    required this.samplePairs,
  });

  final String label;
  final String className;
  final String defaultSearch;
  final String inputLabel;
  final String heavyQuery;
  final List<String> sampleWords;
  final List<String> nameParts;
  final (String, String) defaultPair;
  final List<(String, String)> samplePairs;

  Phonetics get instance => switch (this) {
    PhoneticsLanguageChoice.bangla => BanglaPhonetics.instance,
    PhoneticsLanguageChoice.english => EnglishPhonetics.instance,
  };

  Phonetics createInstance({PhoneticExecutionMode? executionMode}) =>
      switch (this) {
        PhoneticsLanguageChoice.bangla => BanglaPhonetics(
          executionMode: executionMode ?? PhoneticExecutionMode.cancelPrevious,
        ),
        PhoneticsLanguageChoice.english => EnglishPhonetics(
          executionMode: executionMode ?? PhoneticExecutionMode.cancelPrevious,
        ),
      };

  List<String> buildCustomerNames(int count) => [
    for (var i = 0; i < count; i++)
      '${nameParts[i % nameParts.length]} '
          '${nameParts[(i * 3) % nameParts.length]} '
          '${nameParts[(i * 5) % nameParts.length]} $i',
  ];
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'flutter_phonetics',
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF1E293B),
        useMaterial3: true,
      ),
      home: const PhoneticsPage(),
    );
  }
}

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

  @override
  State<PhoneticsPage> createState() => _PhoneticsPageState();
}

class _PhoneticsPageState extends State<PhoneticsPage> {
  PhoneticsLanguageChoice _language = PhoneticsLanguageChoice.bangla;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(
        title: Text('flutter_phonetics (${_language.label})'),
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 16),
            child: Center(
              child: Chip(
                avatar: const Icon(Icons.bolt, size: 16),
                label: Text(
                  _language.instance.workerName,
                  style: theme.textTheme.labelMedium,
                ),
              ),
            ),
          ),
        ],
      ),
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Card(
              elevation: 0,
              color: theme.colorScheme.surfaceContainerHighest.withValues(
                alpha: 0.5,
              ),
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 12,
                ),
                child: Row(
                  children: [
                    Icon(Icons.language, color: theme.colorScheme.primary),
                    const SizedBox(width: 12),
                    Text(
                      'Language:',
                      style: theme.textTheme.titleMedium?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(width: 16),
                    Expanded(
                      child: SegmentedButton<PhoneticsLanguageChoice>(
                        segments: const [
                          ButtonSegment(
                            value: PhoneticsLanguageChoice.bangla,
                            label: Text('বাংলা (Bangla)'),
                            icon: Icon(Icons.translate),
                          ),
                          ButtonSegment(
                            value: PhoneticsLanguageChoice.english,
                            label: Text('English'),
                            icon: Icon(Icons.abc),
                          ),
                        ],
                        selected: {_language},
                        onSelectionChanged: (selection) {
                          setState(() => _language = selection.first);
                        },
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),
            SearchSection(
              key: ValueKey('search_${_language.name}'),
              language: _language,
            ),
            const SizedBox(height: 16),
            PairwiseSection(
              key: ValueKey('pair_${_language.name}'),
              language: _language,
            ),
            const SizedBox(height: 16),
            HeavyJobSection(
              key: ValueKey('heavy_${_language.name}'),
              language: _language,
            ),
            const SizedBox(height: 16),
            ThreeCallsSection(
              key: ValueKey('three_${_language.name}'),
              language: _language,
            ),
          ],
        ),
      ),
    );
  }
}

class SectionCard extends StatelessWidget {
  const SectionCard({
    super.key,
    required this.title,
    required this.description,
    required this.child,
  });

  final String title;
  final String description;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      margin: EdgeInsets.zero,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: theme.textTheme.titleMedium),
            const SizedBox(height: 4),
            Text(
              description,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
            const SizedBox(height: 12),
            child,
          ],
        ),
      ),
    );
  }
}

class SearchSection extends StatefulWidget {
  const SearchSection({super.key, required this.language});

  final PhoneticsLanguageChoice language;

  @override
  State<SearchSection> createState() => _SearchSectionState();
}

class _SearchSectionState extends State<SearchSection> {
  late final Phonetics _phonetics = widget.language.instance;
  late final TextEditingController _input = TextEditingController(
    text: widget.language.defaultSearch,
  );

  double _minScore = 70;
  bool _hasLimit = true;
  int _maxResults = 50;
  bool _usesQueue = false;
  List<PhoneticMatch> _matches = const [];
  String? _errorMessage;

  @override
  void initState() {
    super.initState();
    _search();
  }

  @override
  void dispose() {
    _phonetics.cancelAll();
    _phonetics.setExecutionMode(PhoneticExecutionMode.cancelPrevious);
    _input.dispose();
    super.dispose();
  }

  Future<void> _search() async {
    final (:matches, :error) = await _phonetics.generate(
      _input.text,
      minScore: _minScore,
      maxResults: _hasLimit ? _maxResults : null,
    );
    if (!mounted || error is PhoneticCancelledException) return;
    setState(() {
      _matches = matches;
      _errorMessage = error?.message;
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return SectionCard(
      title: '1. Search box: ${widget.language.className}.instance',
      description:
          'One shared instance for quick calls. By default it uses '
          'cancelPrevious, so each new keystroke cancels the search still '
          'running for the one before it.',
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextField(
            controller: _input,
            style: const TextStyle(fontSize: 22),
            decoration: InputDecoration(
              labelText: widget.language.inputLabel,
              border: const OutlineInputBorder(),
            ),
            onChanged: (_) => _search(),
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            crossAxisAlignment: WrapCrossAlignment.center,
            children: [
              Text(
                'Quick test:',
                style: theme.textTheme.labelMedium?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              for (final word in widget.language.sampleWords)
                ActionChip(
                  label: Text(word),
                  onPressed: () {
                    _input.text = word;
                    _search();
                  },
                ),
            ],
          ),
          const SizedBox(height: 12),
          // Percentage slider
          Row(
            children: [
              SizedBox(
                width: 150,
                child: Text(
                  'Minimum match: ${_minScore.round()}%',
                  style: const TextStyle(fontWeight: FontWeight.w500),
                ),
              ),
              Expanded(
                child: Slider(
                  value: _minScore,
                  max: 100,
                  divisions: 20,
                  label: '${_minScore.round()}%',
                  onChanged: (value) {
                    setState(() => _minScore = value);
                    _search();
                  },
                ),
              ),
            ],
          ),
          // Max returning limit (optional)
          Row(
            children: [
              SizedBox(
                width: 150,
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Checkbox(
                      value: _hasLimit,
                      visualDensity: VisualDensity.compact,
                      onChanged: (val) {
                        setState(() => _hasLimit = val ?? true);
                        _search();
                      },
                    ),
                    Flexible(
                      child: Text(
                        _hasLimit ? 'Limit: $_maxResults' : 'No limit',
                        style: TextStyle(
                          fontWeight: FontWeight.w500,
                          color: _hasLimit
                              ? null
                              : theme.colorScheme.onSurfaceVariant,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              Expanded(
                child: _hasLimit
                    ? Slider(
                        value: _maxResults.toDouble().clamp(5, 200),
                        min: 5,
                        max: 200,
                        divisions: 39,
                        label: '$_maxResults',
                        onChanged: (value) {
                          setState(() => _maxResults = value.round());
                          _search();
                        },
                      )
                    : Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 16),
                        child: Text(
                          'Unlimited (all matching phonetics)',
                          style: theme.textTheme.bodySmall?.copyWith(
                            fontStyle: FontStyle.italic,
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                      ),
              ),
              if (_hasLimit)
                SegmentedButton<int>(
                  showSelectedIcon: false,
                  style: const ButtonStyle(
                    visualDensity: VisualDensity.compact,
                    tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                  ),
                  segments: const [
                    ButtonSegment(
                      value: 20,
                      label: Text('20', style: TextStyle(fontSize: 12)),
                    ),
                    ButtonSegment(
                      value: 50,
                      label: Text('50', style: TextStyle(fontSize: 12)),
                    ),
                    ButtonSegment(
                      value: 100,
                      label: Text('100', style: TextStyle(fontSize: 12)),
                    ),
                  ],
                  selected: {_maxResults},
                  onSelectionChanged: (selected) {
                    setState(() => _maxResults = selected.first);
                    _search();
                  },
                ),
            ],
          ),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text('Queue instead of cancelPrevious'),
            subtitle: Text(
              '${widget.language.className}.instance.setExecutionMode',
            ),
            value: _usesQueue,
            onChanged: (value) {
              setState(() => _usesQueue = value);
              _phonetics.setExecutionMode(
                value
                    ? PhoneticExecutionMode.queue
                    : PhoneticExecutionMode.cancelPrevious,
              );
            },
          ),
          const Divider(),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                '${_matches.length} variants'
                '${_hasLimit && _matches.length >= _maxResults ? ' (capped at $_maxResults)' : ''}',
                style: theme.textTheme.titleSmall,
              ),
              Text(
                _hasLimit ? 'Limit: $_maxResults max' : 'Unlimited results',
                style: theme.textTheme.bodySmall?.copyWith(
                  color: _hasLimit
                      ? theme.colorScheme.onSurfaceVariant
                      : theme.colorScheme.primary,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ],
          ),
          if (_errorMessage != null)
            Text(
              _errorMessage!,
              style: TextStyle(color: theme.colorScheme.error),
            ),
          SizedBox(
            height: 260,
            child: ListView.builder(
              itemCount: _matches.length,
              itemBuilder: (context, index) {
                final match = _matches[index];
                return ListTile(
                  dense: true,
                  title: Text(match.word, style: const TextStyle(fontSize: 20)),
                  subtitle: match.changes.isEmpty
                      ? const Text('original')
                      : Text(match.changes.join('  ')),
                  trailing: Text(
                    '${match.score.toStringAsFixed(match.score % 1 == 0 ? 0 : 2)}%',
                    style: theme.textTheme.titleMedium,
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

class PairwiseSection extends StatefulWidget {
  const PairwiseSection({super.key, required this.language});

  final PhoneticsLanguageChoice language;

  @override
  State<PairwiseSection> createState() => _PairwiseSectionState();
}

class _PairwiseSectionState extends State<PairwiseSection> {
  late final Phonetics _phonetics = widget.language.createInstance();
  late final TextEditingController _word1 = TextEditingController(
    text: widget.language.defaultPair.$1,
  );
  late final TextEditingController _word2 = TextEditingController(
    text: widget.language.defaultPair.$2,
  );

  double? _score;
  bool? _isMatch;
  String? _errorMessage;

  @override
  void initState() {
    super.initState();
    _compare();
  }

  @override
  void dispose() {
    _phonetics.dispose();
    _word1.dispose();
    _word2.dispose();
    super.dispose();
  }

  Future<void> _compare() async {
    final phonetics = _phonetics;
    final w1 = _word1.text.trim();
    final w2 = _word2.text.trim();
    if (w1.isEmpty || w2.isEmpty) {
      setState(() {
        _score = 0;
        _isMatch = false;
        _errorMessage = null;
      });
      return;
    }
    final (:score, :error) = await phonetics.similarity(w1, w2);
    final matchResult = await phonetics.matches(w1, w2, minScore: 80);
    if (!mounted) return;
    setState(() {
      _score = score;
      _isMatch = matchResult.isMatch;
      _errorMessage = error?.message ?? matchResult.error?.message;
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return SectionCard(
      title: '2. Pairwise similarity: similarity() & matches()',
      description:
          'Compare two ${widget.language.label} words to see their phonetic '
          'similarity percentage and whether they meet the match threshold (≥ 80%).',
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Row(
            children: [
              Expanded(
                child: TextField(
                  controller: _word1,
                  decoration: const InputDecoration(
                    labelText: 'Word 1',
                    border: OutlineInputBorder(),
                  ),
                  onChanged: (_) => _compare(),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: TextField(
                  controller: _word2,
                  decoration: const InputDecoration(
                    labelText: 'Word 2',
                    border: OutlineInputBorder(),
                  ),
                  onChanged: (_) => _compare(),
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            crossAxisAlignment: WrapCrossAlignment.center,
            children: [
              Text(
                'Sample pairs:',
                style: theme.textTheme.labelMedium?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                ),
              ),
              for (final pair in widget.language.samplePairs)
                ActionChip(
                  label: Text('${pair.$1} ↔ ${pair.$2}'),
                  onPressed: () {
                    _word1.text = pair.$1;
                    _word2.text = pair.$2;
                    _compare();
                  },
                ),
            ],
          ),
          const SizedBox(height: 12),
          if (_errorMessage != null)
            Text(
              _errorMessage!,
              style: TextStyle(color: theme.colorScheme.error),
            )
          else if (_score != null)
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              decoration: BoxDecoration(
                color: theme.colorScheme.surfaceContainerHighest,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text(
                    'Similarity: ${_score!.toStringAsFixed(_score! % 1 == 0 ? 0 : 2)}%',
                    style: theme.textTheme.titleMedium,
                  ),
                  Chip(
                    avatar: Icon(
                      _isMatch == true ? Icons.check_circle : Icons.cancel,
                      color: _isMatch == true ? Colors.green : Colors.orange,
                      size: 18,
                    ),
                    label: Text(
                      _isMatch == true ? 'Match (≥80%)' : 'No Match (<80%)',
                      style: TextStyle(
                        color: _isMatch == true
                            ? Colors.green[800]
                            : Colors.orange[900],
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ],
              ),
            ),
        ],
      ),
    );
  }
}

class HeavyJobSection extends StatefulWidget {
  const HeavyJobSection({super.key, required this.language});

  final PhoneticsLanguageChoice language;

  @override
  State<HeavyJobSection> createState() => _HeavyJobSectionState();
}

class _HeavyJobSectionState extends State<HeavyJobSection> {
  Phonetics? _phonetics;
  PhoneticCancelToken? _cancelToken;
  bool _isRunning = false;
  String _status = '';

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

  Future<void> _run() async {
    final phonetics = _phonetics = widget.language.createInstance();
    final cancelToken = _cancelToken = PhoneticCancelToken();
    setState(() {
      _isRunning = true;
      _status = 'Ranking 100,000 ${widget.language.label} names…';
    });

    final names = widget.language.buildCustomerNames(100000);
    final stopwatch = Stopwatch()..start();
    final (:matches, :error) = await phonetics.rank(
      widget.language.heavyQuery,
      names,
      limit: 3,
      cancelToken: cancelToken,
    );
    await phonetics.dispose();
    if (identical(_phonetics, phonetics)) _phonetics = null;
    if (!mounted) return;

    final status = switch (error) {
      null =>
        '${stopwatch.elapsedMilliseconds} ms. Best: '
            '${matches.map((match) => '${match.word} ${match.score.toStringAsFixed(match.score % 1 == 0 ? 0 : 2)}%').join(', ')}',
      PhoneticCancelledException() =>
        'Cancelled after ${stopwatch.elapsedMilliseconds} ms',
      PhoneticException(:final message) => message,
    };
    setState(() {
      _isRunning = false;
      _status = status;
    });
  }

  @override
  Widget build(BuildContext context) {
    return SectionCard(
      title: '3. Heavy job: its own ${widget.language.className}()',
      description:
          'A big job gets its own instance, created when it starts '
          'and disposed when it ends. Typing in the search box above does not '
          'cancel it, and the spinner keeps turning because the work runs on '
          'a background thread.',
      child: Row(
        children: [
          if (_isRunning)
            OutlinedButton(
              onPressed: () => _cancelToken?.cancel(),
              child: const Text('Cancel'),
            )
          else
            FilledButton.tonal(
              onPressed: _run,
              child: const Text('Stress test'),
            ),
          const SizedBox(width: 12),
          const SizedBox(
            width: 20,
            height: 20,
            child: CircularProgressIndicator(strokeWidth: 2.5),
          ),
          const SizedBox(width: 12),
          Expanded(child: Text(_status)),
        ],
      ),
    );
  }
}

class ThreeCallsSection extends StatefulWidget {
  const ThreeCallsSection({super.key, required this.language});

  final PhoneticsLanguageChoice language;

  @override
  State<ThreeCallsSection> createState() => _ThreeCallsSectionState();
}

class _ThreeCallsSectionState extends State<ThreeCallsSection> {
  PhoneticExecutionMode _mode = PhoneticExecutionMode.cancelPrevious;
  bool _isRunning = false;
  List<String> _lines = const [];

  Future<void> _run() async {
    setState(() {
      _isRunning = true;
      _lines = const [];
    });

    final phonetics = widget.language.createInstance(executionMode: _mode);
    final names = widget.language.buildCustomerNames(30000);
    final stopwatch = Stopwatch()..start();
    final lines = List<String>.filled(3, '');

    await Future.wait([
      for (var index = 0; index < 3; index++)
        phonetics.rank(widget.language.heavyQuery, names, limit: 1).then((
          result,
        ) {
          final elapsed = stopwatch.elapsedMilliseconds;
          lines[index] = result.error is PhoneticCancelledException
              ? 'Call ${index + 1}: cancelled after $elapsed ms'
              : 'Call ${index + 1}: done in $elapsed ms';
        }),
    ]);
    await phonetics.dispose();
    if (!mounted) return;

    setState(() {
      _isRunning = false;
      _lines = lines;
    });
  }

  @override
  Widget build(BuildContext context) {
    return SectionCard(
      title: '4. Three calls at once: cancelPrevious or queue',
      description:
          'The three calls are made together on a new '
          '${widget.language.className}(executionMode: ...). cancelPrevious: each new call '
          'cancels the one before, so only the last finishes. queue: each call '
          'waits for the one before it.',
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SegmentedButton<PhoneticExecutionMode>(
            segments: const [
              ButtonSegment(
                value: PhoneticExecutionMode.cancelPrevious,
                label: Text('cancelPrevious'),
              ),
              ButtonSegment(
                value: PhoneticExecutionMode.queue,
                label: Text('queue'),
              ),
            ],
            selected: {_mode},
            onSelectionChanged: _isRunning
                ? null
                : (selection) => setState(() => _mode = selection.first),
          ),
          const SizedBox(height: 12),
          FilledButton.tonal(
            onPressed: _isRunning ? null : _run,
            child: const Text('Run 3 calls'),
          ),
          const SizedBox(height: 8),
          for (final line in _lines) Text(line),
        ],
      ),
    );
  }
}
5
likes
160
points
97
downloads

Documentation

API reference

Publisher

verified publisherjview.online

Weekly Downloads

Finds sound-alike spellings of Bangla and English words with a match percentage. Runs on a background thread on every Flutter platform.

Repository (GitHub)
View/report issues

Topics

#phonetics #bangla #nlp #search #isolate

License

MIT (license)

Dependencies

web

More

Packages that depend on flutter_phonetics