Skip to main content

flutter_whisper 0.2.0 copy "flutter_whisper: ^0.2.0" to clipboard
flutter_whisper: ^0.2.0 copied to clipboard

PlatformAndroid

On-device speech-to-text transcription using whisper.cpp. Automatic model download, streaming segment results, Android support.

example/lib/main.dart

// ignore_for_file: deprecated_member_use — Radio groupValue/onChanged deprecated
// in Flutter 3.35+; RadioGroup ancestor not available on the min supported SDK.
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_whisper/flutter_whisper.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:share_plus/share_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Whisper Demo',
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      home: const WhisperDemoScreen(),
    );
  }
}

enum AppState {
  idle,
  downloading,
  paused,
  initializing,
  ready,
  recording,
  transcribing,
  error
}

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

  @override
  State<WhisperDemoScreen> createState() => _WhisperDemoScreenState();
}

class _WhisperDemoScreenState extends State<WhisperDemoScreen> {
  static const _onboardingKey = 'onboarding_done';

  final Whisper _whisper = Whisper();

  WhisperModel _selected = WhisperModel.tiny;
  AppState _state = AppState.idle;
  String _status = '';
  String _hint = '';
  WhisperDownloadProgress? _dl;
  String _errorDetail = '';
  File? _audioFile;
  TranscriptionResult? _result;
  bool _onboardingDone = false;
  late Map<WhisperModel, bool> _cached = {
    for (final m in WhisperModel.values) m: false
  };

  // Recording + transcription progress.
  bool _recording = false;
  int _recordSeconds = 0;
  Timer? _recordTimer;
  int _transcribeProgress = 0;
  String _language = 'auto';

  // History (JSONL in app support dir).
  File? _historyFile;
  List<Map<String, dynamic>> _history = [];

  @override
  void initState() {
    super.initState();
    _loadOnboarding();
    _refreshCached();
    _initHistory();
    _setIdle();
  }

  Future<void> _initHistory() async {
    final dir = await _appSupportPath();
    _historyFile = File('$dir/history.jsonl');
    if (_historyFile!.existsSync()) {
      try {
        _history = _historyFile!
            .readAsLinesSync()
            .map((l) => jsonDecode(l) as Map<String, dynamic>)
            .toList();
      } catch (_) {}
    }
  }

  Future<void> _loadOnboarding() async {
    final prefs = await SharedPreferences.getInstance();
    setState(() => _onboardingDone = prefs.getBool(_onboardingKey) ?? false);
  }

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

