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

PlatformiOS

Apple Intelligence Foundation Models for Flutter: on-device AI, streaming, live speech-to-text, Private Cloud Compute, and hybrid routing to Gemini or ChatGPT.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:cupertino_fundations_models/cupertino_fundations_models.dart';
import 'package:flutter/material.dart';

/// Optional Gemini API key used to demo hybrid routing.
///
/// Run with an external fallback provider:
/// flutter run --dart-define=GEMINI_API_KEY=your_key
const String geminiApiKey = String.fromEnvironment('GEMINI_API_KEY');

/// Backend selection exposed in the example UI.
enum ChatBackend { auto, local, privateCloud, gemini }

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Foundation Models Chat',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0A84FF)),
      ),
      home: const ChatScreen(),
    );
  }
}

/// Minimal Gemini adapter used to demo Apple + external hybrid routing.
///
/// The package never ships provider clients; this adapter lives in the app.
final class GeminiExternalProvider extends FoundationModelsExternalProvider {
  const GeminiExternalProvider({required this.apiKey});

  final String apiKey;

  @override
  String get name => 'gemini';

  @override
  Future<bool> isAvailable() async => apiKey.isNotEmpty;

  @override
  Future<FoundationModelsProviderResponse> respond(
    FoundationModelsRequest request,
  ) async {
    final List<Map<String, Object?>> contents = <Map<String, Object?>>[
      for (final FoundationModelsChatMessage message in request.history)
        <String, Object?>{
          'role': message.role == FoundationModelsChatRole.assistant
              ? 'model'
              : 'user',
          'parts': <Map<String, Object?>>[
            <String, Object?>{'text': message.text},
          ],
        },
      <String, Object?>{
        'role': 'user',
        'parts': <Map<String, Object?>>[
          <String, Object?>{'text': request.prompt.text},
        ],
      },
    ];

    final Map<String, Object?> body = <String, Object?>{
      'contents': contents,
      if (request.instructions != null)
        'systemInstruction': <String, Object?>{
          'parts': <Map<String, Object?>>[
            <String, Object?>{'text': request.instructions},
          ],
        },
    };

    final HttpClient client = HttpClient();
    try {
      final HttpClientRequest httpRequest = await client.postUrl(
        Uri.parse(
          'https://generativelanguage.googleapis.com/v1beta/models/'
          'gemini-2.5-flash:generateContent?key=$apiKey',
        ),
      );
      httpRequest.headers.contentType = ContentType.json;
      httpRequest.write(jsonEncode(body));
      final HttpClientResponse httpResponse = await httpRequest.close();
      final String payload = await httpResponse.transform(utf8.decoder).join();
      if (httpResponse.statusCode != 200) {
        throw FoundationModelsException(
          code: FoundationModelsErrorCode.networkUnavailable,
          message: 'Gemini request failed with ${httpResponse.statusCode}.',
        );
      }
      final Map<String, Object?> decoded = (jsonDecode(payload) as Map)
          .cast<String, Object?>();
      final List<Object?> candidates =
          (decoded['candidates'] as List<Object?>?) ?? <Object?>[];
      final Map<String, Object?> first = candidates.isEmpty
          ? <String, Object?>{}
          : (candidates.first as Map).cast<String, Object?>();
      final Map<String, Object?> content =
          (first['content'] as Map?)?.cast<String, Object?>() ??
          <String, Object?>{};
      final List<Object?> parts =
          (content['parts'] as List<Object?>?) ?? <Object?>[];
      final String text = parts
          .whereType<Map<String, Object?>>()
          .map((Map<String, Object?> part) => part['text'] as String? ?? '')
          .join();
      return FoundationModelsProviderResponse(text: text);
    } finally {
      client.close(force: true);
    }
  }
}

/// Demo tool: the on-device model can call this to read the device clock.
final class DeviceTimeTool implements ModelTool {
  const DeviceTimeTool();

  @override
  String get name => 'get_current_time';

  @override
  String get description =>
      'Returns the current date and time on this device in ISO 8601 format.';

  @override
  Map<String, Object?> get parameters => const <String, Object?>{
    'type': 'object',
    'properties': <String, Object?>{},
  };

  @override
  Duration get timeout => const Duration(seconds: 5);

