fasttext_flutter 0.1.2
fasttext_flutter: ^0.1.2 copied to clipboard
On-device text classification and sentence embeddings using fastText .ftz quantized models via Dart FFI. Verified on Android. Contributions welcome
// Copyright 2024 the fasttext_flutter authors. Apache-2.0 license.
//
// Example app: demonstrates fasttext_flutter for both:
// - Supervised models (e.g. lid.176.ftz): text classification via predict()
// - Unsupervised models (e.g. word-vector .ftz): embeddings + cosineSimilarity
//
// Place your model at example/assets/model.ftz (any model works).
// For a pre-trained language-ID model: https://fasttext.cc/docs/en/language-identification.html
import 'dart:typed_data';
import 'package:fasttext_flutter/fasttext_flutter.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const FastTextExampleApp());
}
class FastTextExampleApp extends StatelessWidget {
const FastTextExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'fasttext_flutter Demo',
theme: ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6C63FF),
brightness: Brightness.dark,
),
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
FastTextModel? _model;
bool _loading = true;
String? _loadError;
final _controller = TextEditingController();
final _compareController = TextEditingController();
List<FastTextPrediction> _predictions = [];
Float32List? _embedding;
double? _similarity;
bool _inferring = false;
@override
void initState() {
super.initState();
_initModel();
}
Future<void> _initModel() async {
print('--> App init: Loading fastText model...');
try {
final m = await FastTextModel.loadAsset('assets/model.ftz');
print('--> App init: Model loaded successfully.');
setState(() {
_model = m;
_loading = false;
});
} catch (e) {
print('--> App init ERROR: $e');
setState(() {
_loadError = e.toString();
_loading = false;
});
}
}
Future<void> _runInference() async {
final text = _controller.text.trim();
if (text.isEmpty || _model == null) return;
setState(() => _inferring = true);
try {
// computeEmbedding() works for ALL model types.
final emb = await _model!.computeEmbedding(text);
setState(() => _embedding = emb);
// predict() is supervised-only.
if (_model!.isSupervised) {
final preds = await _model!.predict(text, k: 5);
setState(() => _predictions = preds);
} else {
setState(() => _predictions = []);
}
// Cosine similarity to the comparison text (if provided).
final compareText = _compareController.text.trim();
if (compareText.isNotEmpty) {
final embB = await _model!.computeEmbedding(compareText);
setState(() => _similarity = FastTextModel.cosineSimilarity(emb, embB));
} else {
setState(() => _similarity = null);
}
} on FastTextException catch (e) {
_showError(e.message);
} finally {
setState(() => _inferring = false);
}
}
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
}
@override
void dispose() {
_model?.close();
_controller.dispose();
_compareController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
title: const Text('fasttext_flutter'),
centerTitle: true,
backgroundColor: cs.surfaceContainerHighest,
actions: [
if (_model != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Chip(
label: Text(
'type: ${_model!.modelType.name} · dim: ${_model!.dimension} · labels: ${_model!.labelCount}',
style: const TextStyle(fontSize: 11),
),
backgroundColor: cs.primaryContainer,
),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _loadError != null
? _ErrorView(error: _loadError!)
: _MainView(
controller: _controller,
compareController: _compareController,
predictions: _predictions,
embedding: _embedding,
similarity: _similarity,
inferring: _inferring,
isSupervised: _model?.isSupervised ?? false,
onRun: _runInference,
),
);
}
}
// ---------------------------------------------------------------------------
// Error screen
// ---------------------------------------------------------------------------
class _ErrorView extends StatelessWidget {
final String error;
const _ErrorView({required this.error});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
const Text(
'Failed to load model',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
const Text(
'Place model.ftz in example/assets/ and re-run.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Main content
// ---------------------------------------------------------------------------
class _MainView extends StatelessWidget {
final TextEditingController controller;
final TextEditingController compareController;
final List<FastTextPrediction> predictions;
final Float32List? embedding;
final double? similarity;
final bool inferring;
final bool isSupervised;
final VoidCallback onRun;
const _MainView({
required this.controller,
required this.compareController,
required this.predictions,
required this.embedding,
required this.similarity,
required this.inferring,
required this.isSupervised,
required this.onRun,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Input text
_SectionHeader(isSupervised ? 'Classify text' : 'Compute embedding'),
const SizedBox(height: 8),
TextField(
controller: controller,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Enter text…',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: cs.surfaceContainerHighest,
),
),
const SizedBox(height: 10),
// Comparison text (for cosine similarity)
_SectionHeader('Compare with (for cosine similarity)'),
const SizedBox(height: 8),
TextField(
controller: compareController,
decoration: InputDecoration(
hintText: 'Enter a second text to compare… (optional)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: cs.surfaceContainerHighest,
),
),
const SizedBox(height: 12),
// Run button
FilledButton.icon(
onPressed: inferring ? null : onRun,
icon: inferring
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bolt),
label: Text(inferring ? 'Running…' : 'Run'),
),
const SizedBox(height: 28),
// Cosine similarity card
if (similarity != null) ...[
_SectionHeader('Cosine Similarity'),
const SizedBox(height: 8),
_SimilarityCard(similarity: similarity!),
const SizedBox(height: 24),
],
// Classification predictions (supervised only)
if (predictions.isNotEmpty) ...[
_SectionHeader('Top Predictions'),
const SizedBox(height: 8),
...predictions.map((p) => _PredictionCard(prediction: p)),
const SizedBox(height: 24),
],
// Embedding snippet
if (embedding != null) ...[
_SectionHeader('Embedding (first 8 of ${embedding!.length} dims)'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: cs.surfaceContainerHighest,
),
child: Text(
'${embedding!.take(8).map((v) => v.toStringAsFixed(4)).join(', ')}…',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
],
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
);
}
}
class _SimilarityCard extends StatelessWidget {
final double similarity;
const _SimilarityCard({required this.similarity});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final pct = ((similarity + 1) / 2).clamp(0.0, 1.0); // map [-1,1]→[0,1]
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
minHeight: 8,
backgroundColor: cs.surfaceContainerHighest,
color: cs.tertiary,
),
),
),
const SizedBox(width: 16),
Text(
similarity.toStringAsFixed(4),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: cs.tertiary,
),
),
],
),
),
);
}
}
class _PredictionCard extends StatelessWidget {
final FastTextPrediction prediction;
const _PredictionCard({required this.prediction});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prediction.cleanLabel.toUpperCase(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: prediction.probability,
minHeight: 6,
backgroundColor: cs.surfaceContainerHighest,
color: cs.primary,
),
),
],
),
),
const SizedBox(width: 16),
Text(
'${(prediction.probability * 100).toStringAsFixed(1)}%',
style: TextStyle(
color: cs.primary,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
);
}
}