Skip to main content

flutter_device_ai 0.1.1 copy "flutter_device_ai: ^0.1.1" to clipboard
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']}'));
    }
  }
}
1
likes
0
points
208
downloads

Publisher

verified publishertechflow.ir

Weekly Downloads

On-device generative AI for Flutter: Apple Foundation Models, Gemini Nano and Chrome built-in AI with streaming, tool calling and JSON-schema output.

Repository (GitHub)
View/report issues

Topics

#ai #llm #on-device #gemini-nano #foundation-models

License

unknown (license)

Dependencies

flutter, flutter_web_plugins, plugin_platform_interface

More

Packages that depend on flutter_device_ai

Packages that implement flutter_device_ai