  Future<void> _finishOnboarding() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_onboardingKey, true);
    setState(() => _onboardingDone = true);
  }

  Future<void> _refreshCached() async {
    try {
      final dir = await _whisperSupportDir();
      final models = Directory('$dir/models');
      setState(() {
        _cached = {
          for (final m in WhisperModel.values)
            m: File('${models.path}/${m.name}.bin').existsSync(),
        };
      });
    } catch (_) {}
  }

  Future<String> _whisperSupportDir() async {
    // Same path the plugin uses (application support directory).
    final path = await _appSupportPath();
    return path;
  }

  Future<String> _appSupportPath() async {
    return (await getApplicationSupportDirectory()).path;
  }

  // ---- State transitions ----

  void _setIdle() {
    setState(() {
      _state = AppState.idle;
      _status = 'Not initialized';
      _hint = 'Pick a model, then download. Transcription runs on your phone.';
      _dl = null;
    });
  }

  void _setError(String title, String detail) {
    setState(() {
      _state = AppState.error;
      _status = title;
      _hint = 'Fix the issue, then retry.';
      _errorDetail = detail;
      _dl = null;
    });
  }

  String _friendlyError(Object e) {
    if (e is PlatformException) {
      switch (e.code) {
        case 'NATIVE_NOT_BUILT':
          return 'Native whisper engine not installed in this dev build.';
        case 'MODEL_NOT_FOUND':
          return 'Model file missing. Try downloading again.';
        case 'NOT_INITIALIZED':
          return 'Engine not initialized yet.';
      }
      return e.message ?? e.code;
    }
    if (e is WhisperError) {
      switch (e.code) {
        case WhisperErrorCode.modelDownloadFailed:
          return 'Download failed. Check your network, then retry — it resumes where it stopped.';
        case WhisperErrorCode.cancelled:
          return 'Download cancelled.';
        case WhisperErrorCode.downloadPaused:
          return 'Download paused.';
        default:
          return e.message;
      }
    }
    return '$e';
  }

  // ---- Actions ----

  Future<void> _initialize() async {
    setState(() {
      _state = AppState.downloading;
      _status = 'Downloading ${_selected.name}…';
      _hint = 'You can leave the app — download resumes where it stopped.';
      _dl = null;
    });
    try {
      await _whisper.initialize(
        model: _selected,
        onProgress: (p) {
          if (!mounted) return;
          setState(() {
            _dl = p;
            if (p.fraction < 1.0) {
              _status = 'Downloading ${_selected.name} '
                  '${(p.fraction * 100).toStringAsFixed(0)}%';
            } else {
              _status = 'Initializing ${_selected.name}…';
            }
          });
        },
      );
      if (!mounted) return;
      setState(() {
        _state = AppState.ready;
        _status = 'Ready — ${_selected.name} loaded';
        _hint = 'Transcribe a file, or try the sample below.';
        _dl = null;
      });
      _refreshCached();
    } catch (e) {
      if (!mounted) return;
      if (e is WhisperError && e.code == WhisperErrorCode.downloadPaused) {
        setState(() {
          _state = AppState.paused;
          _status = 'Paused — ${_selected.name} '
              '${_dl != null ? '${(_dl!.fraction * 100).toStringAsFixed(0)}%' : ''}';
          _hint = 'Resume anytime — nothing is lost.';
        });
      } else if (e is WhisperError && e.code == WhisperErrorCode.cancelled) {
        _setIdle();
      } else {
        _setError(_friendlyError(e), '$e');
      }
    }
  }

  Future<void> _resumeDownload() async {
    setState(() {
      _state = AppState.downloading;
      _status = 'Resuming download…';
      _hint = 'Picking up where it stopped.';
    });
    try {
      await _whisper.resumeDownload();
      if (!mounted) return;
      setState(() {
        _state = AppState.ready;
        _status = 'Ready — ${_selected.name} loaded';
        _hint = 'Transcribe a file, or try the sample below.';
        _dl = null;
      });
      _refreshCached();
    } catch (e) {
      if (!mounted) return;
      _setError(_friendlyError(e), '$e');
    }
  }

  Future<void> _pickAudioFile() async {
    final picked = await FilePicker.platform.pickFiles(type: FileType.audio);
    if (picked == null || picked.files.isEmpty) return;
    setState(() {
      _audioFile = File(picked.files.first.path!);
      _result = null;
    });
  }

  Future<void> _trySample() async {
    setState(() => _status = 'Loading sample…');
    try {
      final bytes = await rootBundle.load('assets/sample.wav');
      final dir = await _appSupportPath();
      final sample = File('$dir/sample.wav');
      await sample.writeAsBytes(bytes.buffer.asUint8List());
      setState(() {
        _audioFile = sample;
        _result = null;
        _status = 'Sample ready — transcribe it.';
      });
    } catch (e) {
      _setError('Could not load sample', '$e');
    }
  }

  Future<void> _transcribe() async {
    final file = _audioFile;
    if (file == null || _state != AppState.ready) return;
    setState(() {
      _state = AppState.transcribing;
      _status = 'Transcribing…';
      _hint = 'On-device — no data leaves your phone.';
      _transcribeProgress = 0;
      _result = null;
    });
    final options =
        _language == 'auto' ? null : WhisperOptions(language: _language);
    try {
      final result = await _whisper.transcribeFile(
        file.path,
        options: options,
        onProgress: (p) {
          if (!mounted) return;
          setState(() => _transcribeProgress = p);
        },
      );
      if (!mounted) return;
      setState(() {
        _state = AppState.ready;
        _status = 'Done';
        _hint = 'Transcribe another file, or record your voice.';
        _result = result;
      });
      _appendHistory(result);
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _state = AppState.ready;
        if (e is PlatformException && e.code == 'TRANSCRIPTION_CANCELLED') {
          _status = 'Cancelled';
          _hint = 'Tap Transcribe to start over.';
        } else {
          _status = 'Transcription failed';
          _hint = _friendlyError(e);
        }
      });
    }
  }

  // ---- Recording ----

  Future<bool> _ensureMicPermission() async {
    final status = await Permission.microphone.request();
    if (status.isGranted) return true;
    if (status.isPermanentlyDenied) {
      if (!mounted) return false;
      final open = await showDialog<bool>(
        context: context,
        builder: (ctx) => AlertDialog(
          title: const Text('Microphone permission'),
          content: const Text('Recording needs mic access. '
              'Open Settings and allow it, then come back.'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(ctx, false),
              child: const Text('Not now'),
            ),
            FilledButton(
              onPressed: () => Navigator.pop(ctx, true),
              child: const Text('Open Settings'),
            ),
          ],
        ),
      );
      if (open == true) await openAppSettings();
      return false;
    }
    return false;
  }

  Future<void> _startRecording() async {
    if (_state != AppState.ready) return;
    if (!await _ensureMicPermission()) return;
    try {
      await _whisper.startRecording();
      if (!mounted) return;
      setState(() {
        _state = AppState.recording;
        _recording = true;
        _recordSeconds = 0;
        _status = 'Recording…';
        _hint = 'Speak now. Tap Stop when done.';
      });
      _recordTimer = Timer.periodic(const Duration(seconds: 1), (_) {
        if (!mounted) return;
        setState(() => _recordSeconds++);
      });
    } catch (e) {
      if (!mounted) return;
      _setError('Could not start recording', '$e');
    }
  }

  Future<void> _stopAndTranscribe() async {
    if (!_recording) return;
    _recordTimer?.cancel();
    setState(() {
      _recording = false;
      _state = AppState.transcribing;
      _status = 'Transcribing recording…';
      _hint = 'On-device — no data leaves your phone.';
      _transcribeProgress = 0;
    });
    try {
      final path = await _whisper.stopRecording();
      if (!mounted) return;
      _audioFile = File(path);
      await _transcribe();
    } catch (e) {
      if (!mounted) return;
      _setError('Could not finish recording', '$e');
    }
  }

  void _appendHistory(TranscriptionResult r) {
    final f = _historyFile;
    if (f == null) return;
    final entry = {
      'text': r.text,
      'language': r.language,
      'duration': r.duration,
      'ts': DateTime.now().toIso8601String(),
    };
    _history.insert(0, entry);
    if (_history.length > 200) _history.removeRange(200, _history.length);
    try {
      f.writeAsStringSync(
        '${jsonEncode(entry)}\n',
        mode: FileMode.append,
      );
    } catch (_) {}
  }

  void _selectModel(WhisperModel m) {
    if (m == _selected) return;
    setState(() => _selected = m);

    final cached = _cached[m] == true;
    if (_state == AppState.ready) {
      _showSwitchConfirm(m);
    } else if (cached) {
      // Model already downloaded — initialize immediately.
      _initialize();
    } else {
      // Not cached — go to idle, user must tap the download button in the card.
      _setIdle();
    }
  }

  void _showSwitchConfirm(WhisperModel m) {
    showDialog<void>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Switch model?'),
        content: Text('Load ${m.name.toUpperCase()} (${m.fileSizeHuman})? '
            'Current session will reload.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              Navigator.pop(ctx);
              _initialize();
            },
            child: const Text('Switch'),
          ),
        ],
      ),
    );
  }

  // ---- UI ----

  @override
  Widget build(BuildContext context) {
    if (!_onboardingDone) {
      return OnboardingScreen(onDone: _finishOnboarding);
    }
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Whisper'),
        actions: [
          IconButton(
            tooltip: 'History',
            icon: const Icon(Icons.history),
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => HistoryScreen(
                  history: List.of(_history),
                  onClear: () {
                    _history = [];
                    _historyFile?.deleteSync();
                    if (mounted) setState(() {});
                  },
                ),
              ),
            ),
          ),
          if (_state == AppState.ready || _state == AppState.transcribing)
            IconButton(
              tooltip: 'Reset',
              icon: const Icon(Icons.refresh),
              onPressed: () async {
                await _whisper.dispose();
                _setIdle();
                _refreshCached();
              },
            ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _buildCapabilityChips(),
            const SizedBox(height: 16),
            _buildStatusCard(),
            if (_state == AppState.ready ||
                _state == AppState.recording ||
                _state == AppState.transcribing) ...[
              const SizedBox(height: 16),
              _buildActions(),
            ],
            if (_result != null) ...[
              const SizedBox(height: 16),
              _buildResultCard(),
            ],
            const SizedBox(height: 24),
            _buildModelSelector(),
          ],
        ),
      ),
    );
  }

  Widget _buildCapabilityChips() {
    return const Wrap(
      spacing: 8,
      children: [
        Chip(avatar: Icon(Icons.cloud_off, size: 16), label: Text('Offline')),
        Chip(
            avatar: Icon(Icons.smartphone, size: 16), label: Text('On-device')),
        Chip(
            avatar: Icon(Icons.lock_outline, size: 16),
            label: Text('No uploads')),
        Chip(
            avatar: Icon(Icons.language, size: 16),
            label: Text('Multilingual')),
      ],
    );
  }

  Widget _buildStatusCard() {
    final (icon, color) = switch (_state) {
      AppState.idle => (Icons.mic_none, Colors.blueGrey),
      AppState.downloading || AppState.paused => (Icons.download, Colors.blue),
      AppState.initializing => (Icons.settings, Colors.blue),
      AppState.ready => (Icons.check_circle, Colors.green),
      AppState.recording => (Icons.mic, Colors.red),
      AppState.transcribing => (Icons.graphic_eq, Colors.teal),
      AppState.error => (Icons.error, Colors.red),
    };
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(icon, color: color),
                const SizedBox(width: 12),
                Expanded(
                    child: Text(_status,
                        style: Theme.of(context).textTheme.titleMedium)),
              ],
            ),
            if (_state == AppState.downloading && _dl != null) ...[
              const SizedBox(height: 12),
              LinearProgressIndicator(value: _dl!.fraction.clamp(0.0, 1.0)),
              const SizedBox(height: 8),
              Text(_formatProgress(_dl!),
                  style: Theme.of(context).textTheme.bodySmall),
            ],
            if (_state == AppState.downloading && _dl == null) ...[
              const SizedBox(height: 12),
              const LinearProgressIndicator(),
            ],
            if (_state == AppState.transcribing) ...[
              const SizedBox(height: 12),
              LinearProgressIndicator(value: _transcribeProgress / 100),
              const SizedBox(height: 8),
              Text('$_transcribeProgress%',
                  style: Theme.of(context).textTheme.bodySmall),
            ],
            if (_state == AppState.recording) ...[
              const SizedBox(height: 12),
              Row(
                children: [
                  const Icon(Icons.fiber_manual_record,
                      color: Colors.red, size: 14),
                  const SizedBox(width: 8),
                  Text(
                    _fmtDuration(Duration(seconds: _recordSeconds)),
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ],
              ),
            ],
            const SizedBox(height: 8),
            Text(_hint, style: Theme.of(context).textTheme.bodySmall),
            if (_state == AppState.downloading) ...[
              const SizedBox(height: 12),
              Row(
                children: [
                  FilledButton.tonalIcon(
                    onPressed: _whisper.pauseDownload,
                    icon: const Icon(Icons.pause),
                    label: const Text('Pause'),
                  ),
                  const SizedBox(width: 8),
                  IconButton(
                    tooltip: 'Cancel download',
                    onPressed: _whisper.cancel,
                    icon: const Icon(Icons.close),
                  ),
                ],
              ),
            ],
            if (_state == AppState.paused) ...[
              const SizedBox(height: 12),
              FilledButton.icon(
                onPressed: _resumeDownload,
                icon: const Icon(Icons.play_arrow),
                label: const Text('Resume download'),
              ),
            ],
            if (_state == AppState.error) ...[
              const SizedBox(height: 12),
              ExpansionTile(
                tilePadding: EdgeInsets.zero,
                title: const Text('Details', style: TextStyle(fontSize: 13)),
                children: [
                  SelectableText(_errorDetail,
                      style: const TextStyle(fontSize: 12)),
                ],
              ),
              const SizedBox(height: 8),
              FilledButton.icon(
                onPressed: _initialize,
                icon: const Icon(Icons.refresh),
                label: const Text('Retry'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  String _formatProgress(WhisperDownloadProgress p) {
    final received = _fmtBytes(p.receivedBytes);
    final total = _fmtBytes(p.totalBytes);
    final speed = p.speedBytesPerSec != null
        ? '${_fmtBytes(p.speedBytesPerSec!.round())}/s'
        : '';
    final eta = p.eta != null ? ' • ETA ${_fmtDuration(p.eta!)}' : '';
    return '$received / $total$speed$eta';
  }

  static String _fmtBytes(int b) {
    if (b >= 1024 * 1024 * 1024) {
      return '${(b / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
    }
    if (b >= 1024 * 1024) return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
    if (b >= 1024) return '${(b / 1024).toStringAsFixed(0)} KB';
    return '$b B';
  }

  static String _fmtDuration(Duration d) {
    final s = d.inSeconds;
    if (s >= 3600) return '${s ~/ 3600}h ${(s % 3600) ~/ 60}m';
    if (s >= 60) return '${s ~/ 60}m ${s % 60}s';
    return '${s}s';
  }

  Widget _buildActions() {
    final busy = _state == AppState.transcribing;
    final recording = _state == AppState.recording;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Text('Transcribe',
                    style: Theme.of(context).textTheme.titleMedium),
                const Spacer(),
                DropdownButton<String>(
                  value: _language,
                  underline: const SizedBox.shrink(),
                  items: const [
                    DropdownMenuItem(
                        value: 'auto', child: Text('Auto language')),
                    DropdownMenuItem(value: 'en', child: Text('English')),
                    DropdownMenuItem(value: 'hi', child: Text('Hindi')),
                    DropdownMenuItem(value: 'es', child: Text('Spanish')),
                    DropdownMenuItem(value: 'fr', child: Text('French')),
                    DropdownMenuItem(value: 'de', child: Text('German')),
                    DropdownMenuItem(value: 'ja', child: Text('Japanese')),
                    DropdownMenuItem(value: 'zh', child: Text('Chinese')),
                  ],
                  onChanged: recording || busy
                      ? null
                      : (v) => setState(() => _language = v!),
                ),
              ],
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              style: FilledButton.styleFrom(
                backgroundColor: recording ? Colors.red : null,
                padding: const EdgeInsets.symmetric(vertical: 16),
              ),
              onPressed: recording
                  ? _stopAndTranscribe
                  : busy
                      ? null
                      : _startRecording,
              icon: Icon(recording ? Icons.stop : Icons.mic),
              label: Text(
                recording
                    ? 'Stop & Transcribe'
                    : busy
                        ? 'Transcribing…'
                        : 'Record & Transcribe',
                style: const TextStyle(fontSize: 16),
              ),
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: (busy || recording) ? null : _pickAudioFile,
                    icon: const Icon(Icons.file_open),
                    label: const Text('Pick audio'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: (busy || recording) ? null : _trySample,
                    icon: const Icon(Icons.audio_file),
                    label: const Text('Try sample'),
                  ),
                ),
              ],
            ),
            if (_audioFile != null && !recording) ...[
              const SizedBox(height: 8),
              ListTile(
                dense: true,
                leading: const Icon(Icons.music_note),
                title: Text(_audioFile!.path.split('/').last,
                    overflow: TextOverflow.ellipsis),
                trailing: IconButton(
                  icon: const Icon(Icons.close),
                  onPressed: () => setState(() => _audioFile = null),
                ),
              ),
              FilledButton.tonalIcon(
                onPressed: busy ? null : _transcribe,
                icon: const Icon(Icons.transcribe),
                label: const Text('Transcribe file'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildResultCard() {
    final r = _result!;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Text('Result', style: Theme.of(context).textTheme.titleMedium),
                const Spacer(),
                if (r.language.isNotEmpty)
                  Chip(
                      label: Text(r.language),
                      visualDensity: VisualDensity.compact),
              ],
            ),
            const SizedBox(height: 8),
            SelectableText(r.text,
                style: const TextStyle(fontSize: 15, height: 1.4)),
            if (r.segments.isNotEmpty) ...[
              const SizedBox(height: 8),
              ExpansionTile(
                tilePadding: EdgeInsets.zero,
                title: const Text('Segments', style: TextStyle(fontSize: 13)),
                children: [
                  for (final s in r.segments)
                    ListTile(
                      dense: true,
                      leading: Text(
                        '[${s.start.toStringAsFixed(1)}-${s.end.toStringAsFixed(1)}s]',
                        style: const TextStyle(
                            fontSize: 11, fontFamily: 'monospace'),
                      ),
                      title: Text(s.text, style: const TextStyle(fontSize: 13)),
                    ),
                ],
              ),
            ],
            const SizedBox(height: 8),
            Align(
              alignment: Alignment.centerRight,
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  IconButton(
                    tooltip: 'Copy text',
                    icon: const Icon(Icons.copy),
                    onPressed: () =>
                        Clipboard.setData(ClipboardData(text: r.text)),
                  ),
                  IconButton(
                    tooltip: 'Share',
                    icon: const Icon(Icons.share),
                    onPressed: () => Share.share(
                      r.text,
                      subject: 'Transcription (${r.language})',
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildModelSelector() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('Models', style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        for (final m in WhisperModel.values)
          Card(
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(12),
              side: BorderSide(
                color: m == _selected
                    ? Theme.of(context).colorScheme.primary
                    : Colors.transparent,
                width: 2,
              ),
            ),
            child: ListTile(
              onTap: () => _selectModel(m),
              leading: Icon(
                m == WhisperModel.tiny
                    ? Icons.flash_on
                    : m == WhisperModel.large
                        ? Icons.verified
                        : Icons.mic,
                color: m == _selected
                    ? Theme.of(context).colorScheme.primary
                    : null,
              ),
              title: Text(m.name.toUpperCase()),
              subtitle: Text(
                  '${m.fileSizeHuman} • ${m.isMultilingual ? 'Multilingual' : 'English only'}'),
              trailing: _buildModelAction(m),
            ),
          ),
      ],
    );
  }

  Widget _buildModelAction(WhisperModel m) {
    final cached = _cached[m] == true;
    final isSelected = m == _selected;

    // If already initialized with this model
    if (_state == AppState.ready && isSelected) {
      return const Chip(
        avatar: Icon(Icons.check_circle, size: 14, color: Colors.green),
        label: Text('Loaded'),
        visualDensity: VisualDensity.compact,
      );
    }

    // Cached but not loaded — offer to initialize
    if (cached) {
      return FilledButton.tonal(
        onPressed: () => _selectModel(m),
        style: FilledButton.styleFrom(
          visualDensity: VisualDensity.compact,
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        ),
        child: const Text('Initialize'),
      );
    }

    // Not cached — radio to select and download
    return Radio<WhisperModel>(
      value: m,
      groupValue: _selected,
      onChanged: (_) => _selectModel(m),
    );
  }
}

/// First-run onboarding — teaches what the app does.
class OnboardingScreen extends StatelessWidget {
  const OnboardingScreen({super.key, required this.onDone});

  final Future<void> Function() onDone;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: PageView(
          children: [
            _Slide(
              icon: Icons.cloud_off,
              title: 'Transcribe offline',
              body: 'Speech-to-text on your phone. No cloud, no uploads — '
                  'works without internet.',
              onDone: onDone,
            ),
            _Slide(
              icon: Icons.linear_scale,
              title: 'Pick a model',
              body: '5 sizes from 74 MB (fast) to 2.9 GB (most accurate). '
                  'Smaller = faster, bigger = better.',
              onDone: onDone,
            ),
            _Slide(
              icon: Icons.download_done,
              title: 'Download once',
              body: 'The model downloads to your phone, then everything runs '
                  'locally. Downloads resume — kill the app and pick up '
                  'where you left off.',
              isLast: true,
              onDone: onDone,
            ),
          ],
        ),
      ),
    );
  }
}