  @override
  Object? call(Map<String, Object?> arguments) {
    return DateTime.now().toIso8601String();
  }
}

final class ChatMessageView {
  ChatMessageView({
    required this.isUser,
    required this.text,
    this.providerName,
    this.isStreaming = false,
    this.attachmentName,
  });

  final bool isUser;
  String text;
  String? providerName;
  bool isStreaming;
  final String? attachmentName;
}

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

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

final class _ChatScreenState extends State<ChatScreen> {
  final CupertinoFoundationModels _models = CupertinoFoundationModels();
  late FoundationModelsOrchestrator _orchestrator;
  late FoundationModelsChatSession _chat;

  final TextEditingController _input = TextEditingController();
  final ScrollController _scroll = ScrollController();
  final List<ChatMessageView> _messages = <ChatMessageView>[];

  ModelAvailability? _localAvailability;
  PickedFoundationModelsFile? _pendingAttachment;
  StreamSubscription<LiveTranscriptionEvent>? _liveTranscription;
  bool _sending = false;
  bool _listening = false;
  ChatBackend _backend = ChatBackend.auto;
  String? _transcriptionEngine;

  static const String _instructions =
      'You are a helpful, versatile assistant. Always answer in the same '
      'language the user writes in. Fulfill creative requests such as '
      'stories, poems, or brainstorming. Give complete, well-developed '
      'answers; only be brief when the user asks for brevity.';

  static final String _deviceLocale = Platform.localeName;

  @override
  void initState() {
    super.initState();
    _configureChat();
    unawaited(_refreshAvailability());
  }

  void _configureChat() {
    _orchestrator = FoundationModelsOrchestrator(
      apple: _models,
      externalProvider: geminiApiKey.isEmpty
          ? null
          : const GeminiExternalProvider(apiKey: geminiApiKey),
      router: switch (_backend) {
        ChatBackend.auto => FoundationModelsRoutingPolicy.hybrid(
          allowPrivateCloud: true,
          allowExternalFallback: geminiApiKey.isNotEmpty,
        ),
        ChatBackend.local => FoundationModelsRoutingPolicy.localOnly(),
        ChatBackend.privateCloud =>
          FoundationModelsRoutingPolicy.privateCloudFirst(
            allowLocalFallback: false,
          ),
        ChatBackend.gemini => FoundationModelsRoutingPolicy.externalFirst(
          allowAppleFallback: false,
        ),
      },
      defaults: FoundationModelsDefaults(
        localeIdentifier: _deviceLocale,
        options: const GenerationOptions(maximumResponseTokens: 2000),
        tools: const <ModelTool>[DeviceTimeTool()],
      ),
    );
    _chat = _orchestrator.startChat(instructions: _instructions);
  }

  Future<void> _switchBackend(ChatBackend backend) async {
    if (backend == _backend) {
      return;
    }
    final FoundationModelsChatSession previous = _chat;
    setState(() {
      _backend = backend;
      _messages.clear();
      _configureChat();
    });
    await previous.dispose();
  }

  String _backendLabel(ChatBackend backend) {
    return switch (backend) {
      ChatBackend.auto => 'Auto (hybrid)',
      ChatBackend.local => 'Apple on-device',
      ChatBackend.privateCloud => 'Private Cloud Compute',
      ChatBackend.gemini => 'Gemini',
    };
  }

  @override
  void dispose() {
    unawaited(_liveTranscription?.cancel());
    unawaited(_chat.dispose());
    _input.dispose();
    _scroll.dispose();
    super.dispose();
  }

  Future<void> _refreshAvailability() async {
    try {
      final ModelAvailability availability = await _models.checkAvailability(
        mode: ModelMode.local,
        localeIdentifier: _deviceLocale,
      );
      if (mounted) {
        setState(() => _localAvailability = availability);
      }
    } on FoundationModelsException catch (error) {
      if (mounted) {
        setState(
          () => _localAvailability = ModelAvailability.fromMap(
            <Object?, Object?>{
              'status': 'unavailable',
              'reason': error.message,
            },
          ),
        );
      }
    }
  }

