edge_gen_ai 0.1.1 copy "edge_gen_ai: ^0.1.1" to clipboard
edge_gen_ai: ^0.1.1 copied to clipboard

A Flutter plugin for on-device generative AI, using Apple's Foundation Models (iOS) and Gemini Nano via ML Kit GenAI (Android).

example/lib/main.dart

import 'dart:async';

import 'package:edge_gen_ai/edge_gen_ai.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';

void main() {
  runApp(const MyApp());
}

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

  static ThemeData _theme(Brightness brightness) {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: Colors.black,
      brightness: brightness,
    );
    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      appBarTheme: AppBarTheme(
        centerTitle: false,
        backgroundColor: colorScheme.surface,
        surfaceTintColor: colorScheme.surfaceTint,
        scrolledUnderElevation: 1,
      ),
      cardTheme: CardThemeData(
        elevation: 0,
        color: colorScheme.surfaceContainerHighest,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      ),
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(16),
          borderSide: BorderSide.none,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'EdgeGenAi',
      theme: _theme(Brightness.light),
      darkTheme: _theme(Brightness.dark),
      home: DefaultTabController(
        length: 5,
        child: Scaffold(
          appBar: AppBar(
            title: const Text(
              'EdgeGenAi',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            bottom: const TabBar(
              isScrollable: true,
              tabAlignment: TabAlignment.start,
              tabs: [
                Tab(icon: Icon(Icons.chat_bubble_outline), text: 'Chat'),
                Tab(icon: Icon(Icons.summarize_outlined), text: 'Summarize'),
                Tab(icon: Icon(Icons.spellcheck), text: 'Proofread'),
                Tab(icon: Icon(Icons.auto_fix_high), text: 'Rewrite'),
                Tab(icon: Icon(Icons.image_outlined), text: 'Describe image'),
              ],
            ),
          ),
          body: const TabBarView(
            children: [
              ChatPage(),
              SummarizePage(),
              ProofreadPage(),
              RewritePage(),
              ImageDescriptionPage(),
            ],
          ),
        ),
      ),
    );
  }
}

/// A card that displays a one-shot tool's result, used by the summarize,
/// proofread, rewrite, and image description tabs.
class _ResultCard extends StatelessWidget {
  const _ResultCard(this.text);

  final String text;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.zero,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(
                  Icons.auto_awesome,
                  size: 16,
                  color: Theme.of(context).colorScheme.primary,
                ),
                const SizedBox(width: 6),
                Text(
                  'Result',
                  style: Theme.of(context).textTheme.labelMedium?.copyWith(
                    color: Theme.of(context).colorScheme.primary,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            SelectableText(text),
          ],
        ),
      ),
    );
  }
}

/// A single message in the conversation transcript shown in the example UI.
class _ChatMessage {
  _ChatMessage({required this.isUser, required this.text, this.image});

  final bool isUser;
  String text;
  final Uint8List? image;
}

