flutter_device_ai 0.1.1
flutter_device_ai: ^0.1.1 copied to clipboard
On-device generative AI for Flutter: Apple Foundation Models, Gemini Nano and Chrome built-in AI with streaming, tool calling and JSON-schema output.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_device_ai/flutter_device_ai.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
ThemeData theme(Brightness b) => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0F766E),
brightness: b,
),
);
return MaterialApp(
title: 'On-device AI',
debugShowCheckedModeBanner: false,
theme: theme(Brightness.light),
darkTheme: theme(Brightness.dark),
home: const Home(),
);
}
}
/// A canned-answer engine used ONLY to exercise the UI on machines without an
/// OS model (desktop, simulators). It is not an AI.
OnDeviceAiFallbackAdapter demoFallback() => EdgeAiFallbackAdapter(
modelName: 'demo echo engine',
onGenerateText: (prompt, {config, instructions}) async =>
'[demo engine - no real model] ${prompt.split('\n').lastWhere((l) => l.trim().isNotEmpty && !l.startsWith('Assistant:'), orElse: () => prompt)}',
);
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final _ai = FlutterDeviceAi.withFallback();
OnDeviceAiAvailability? _availability;
OnDeviceAiCapabilities? _caps;
bool _demoFallback = false;
double? _downloadProgress;
String? _setupError;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
_ai.fallbackAdapter = _demoFallback ? demoFallback() : null;
final availability = await _ai.availability();
final caps = await _ai.capabilities();
if (!mounted) return;
setState(() {
_availability = availability;
_caps = caps;
});
}
Future<void> _prepare() async {
setState(() {
_setupError = null;
_downloadProgress = 0;
});
try {
await _ai.prepare(
onProgress: (p) => setState(() => _downloadProgress = p),
);
} on OnDeviceAiException catch (e) {
_setupError = e.message;
}
_downloadProgress = null;
await _refresh();
}
@override
Widget build(BuildContext context) {
final ready =
_caps != null && _caps!.backend != OnDeviceAiBackend.unsupported;
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text('On-device AI'),
bottom: const TabBar(
tabs: [
Tab(text: 'Chat'),
Tab(text: 'Extract'),
Tab(text: 'Tools'),
Tab(text: 'Screen'),
],
),
),
body: Column(
children: [
_StatusBar(
availability: _availability,
caps: _caps,
progress: _downloadProgress,
error: _setupError,
demo: _demoFallback,
onDemo: (v) {
_demoFallback = v;
_refresh();
},
onPrepare: _prepare,
),
Expanded(
child: ready
? TabBarView(
children: [
ChatTab(ai: _ai),
ExtractTab(ai: _ai),
ToolsTab(ai: _ai),
ScreenTab(ai: _ai),
],
)
: const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No model is usable on this device yet. Follow the '
'message above, or switch on the demo engine to try the UI.',
textAlign: TextAlign.center,
),
),
),
),
],
),
),
);
}
}
class _StatusBar extends StatelessWidget {
const _StatusBar({
required this.availability,
required this.caps,
required this.progress,
required this.error,
required this.demo,
required this.onDemo,
required this.onPrepare,
});
final OnDeviceAiAvailability? availability;
final OnDeviceAiCapabilities? caps;
final double? progress;
final String? error;
final bool demo;
final ValueChanged<bool> onDemo;
final VoidCallback onPrepare;
@override
Widget build(BuildContext context) {
final a = availability;
final c = caps;
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
a == null
? 'Probing…'
: '${c?.backend.name ?? '?'} · ${a.status.name}',
style: Theme.of(context).textTheme.titleSmall,
),
if (a != null)
Text(a.message, style: Theme.of(context).textTheme.bodySmall),
if (c != null)
Text(
'tools: ${c.toolCalling.name} · structured: ${c.structuredOutput.name} · '
'images: ${c.imageInput} · exact tokens: ${c.exactTokenCounts}',
style: Theme.of(context).textTheme.bodySmall,
),
if (progress != null) LinearProgressIndicator(value: progress),
if (error != null)
Text(error!, style: TextStyle(color: scheme.error)),
Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (a != null &&
!a.isReady &&
a.status != ModelFeatureStatus.unavailable)
FilledButton.tonal(
onPressed: onPrepare,
child: const Text('Download model'),
),
if (a?.reason == AvailabilityReason.systemComponentMissing)
FilledButton.tonal(
onPressed: () => FlutterDeviceAi().openAICorePlayStore(),
child: const Text('Open AICore in Play Store'),
),
if (a != null && !a.isReady) ...[
Switch(value: demo, onChanged: onDemo),
const Text('demo engine (UI test only)'),
],
],
),
],
),
),
);
}
}
class ChatTab extends StatefulWidget {
const ChatTab({super.key, required this.ai});
final FlutterDeviceAi ai;
@override
State<ChatTab> createState() => _ChatTabState();
}
class _ChatTabState extends State<ChatTab> {
final _input = TextEditingController();
final _messages = <(bool, String)>[];
OnDeviceAiSession? _session;
String _live = '';
bool _busy = false;
int? _tokens;
Future<void> _send() async {
final text = _input.text.trim();
if (text.isEmpty || _busy) return;
_input.clear();
setState(() {
_messages.add((true, text));
_busy = true;
_live = '';
});
try {
_session ??= await widget.ai.createSession(
systemInstruction: 'You are a concise, friendly assistant.',
);
await for (final chunk in _session!.askStream(text)) {
setState(() => _live += chunk);
}
_tokens = await _session!.countTokens();
setState(() => _messages.add((false, _live)));
} on OnDeviceAiCancelledException {
setState(() => _messages.add((false, '$_live (stopped)')));
} on OnDeviceAiException catch (e) {
setState(() => _messages.add((false, 'Error: ${e.message}')));
} finally {
setState(() {
_live = '';
_busy = false;
});
}
}
@override
void dispose() {
_session?.close();
_input.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final m in _messages) _Bubble(fromUser: m.$1, text: m.$2),
if (_live.isNotEmpty) _Bubble(fromUser: false, text: _live),
],
),
),
if (_tokens != null)
Text(
'conversation: ~$_tokens tokens',
style: Theme.of(context).textTheme.bodySmall,
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _input,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'Message',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
_busy
? IconButton.filledTonal(
onPressed: () => _session?.stopGeneration(),
icon: const Icon(Icons.stop),
)
: IconButton.filled(
onPressed: _send,
icon: const Icon(Icons.send),
),
],
),
),
],
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.fromUser, required this.text});
final bool fromUser;
final String text;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: fromUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(maxWidth: 520),
decoration: BoxDecoration(
color: fromUser
? scheme.primaryContainer
: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: SelectableText(text),
),
);
}
}
class ExtractTab extends StatefulWidget {
const ExtractTab({super.key, required this.ai});
final FlutterDeviceAi ai;
@override
State<ExtractTab> createState() => _ExtractTabState();
}
class _ExtractTabState extends State<ExtractTab> {
final _input = TextEditingController(
text:
'Elena Rostova is a principal engineer with 9 years in ML. '
'She specialises in compilers and distributed systems.',
);
String _out = '';
bool _busy = false;
static const _schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'role': {'type': 'string'},
'yearsExperience': {'type': 'integer', 'minimum': 0},
'specialties': {
'type': 'array',
'items': {'type': 'string'},
},
},
'required': ['name', 'role', 'specialties'],
};
Future<void> _run() async {
setState(() {
_busy = true;
_out = '';
});
try {
final res = await widget.ai.generateText(
prompt: 'Extract the person described here:\n${_input.text}',
config: const GenerationConfig(
responseFormat: ResponseFormat.json,
schema: _schema,
),
);
setState(() => _out = 'Parsed: ${res.json}');
} on OnDeviceAiException catch (e) {
setState(() => _out = '${e.runtimeType}: ${e.message}');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _input,
maxLines: 4,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _busy ? null : _run,
child: const Text('Extract as JSON'),
),
const SizedBox(height: 12),
SelectableText(_out),
],
);
}
}
class ToolsTab extends StatefulWidget {
const ToolsTab({super.key, required this.ai});
final FlutterDeviceAi ai;
@override
State<ToolsTab> createState() => _ToolsTabState();
}
class _ToolsTabState extends State<ToolsTab> {
double _temperature = 20;
final _log = <String>[];
bool _busy = false;
final _input = TextEditingController(
text: 'It feels chilly, set the thermostat to 23 degrees.',
);
Future<void> _run() async {
setState(() {
_busy = true;
_log.clear();
});
final session = await widget.ai.createSession(
systemInstruction: 'You control a smart home. Use tools to act.',
tools: [
OnDeviceAiTool(
name: 'setThermostat',
description:
'Sets the thermostat to a target temperature in Celsius.',
parameters: const [
ToolParameter(
name: 'celsius',
type: ToolArgumentType.number,
description: 'Target temperature in Celsius',
),
],
onCall: (args) {
final c = (args['celsius'] as num).toDouble();
setState(() {
_temperature = c;
_log.add('tool setThermostat(celsius: $c)');
});
return {'ok': true, 'celsius': c};
},
),
],
);
try {
final res = await session.ask(_input.text);
setState(() => _log.add('model: ${res.text}'));
} on OnDeviceAiException catch (e) {
setState(() => _log.add('${e.runtimeType}: ${e.message}'));
} finally {
await session.close();
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Thermostat: ${_temperature.toStringAsFixed(1)} °C',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
TextField(
controller: _input,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
const SizedBox(height: 12),
FilledButton(onPressed: _busy ? null : _run, child: const Text('Ask')),
const SizedBox(height: 12),
for (final l in _log) SelectableText(l),
],
);
}
}
class ScreenTab extends StatefulWidget {
const ScreenTab({super.key, required this.ai});
final FlutterDeviceAi ai;
@override
State<ScreenTab> createState() => _ScreenTabState();
}
class _ScreenTabState extends State<ScreenTab> {
final _goal = TextEditingController(
text: 'A one week plan to prepare for a 10K run',
);
GenUiModuleSpec? _module;
String? _error;
bool _busy = false;
Future<void> _run() async {
setState(() {
_busy = true;
_module = null;
_error = null;
});
final generator = OnDeviceUiGenerator(widget.ai);
final module = await generator.generateModule(_goal.text);
setState(() {
_module = module;
_error = generator.lastError;
_busy = false;
});
}
@override
Widget build(BuildContext context) {
final m = _module;
return ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _goal,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _busy ? null : _run,
child: const Text('Generate screen'),
),
const SizedBox(height: 12),
if (_busy) const LinearProgressIndicator(),
if (_error != null) Text(_error!),
if (m != null) ...[
Text(m.title, style: Theme.of(context).textTheme.headlineSmall),
if (m.blurb.isNotEmpty) Text(m.blurb),
const SizedBox(height: 8),
for (final b in m.blocks) BlockView(block: b),
],
],
);
}
}
/// Minimal renderer for the typed blocks - replace with your own design system.
class BlockView extends StatelessWidget {
const BlockView({super.key, required this.block});
final Map<String, dynamic> block;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
Widget card(Widget child) => Card(
margin: const EdgeInsets.symmetric(vertical: 6),
child: Padding(padding: const EdgeInsets.all(14), child: child),
);
switch (block['type']) {
case 'stat':
return card(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${block['label']}', style: t.labelMedium),
Text('${block['value']}', style: t.headlineMedium),
if (block['subtext'] != null)
Text('${block['subtext']}', style: t.bodySmall),
],
),
);
case 'progress':
final current = (block['current'] as num).toDouble();
final target = (block['target'] as num).toDouble();
return card(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${block['label']} $current / $target ${block['unit'] ?? ''}',
),
const SizedBox(height: 6),
LinearProgressIndicator(value: (current / target).clamp(0, 1)),
],
),
);
case 'checklist':
return card(
Column(
children: [
for (final i in block['items'] as List)
CheckboxListTile(
dense: true,
value: i['completed'] == true,
onChanged: (_) {},
title: Text('${i['title']}'),
),
],
),
);
case 'list':
return card(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (block['title'] != null)
Text('${block['title']}', style: t.titleSmall),
for (final i in block['items'] as List) Text('• $i'),
],
),
);
case 'steps':
return card(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (n, i) in (block['items'] as List).indexed)
Text(
'${n + 1}. ${i['title']}${(i['detail'] as String).isEmpty ? '' : ' - ${i['detail']}'}',
),
],
),
);
case 'table':
return card(
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
for (final c in block['columns'] as List)
DataColumn(label: Text('$c')),
],
rows: [
for (final r in block['rows'] as List)
DataRow(
cells: [
for (final cell in r as List) DataCell(Text('$cell')),
],
),
],
),
),
);
default:
return card(Text('${block['content']}'));
}
}
}