  Future<void> _send() async {
    final String text = _input.text.trim();
    if (text.isEmpty || _sending) {
      return;
    }

    await _stopLiveTranscription();
    final PickedFoundationModelsFile? attachment = _pendingAttachment;
    final ChatMessageView reply = ChatMessageView(
      isUser: false,
      text: '',
      isStreaming: true,
    );
    setState(() {
      _sending = true;
      _pendingAttachment = null;
      _messages
        ..add(
          ChatMessageView(
            isUser: true,
            text: text,
            attachmentName: attachment?.name,
          ),
        )
        ..add(reply);
      _input.clear();
    });
    _scrollToEnd();

    try {
      final Stream<OrchestratedChatEvent> stream = _chat.sendStream(
        text,
        attachments: <PromptAttachment>[
          if (attachment != null)
            attachment.toPromptAttachment(label: attachment.name),
        ],
      );
      await for (final OrchestratedChatEvent event in stream) {
        if (!mounted) {
          return;
        }
        switch (event) {
          case OrchestratedChatTextEvent():
            setState(() {
              reply
                ..text = event.text
                ..providerName = event.route.label;
            });
          case OrchestratedChatCompletionEvent():
            setState(() {
              reply
                ..text = event.response.text
                ..providerName = event.response.providerName
                ..isStreaming = false;
            });
        }
        _scrollToEnd();
      }
    } on FoundationModelsException catch (error) {
      if (mounted) {
        setState(() {
          reply
            ..text = error.recoverySuggestion ?? error.message
            ..providerName = 'error: ${error.code.name}'
            ..isStreaming = false;
        });
      }
    } finally {
      if (mounted) {
        setState(() => _sending = false);
      }
      _scrollToEnd();
    }
  }

  Future<void> _toggleLiveTranscription() async {
    if (_listening) {
      await _stopLiveTranscription();
      return;
    }

    setState(() {
      _listening = true;
      _transcriptionEngine = null;
    });
    _liveTranscription = _models
        .liveTranscription(
          request: LiveTranscriptionRequest(
            localeIdentifier: _deviceLocale,
            mode: AudioTranscriptionMode.automatic,
          ),
        )
        .listen(
          (LiveTranscriptionEvent event) {
            _input
              ..text = event.text
              ..selection = TextSelection.collapsed(offset: event.text.length);
            final String? engine = event.metadata['engine'] as String?;
            if (mounted && engine != null && engine != _transcriptionEngine) {
              setState(() => _transcriptionEngine = engine);
            }
            if (event.isFinal && mounted) {
              setState(() => _listening = false);
            }
          },
          onError: (Object error) {
            final String message = error is FoundationModelsException
                ? (error.recoverySuggestion ?? error.message)
                : error.toString();
            _showSnack(message);
            if (mounted) {
              setState(() => _listening = false);
            }
          },
          onDone: () {
            if (mounted && _listening) {
              setState(() => _listening = false);
            }
          },
        );
  }

  Future<void> _stopLiveTranscription() async {
    final StreamSubscription<LiveTranscriptionEvent>? subscription =
        _liveTranscription;
    _liveTranscription = null;
    if (subscription != null) {
      await subscription.cancel();
    }
    if (mounted && _listening) {
      setState(() => _listening = false);
    }
  }

  Future<void> _pickAttachment() async {
    try {
      final PickedFoundationModelsFile? file = await _models.pickFile(
        kind: FoundationModelsFileKind.image,
      );
      if (file != null && mounted) {
        setState(() => _pendingAttachment = file);
      }
    } on FoundationModelsException catch (error) {
      _showSnack(error.recoverySuggestion ?? error.message);
    }
  }

  Future<void> _resetChat() async {
    await _chat.reset();
    if (mounted) {
      setState(_messages.clear);
    }
  }