class _Slide extends StatelessWidget {
  const _Slide({
    required this.icon,
    required this.title,
    required this.body,
    required this.onDone,
    this.isLast = false,
  });

  final IconData icon;
  final String title;
  final String body;
  final bool isLast;
  final Future<void> Function() onDone;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(32),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Icon(icon, size: 96, color: Theme.of(context).colorScheme.primary),
          const SizedBox(height: 32),
          Text(title,
              style: Theme.of(context).textTheme.headlineSmall,
              textAlign: TextAlign.center),
          const SizedBox(height: 16),
          Text(body,
              style: Theme.of(context).textTheme.bodyLarge,
              textAlign: TextAlign.center),
          const SizedBox(height: 48),
          FilledButton(
            onPressed: onDone,
            child: Text(isLast ? 'Get started' : 'Skip'),
          ),
        ],
      ),
    );
  }
}

/// Saved transcriptions (JSONL history).
class HistoryScreen extends StatelessWidget {
  const HistoryScreen(
      {super.key, required this.history, required this.onClear});

  final List<Map<String, dynamic>> history;
  final VoidCallback onClear;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('History'),
        actions: [
          if (history.isNotEmpty)
            IconButton(
              tooltip: 'Clear history',
              icon: const Icon(Icons.delete_outline),
              onPressed: () {
                onClear();
                Navigator.pop(context);
              },
            ),
        ],
      ),
      body: history.isEmpty
          ? const Center(child: Text('No transcriptions yet'))
          : ListView.builder(
              itemCount: history.length,
              itemBuilder: (context, i) {
                final e = history[i];
                final text = e['text'] as String;
                final ts =
                    (e['ts'] as String).replaceFirst('T', ' ').split('.').first;
                return ListTile(
                  leading: const Icon(Icons.transcribe),
                  title:
                      Text(text, maxLines: 2, overflow: TextOverflow.ellipsis),
                  subtitle: Text('${e['language']} • $ts'),
                  onTap: () => showDialog<void>(
                    context: context,
                    builder: (ctx) => AlertDialog(
                      title: Text('$ts • ${e['language']}'),
                      content: SingleChildScrollView(
                        child: SelectableText(text),
                      ),
                      actions: [
                        TextButton(
                          onPressed: () => Navigator.pop(ctx),
                          child: const Text('Close'),
                        ),
                        IconButton(
                          tooltip: 'Copy',
                          icon: const Icon(Icons.copy),
                          onPressed: () =>
                              Clipboard.setData(ClipboardData(text: text)),
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
    );
  }
}
2
likes
160
points
383
downloads

Documentation

API reference

Publisher

verified publishergovindtank.is-a.dev

Weekly Downloads

On-device speech-to-text transcription using whisper.cpp. Automatic model download, streaming segment results, Android support.

Repository (GitHub)
View/report issues

Topics

#speech-to-text #whisper #transcription #on-device #android

License

Apache-2.0 (license)

Dependencies

flutter, http, path_provider

More

Packages that depend on flutter_whisper

Packages that implement flutter_whisper