/// Chat demo built on [EdgeGenAIPrompt] with conversation memory.
class ChatPage extends StatefulWidget {
  const ChatPage({super.key});

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage>
    with AutomaticKeepAliveClientMixin {
  EdgeGenAIAvailability? _availability;
  EdgeGenAIDownloadProgress? _downloadProgress;
  final List<_ChatMessage> _messages = [];
  bool _isGenerating = false;
  var _prompt = EdgeGenAIPrompt(useMemory: true);
  final _promptController = TextEditingController(
    text: 'Write a 3 sentence story about a magical dog.',
  );
  Uint8List? _pendingImage;

  @override
  bool get wantKeepAlive => true;

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

  @override
  void dispose() {
    _promptController.dispose();
    super.dispose();
  }

  Future<void> initPlatformState() async {
    EdgeGenAIAvailability availability;
    try {
      availability = await _prompt.checkAvailability();
    } on PlatformException {
      availability = EdgeGenAIAvailability.unavailable;
    }

    if (!mounted) return;

    setState(() {
      _availability = availability;
    });
  }

  void _downloadModel() {
    _prompt.downloadModel().listen(
      (progress) {
        if (!mounted) return;
        setState(() => _downloadProgress = progress);
      },
      onError: (Object error) {
        if (!mounted) return;
        ScaffoldMessenger.of(
          context,
        ).showSnackBar(SnackBar(content: Text('Download failed: $error')));
      },
    );
  }

  Future<void> _pickImage() async {
    final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
    if (picked == null) return;
    final bytes = await picked.readAsBytes();
    if (!mounted) return;
    setState(() => _pendingImage = bytes);
  }

  void _generateContent() {
    final prompt = _promptController.text;
    if (prompt.trim().isEmpty) return;
    final image = _pendingImage;

    final modelMessage = _ChatMessage(isUser: false, text: '');
    setState(() {
      _isGenerating = true;
      _messages.add(_ChatMessage(isUser: true, text: prompt, image: image));
      _messages.add(modelMessage);
      _pendingImage = null;
    });
    _promptController.clear();

    _prompt
        .generateContent(prompt, image: image)
        .listen(
          (chunk) {
            if (!mounted) return;
            setState(() => modelMessage.text = chunk);
          },
          onError: (Object error) {
            if (!mounted) return;
            setState(() {
              _isGenerating = false;
              modelMessage.text = 'Failed to generate content: $error';
            });
          },
          onDone: () {
            if (!mounted) return;
            setState(() => _isGenerating = false);
          },
        );
  }

  Future<void> _resetConversation() async {
    await _prompt.resetConversation();
    if (!mounted) return;
    setState(() => _messages.clear());
  }

  void _setUseMemory(bool useMemory) {
    if (useMemory == _prompt.useMemory) return;
    // useMemory is fixed per EdgeGenAIPrompt instance, so switching it means
    // starting a fresh conversation on a new instance.
    setState(() {
      _prompt = EdgeGenAIPrompt(useMemory: useMemory);
      _messages.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final canDownload = _availability == EdgeGenAIAvailability.downloadable;
    final colorScheme = Theme.of(context).colorScheme;
    return Column(
      children: [
        Container(
          padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
          decoration: BoxDecoration(
            color: colorScheme.surfaceContainerLow,
            border: Border(
              bottom: BorderSide(color: colorScheme.outlineVariant),
            ),
          ),
          child: Row(
            children: [
              Icon(
                switch (_availability) {
                  EdgeGenAIAvailability.available => Icons.check_circle,
                  EdgeGenAIAvailability.downloadable => Icons.cloud_download,
                  null => Icons.hourglass_empty,
                  _ => Icons.error_outline,
                },
                size: 18,
                color: colorScheme.secondary,
              ),
              const SizedBox(width: 8),
              Expanded(
                child: Text(
                  'Availability: ${_availability?.name ?? 'Checking...'}'
                  '${_downloadProgress != null ? ' — ${_downloadProgress!.status.name}'
                            '${_downloadProgress!.bytesDownloaded != null ? ' (${_downloadProgress!.bytesDownloaded} bytes)' : ''}' : ''}',
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ),
              if (canDownload)
                TextButton(
                  onPressed: _downloadModel,
                  child: const Text('Download'),
                ),
              Tooltip(
                message: 'Conversation memory',
                child: Icon(
                  Icons.memory,
                  size: 18,
                  color: colorScheme.onSurfaceVariant,
                ),
              ),
              Switch(value: _prompt.useMemory, onChanged: _setUseMemory),
              IconButton(
                onPressed: _messages.isEmpty ? null : _resetConversation,
                icon: const Icon(Icons.refresh),
                tooltip: 'New conversation',
              ),
            ],
          ),
        ),
        Expanded(
          child: _messages.isEmpty
              ? Center(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Icon(
                        Icons.chat_bubble_outline,
                        size: 40,
                        color: colorScheme.outline,
                      ),
                      const SizedBox(height: 12),
                      Text(
                        'Say hello to get started.',
                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: colorScheme.outline,
                        ),
                      ),
                    ],
                  ),
                )
              : ListView.builder(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 12,
                  ),
                  itemCount: _messages.length,
                  itemBuilder: (context, index) {
                    final message = _messages[index];
                    return Align(
                      alignment: message.isUser
                          ? Alignment.centerRight
                          : Alignment.centerLeft,
                      child: ConstrainedBox(
                        constraints: BoxConstraints(
                          maxWidth: MediaQuery.of(context).size.width * 0.78,
                        ),
                        child: Container(
                          margin: const EdgeInsets.symmetric(vertical: 4),
                          padding: const EdgeInsets.symmetric(
                            horizontal: 14,
                            vertical: 10,
                          ),
                          decoration: BoxDecoration(
                            color: message.isUser
                                ? colorScheme.primaryContainer
                                : colorScheme.surfaceContainerHighest,
                            borderRadius: BorderRadius.only(
                              topLeft: const Radius.circular(18),
                              topRight: const Radius.circular(18),
                              bottomLeft: Radius.circular(
                                message.isUser ? 18 : 4,
                              ),
                              bottomRight: Radius.circular(
                                message.isUser ? 4 : 18,
                              ),
                            ),
                          ),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              if (message.image != null)
                                Padding(
                                  padding: const EdgeInsets.only(bottom: 8),
                                  child: ClipRRect(
                                    borderRadius: BorderRadius.circular(12),
                                    child: Image.memory(
                                      message.image!,
                                      height: 150,
                                      fit: BoxFit.cover,
                                    ),
                                  ),
                                ),
                              Text(message.text),
                            ],
                          ),
                        ),
                      ),
                    );
                  },
                ),
        ),
        SafeArea(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (_pendingImage != null)
                  Padding(
                    padding: const EdgeInsets.only(bottom: 8),
                    child: Stack(
                      children: [
                        ClipRRect(
                          borderRadius: BorderRadius.circular(12),
                          child: Image.memory(
                            _pendingImage!,
                            height: 80,
                            fit: BoxFit.cover,
                          ),
                        ),
                        Positioned(
                          top: 2,
                          right: 2,
                          child: CircleAvatar(
                            radius: 12,
                            backgroundColor: Colors.black54,
                            child: IconButton(
                              padding: EdgeInsets.zero,
                              iconSize: 16,
                              icon: const Icon(
                                Icons.close,
                                color: Colors.white,
                              ),
                              onPressed: () =>
                                  setState(() => _pendingImage = null),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                Row(
                  children: [
                    IconButton(
                      onPressed: _isGenerating ? null : _pickImage,
                      icon: const Icon(Icons.image_outlined),
                      tooltip: 'Attach image',
                    ),
                    Expanded(
                      child: TextField(
                        controller: _promptController,
                        minLines: 1,
                        maxLines: 5,
                        textCapitalization: TextCapitalization.sentences,
                        decoration: InputDecoration(
                          hintText: 'Message',
                          contentPadding: const EdgeInsets.symmetric(
                            horizontal: 16,
                            vertical: 12,
                          ),
                          border: OutlineInputBorder(
                            borderRadius: BorderRadius.circular(24),
                            borderSide: BorderSide.none,
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    IconButton.filled(
                      onPressed: _isGenerating ? null : _generateContent,
                      icon: _isGenerating
                          ? const SizedBox(
                              width: 18,
                              height: 18,
                              child: CircularProgressIndicator(strokeWidth: 2),
                            )
                          : const Icon(Icons.send),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

/// A one-shot text tool demo page: enter text, run [action], show the
/// result. Shared by the summarize and proofread tabs, which only differ
/// in their initial text, button label, and action.
class _TextToolPage extends StatefulWidget {
  const _TextToolPage({
    required this.initialText,
    required this.buttonLabel,
    required this.icon,
    required this.action,
  });

  final String initialText;
  final String buttonLabel;
  final IconData icon;
  final Future<String> Function(String text) action;

  @override
  State<_TextToolPage> createState() => _TextToolPageState();
}

class _TextToolPageState extends State<_TextToolPage>
    with AutomaticKeepAliveClientMixin {
  late final _controller = TextEditingController(text: widget.initialText);
  String? _result;
  bool _isRunning = false;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _run() async {
    final text = _controller.text;
    if (text.trim().isEmpty) return;
    setState(() {
      _isRunning = true;
      _result = null;
    });
    String result;
    try {
      result = await widget.action(text);
    } catch (error) {
      result = 'Failed: $error';
    }
    if (!mounted) return;
    setState(() {
      _isRunning = false;
      _result = result;
    });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextField(
            controller: _controller,
            maxLines: 5,
            decoration: const InputDecoration(
              labelText: 'Text',
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 8),
          FilledButton.icon(
            onPressed: _isRunning ? null : _run,
            icon: _isRunning
                ? const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : Icon(widget.icon),
            label: Text(widget.buttonLabel),
          ),
          const SizedBox(height: 16),
          if (_result != null) _ResultCard(_result!),
        ],
      ),
    );
  }
}

/// Demo of [EdgeGenAISummarizer].
class SummarizePage extends StatelessWidget {
  const SummarizePage({super.key});

  @override
  Widget build(BuildContext context) {
    return _TextToolPage(
      // Android's summarizer requires at least 400 characters of input for
      // its default ARTICLE input type, so this needs to be a real
      // paragraph rather than a couple of short sentences.
      initialText:
          'The quick brown fox jumps over the lazy dog. It was a sunny day '
          'and everyone in the neighborhood was outside enjoying the '
          'weather, playing games, and having picnics in the park. Children '
          'rode their bicycles up and down the street while their parents '
          'set up folding chairs and coolers on the front lawns. Someone '
          'brought a portable speaker and played music that could be heard '
          'from several houses away. By the time the sun began to set, '
          'everyone agreed it had been one of the best days of the summer '
          'so far, and several neighbors started planning another '
          'get-together for the following weekend.',
      buttonLabel: 'Summarize',
      icon: Icons.summarize_outlined,
      action: EdgeGenAISummarizer().summarize,
    );
  }
}

/// Demo of [EdgeGenAIProofreader].
class ProofreadPage extends StatelessWidget {
  const ProofreadPage({super.key});

  @override
  Widget build(BuildContext context) {
    return _TextToolPage(
      initialText:
          'the quick brown fox jumsp over the lazy dog it was a sunny day '
          'and everyone was outside enjoying the wether',
      buttonLabel: 'Proofread',
      icon: Icons.spellcheck,
      action: EdgeGenAIProofreader().proofread,
    );
  }
}

/// Demo of [EdgeGenAIRewriter], which additionally needs a style picker.
class RewritePage extends StatefulWidget {
  const RewritePage({super.key});

  @override
  State<RewritePage> createState() => _RewritePageState();
}

class _RewritePageState extends State<RewritePage>
    with AutomaticKeepAliveClientMixin {
  final _rewriter = EdgeGenAIRewriter();
  final _controller = TextEditingController(
    text:
        'Hey, can you send me the report when you get a chance? Thanks a '
        'lot!',
  );
  EdgeGenAIRewriteStyle _style = EdgeGenAIRewriteStyle.professional;
  String? _result;
  bool _isRunning = false;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _run() async {
    final text = _controller.text;
    if (text.trim().isEmpty) return;
    setState(() {
      _isRunning = true;
      _result = null;
    });
    String result;
    try {
      result = await _rewriter.rewrite(text, style: _style);
    } catch (error) {
      result = 'Failed: $error';
    }
    if (!mounted) return;
    setState(() {
      _isRunning = false;
      _result = result;
    });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextField(
            controller: _controller,
            maxLines: 5,
            decoration: const InputDecoration(
              labelText: 'Text',
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            crossAxisAlignment: WrapCrossAlignment.center,
            children: [
              FilledButton.icon(
                onPressed: _isRunning ? null : _run,
                icon: _isRunning
                    ? const SizedBox(
                        width: 16,
                        height: 16,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Icon(Icons.auto_fix_high),
                label: const Text('Rewrite'),
              ),
              DropdownButton<EdgeGenAIRewriteStyle>(
                value: _style,
                items: [
                  for (final style in EdgeGenAIRewriteStyle.values)
                    DropdownMenuItem(value: style, child: Text(style.name)),
                ],
                onChanged: (style) {
                  if (style != null) setState(() => _style = style);
                },
              ),
            ],
          ),
          const SizedBox(height: 16),
          if (_result != null) _ResultCard(_result!),
        ],
      ),
    );
  }
}

/// Demo of [EdgeGenAIImageDescriber]: pick a photo, then describe it.
class ImageDescriptionPage extends StatefulWidget {
  const ImageDescriptionPage({super.key});

  @override
  State<ImageDescriptionPage> createState() => _ImageDescriptionPageState();
}

class _ImageDescriptionPageState extends State<ImageDescriptionPage>
    with AutomaticKeepAliveClientMixin {
  final _describer = EdgeGenAIImageDescriber();
  Uint8List? _imageBytes;
  String? _result;
  bool _isRunning = false;

  @override
  bool get wantKeepAlive => true;

  Future<void> _pickImage() async {
    final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
    if (picked == null) return;
    final bytes = await picked.readAsBytes();
    if (!mounted) return;
    setState(() {
      _imageBytes = bytes;
      _result = null;
    });
  }

  Future<void> _describe() async {
    final bytes = _imageBytes;
    if (bytes == null) return;
    setState(() {
      _isRunning = true;
      _result = null;
    });
    String result;
    try {
      result = await _describer.describeImage(bytes);
    } catch (error) {
      result = 'Failed: $error';
    }
    if (!mounted) return;
    setState(() {
      _isRunning = false;
      _result = result;
    });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final imageBytes = _imageBytes;
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          if (imageBytes != null)
            ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Image.memory(imageBytes, height: 200, fit: BoxFit.cover),
            ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              OutlinedButton.icon(
                onPressed: _pickImage,
                icon: const Icon(Icons.photo_library_outlined),
                label: const Text('Pick image'),
              ),
              FilledButton.icon(
                onPressed: (imageBytes == null || _isRunning)
                    ? null
                    : _describe,
                icon: _isRunning
                    ? const SizedBox(
                        width: 16,
                        height: 16,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Icon(Icons.image_search),
                label: const Text('Describe'),
              ),
            ],
          ),
          const SizedBox(height: 16),
          if (_result != null) _ResultCard(_result!),
        ],
      ),
    );
  }
}
2
likes
0
points
150
downloads

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin for on-device generative AI, using Apple's Foundation Models (iOS) and Gemini Nano via ML Kit GenAI (Android).

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, meta, plugin_platform_interface

More

Packages that depend on edge_gen_ai

Packages that implement edge_gen_ai