  Future<void> _showDiagnostics() async {
    try {
      final FoundationModelsDiagnostics diagnostics = await _models
          .getDiagnostics(localeIdentifier: _deviceLocale);
      final String runtimeContext = await _orchestrator
          .buildRuntimePromptContext(refresh: true);
      if (!mounted) {
        return;
      }
      await showDialog<void>(
        context: context,
        builder: (BuildContext context) {
          return AlertDialog(
            title: const Text('Diagnostics'),
            content: SingleChildScrollView(
              child: Text(
                'OS: ${diagnostics.platform} '
                '${diagnostics.operatingSystemVersion}\n'
                'Locale: ${diagnostics.currentLocaleIdentifier}\n'
                'Local model: ${diagnostics.localAvailability.status.name}\n'
                'PCC: '
                '${diagnostics.privateCloudAvailability?.status.name ?? 'unknown'}'
                '\n\n$runtimeContext',
              ),
            ),
            actions: <Widget>[
              TextButton(
                onPressed: () => Navigator.of(context).pop(),
                child: const Text('Close'),
              ),
            ],
          );
        },
      );
    } on FoundationModelsException catch (error) {
      _showSnack(error.recoverySuggestion ?? error.message);
    }
  }

  void _showSnack(String message) {
    if (!mounted) {
      return;
    }
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(SnackBar(content: Text(message)));
  }

  void _scrollToEnd() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scroll.hasClients) {
        unawaited(
          _scroll.animateTo(
            _scroll.position.maxScrollExtent,
            duration: const Duration(milliseconds: 200),
            curve: Curves.easeOut,
          ),
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final ModelAvailability? availability = _localAvailability;
    final bool localReady = availability?.isAvailable ?? false;

    return Scaffold(
      appBar: AppBar(
        title: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            const Text('Foundation Models Chat'),
            Text(
              _backendLabel(_backend),
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
        actions: <Widget>[
          PopupMenuButton<ChatBackend>(
            tooltip: 'Model backend',
            icon: const Icon(Icons.cloud_outlined),
            onSelected: (ChatBackend backend) =>
                unawaited(_switchBackend(backend)),
            itemBuilder: (BuildContext context) {
              return <PopupMenuEntry<ChatBackend>>[
                for (final ChatBackend backend in ChatBackend.values)
                  CheckedPopupMenuItem<ChatBackend>(
                    value: backend,
                    checked: backend == _backend,
                    enabled:
                        backend != ChatBackend.gemini ||
                        geminiApiKey.isNotEmpty,
                    child: Text(_backendLabel(backend)),
                  ),
              ];
            },
          ),
          IconButton(
            tooltip: 'Diagnostics',
            onPressed: _showDiagnostics,
            icon: const Icon(Icons.info_outline),
          ),
          IconButton(
            tooltip: 'Reset conversation',
            onPressed: _sending ? null : _resetChat,
            icon: const Icon(Icons.refresh),
          ),
        ],
      ),
      body: SafeArea(
        child: Column(
          children: <Widget>[
            if (availability != null && !localReady)
              MaterialBanner(
                content: Text(
                  'Apple on-device model unavailable: '
                  '${availability.recoverySuggestion ?? availability.reason ?? availability.status.name}',
                ),
                leading: const Icon(Icons.warning_amber),
                actions: <Widget>[
                  TextButton(
                    onPressed: _refreshAvailability,
                    child: const Text('Retry'),
                  ),
                ],
              ),
            Expanded(
              child: _messages.isEmpty
                  ? _EmptyChatHint(
                      localReady: localReady,
                      hasExternalProvider: geminiApiKey.isNotEmpty,
                    )
                  : ListView.builder(
                      controller: _scroll,
                      padding: const EdgeInsets.all(12),
                      itemCount: _messages.length,
                      itemBuilder: (BuildContext context, int index) {
                        return _MessageBubble(message: _messages[index]);
                      },
                    ),
            ),
            if (_pendingAttachment != null)
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 12),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: InputChip(
                    avatar: const Icon(Icons.image_outlined, size: 18),
                    label: Text(_pendingAttachment!.name),
                    onDeleted: () => setState(() => _pendingAttachment = null),
                  ),
                ),
              ),
            if (_listening)
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text(
                    _transcriptionEngine == null
                        ? 'Listening ($_deviceLocale)…'
                        : 'Listening ($_deviceLocale, '
                              'engine: $_transcriptionEngine)',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ),
              ),
            _InputBar(
              controller: _input,
              sending: _sending,
              listening: _listening,
              onPickAttachment: _pickAttachment,
              onToggleMicrophone: _toggleLiveTranscription,
              onSend: _send,
            ),
          ],
        ),
      ),
    );
  }
}

final class _EmptyChatHint extends StatelessWidget {
  const _EmptyChatHint({
    required this.localReady,
    required this.hasExternalProvider,
  });

  final bool localReady;
  final bool hasExternalProvider;

  @override
  Widget build(BuildContext context) {
    final String routes = <String>[
      if (localReady) 'Apple on-device model',
      'Private Cloud Compute (when available)',
      if (hasExternalProvider) 'Gemini fallback',
    ].join(' + ');
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Icon(Icons.forum_outlined, size: 56),
            const SizedBox(height: 12),
            Text(
              'Start chatting with $routes.\n\n'
              'Use the mic for live transcription and the image button to '
              'attach a picture to your prompt.',
              textAlign: TextAlign.center,
            ),
          ],
        ),
      ),
    );
  }
}

final class _MessageBubble extends StatelessWidget {
  const _MessageBubble({required this.message});

  final ChatMessageView message;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    final bool isUser = message.isUser;
    final String body = message.text.isEmpty && message.isStreaming
        ? '…'
        : message.text;

    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
        constraints: BoxConstraints(
          maxWidth: MediaQuery.sizeOf(context).width * 0.8,
        ),
        decoration: BoxDecoration(
          color: isUser ? colors.primary : colors.surfaceContainerHighest,
          borderRadius: BorderRadius.circular(16),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            if (message.attachmentName != null)
              Padding(
                padding: const EdgeInsets.only(bottom: 4),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    Icon(
                      Icons.attachment,
                      size: 14,
                      color: isUser ? colors.onPrimary : colors.onSurface,
                    ),
                    const SizedBox(width: 4),
                    Flexible(
                      child: Text(
                        message.attachmentName!,
                        style: TextStyle(
                          fontSize: 12,
                          color: isUser ? colors.onPrimary : colors.onSurface,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            Text(
              body,
              style: TextStyle(
                color: isUser ? colors.onPrimary : colors.onSurface,
              ),
            ),
            if (!isUser && message.providerName != null)
              Padding(
                padding: const EdgeInsets.only(top: 4),
                child: Text(
                  message.isStreaming
                      ? '${message.providerName} · streaming'
                      : message.providerName!,
                  style: TextStyle(
                    fontSize: 11,
                    color: colors.onSurfaceVariant,
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

final class _InputBar extends StatelessWidget {
  const _InputBar({
    required this.controller,
    required this.sending,
    required this.listening,
    required this.onPickAttachment,
    required this.onToggleMicrophone,
    required this.onSend,
  });

  final TextEditingController controller;
  final bool sending;
  final bool listening;
  final VoidCallback onPickAttachment;
  final VoidCallback onToggleMicrophone;
  final VoidCallback onSend;

  @override
  Widget build(BuildContext context) {
    final ColorScheme colors = Theme.of(context).colorScheme;
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          IconButton(
            tooltip: 'Attach image',
            onPressed: sending ? null : onPickAttachment,
            icon: const Icon(Icons.image_outlined),
          ),
          IconButton(
            tooltip: listening
                ? 'Stop live transcription'
                : 'Start live transcription',
            onPressed: sending ? null : onToggleMicrophone,
            icon: Icon(
              listening ? Icons.mic : Icons.mic_none,
              color: listening ? colors.error : null,
            ),
          ),
          Expanded(
            child: TextField(
              controller: controller,
              minLines: 1,
              maxLines: 5,
              textInputAction: TextInputAction.send,
              onSubmitted: (_) => onSend(),
              decoration: InputDecoration(
                hintText: listening ? 'Listening…' : 'Message',
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(24),
                ),
                contentPadding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 10,
                ),
              ),
            ),
          ),
          const SizedBox(width: 4),
          IconButton.filled(
            tooltip: 'Send',
            onPressed: sending ? null : onSend,
            icon: sending
                ? const SizedBox(
                    width: 18,
                    height: 18,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.arrow_upward),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
130
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Apple Intelligence Foundation Models for Flutter: on-device AI, streaming, live speech-to-text, Private Cloud Compute, and hybrid routing to Gemini or ChatGPT.

Repository (GitHub)
View/report issues
Contributing

Topics

#apple-intelligence #foundation-models #on-device-ai #ai #speech-to-text

License

MIT (license)

Dependencies

flutter

More

Packages that depend on cupertino_fundations_models

Packages that implement cupertino_fundations_models