cera_ffi_flutter 0.5.4 copy "cera_ffi_flutter: ^0.5.4" to clipboard
cera_ffi_flutter: ^0.5.4 copied to clipboard

Flutter FFI plugin for the Cera on-device inference engine. Ships prebuilt native libraries for Android, iOS, macOS, Linux and Windows.

example/lib/main.dart

// Example Flutter app demonstrating `cera_ffi_flutter` across iOS, Android,
// macOS, Linux, Windows, and the Web.
//
// Demonstrates MVI (Model-View-Intent) architecture with clean model lifecycle
// management (guaranteed engine unloading and resource cleanup on model switch).
//
// Running locally:
//   flutter run -d macos
//   flutter run -d chrome

import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'
    show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';

import 'package:cera_ffi_flutter/cera_ffi_flutter.dart';
import 'chat_controller.dart';
import 'chat_intent.dart';
import 'chat_state.dart';
import 'model_source.dart';
import 'widgets/bundle_picker_dialog.dart';
import 'widgets/message_composer.dart';
import 'widgets/message_list.dart';
import 'widgets/tts_studio_view.dart';

void main() => runApp(const CeraExampleApp());

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

  @override
  Widget build(BuildContext context) {
    const bgDark = Color(0xFF0B0C0E);
    const surfaceDark = Color(0xFF14161B);
    const borderDark = Color(0xFF232732);
    const textPrimary = Color(0xFFF1F5F9);
    const textSecondary = Color(0xFF8E95A5);
    const accentBlue = Color(0xFF3B82F6);

    return MaterialApp(
      title: 'Cera',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        scaffoldBackgroundColor: bgDark,
        canvasColor: bgDark,
        cardColor: surfaceDark,
        dividerColor: borderDark,
        colorScheme: const ColorScheme.dark(
          primary: accentBlue,
          surface: surfaceDark,
          outline: borderDark,
          outlineVariant: Color(0xFF1E222D),
          onSurface: textPrimary,
          onSurfaceVariant: textSecondary,
        ),
        appBarTheme: const AppBarTheme(
          backgroundColor: surfaceDark,
          elevation: 0,
          scrolledUnderElevation: 0,
          titleTextStyle: TextStyle(
            color: textPrimary,
            fontSize: 17,
            fontWeight: FontWeight.w600,
          ),
        ),
      ),
      home: const ChatPage(),
    );
  }
}

/// Main chat interface built with MVI architecture.
class ChatPage extends StatefulWidget {
  const ChatPage({super.key});

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

class _ChatPageState extends State<ChatPage> {
  late final ChatController _controller = ChatController(
    defaultStoreDir: _storeDir,
  );
  final TextEditingController _inputController = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  bool _scrollPending = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onStateChange);
    // Restore the previously loaded model if one was saved
    _controller.dispatch(const RestoreLastModelIntent());
  }

  void _onStateChange() {
    if (!mounted) return;
    if (_controller.value.isGenerating) {
      if (!_scrollController.hasClients) return;
      final pos = _scrollController.position;
      final distanceToBottom = pos.maxScrollExtent - pos.pixels;
      // Only auto-scroll if the user is already near the bottom (within 120px)
      if (distanceToBottom < 120) {
        _scrollToBottom(isStreaming: true);
      }
    }
  }

  void _scrollToBottom({bool isStreaming = false}) {
    if (_scrollPending) return;
    _scrollPending = true;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollPending = false;
      if (!mounted || !_scrollController.hasClients) return;
      final target = _scrollController.position.maxScrollExtent;
      if (isStreaming) {
        _scrollController.jumpTo(target);
      } else {
        _scrollController.animateTo(
          target,
          duration: const Duration(milliseconds: 150),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  void dispose() {
    _controller.removeListener(_onStateChange);
    _controller.dispose();
    _inputController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  /// Supplies bundle cache directory for mobile platforms.
  Future<String?> _storeDir() async {
    if (kIsWeb) return null;
    final mobile =
        defaultTargetPlatform == TargetPlatform.android ||
        defaultTargetPlatform == TargetPlatform.iOS;
    if (!mobile) return null;
    return (await getApplicationSupportDirectory()).path;
  }

  /// Opens the bundle selector dialog and dispatches load intent.
  Future<void> _pickBundle(ChatState state) async {
    String? currentBundleName;
    String? currentQuant;
    final loaded = state.loadedModel;
    if (loaded is BundleModelSource) {
      currentBundleName = loaded.bundleName;
      currentQuant = loaded.quant;
    }

    final choice = await showDialog<BundleChoice>(
      context: context,
      builder: (_) => BundlePickerDialog(
        currentBundleName: currentBundleName,
        currentQuant: currentQuant,
      ),
    );

    if (choice == null || !mounted) return;

    _controller.dispatch(
      LoadBundleIntent(
        bundleName: choice.bundleName,
        quant: choice.quant,
        displayName: choice.displayName,
        storeDir: _storeDir,
      ),
    );
  }

  /// Opens a local .gguf file from device storage.
  Future<void> _pickLocalModel() async {
    try {
      final source = await pickModelSource(dialogTitle: 'Choose a .gguf model');
      if (source != null && mounted) {
        _controller.dispatch(LoadLocalModelIntent(source));
      }
    } catch (err) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Could not open local model: $err')),
        );
      }
    }
  }

  /// Attaches an image file for vision chat inference.
  Future<void> _pickImage() async {
    try {
      final result = await FilePicker.platform.pickFiles(
        type: FileType.image,
        withData: true,
        dialogTitle: 'Select an image to attach',
      );
      final file = result?.files.single;
      if (file == null || !mounted) return;

      final bytes = file.bytes;
      if (bytes == null) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Could not read image file bytes')),
          );
        }
        return;
      }

      _controller.dispatch(AttachImageIntent(bytes: bytes, name: file.name));
    } catch (err) {
      if (mounted) {
        ScaffoldMessenger.of(
          context,
        ).showSnackBar(SnackBar(content: Text('Could not attach image: $err')));
      }
    }
  }

  void _sendMessage() {
    final prompt = _inputController.text.trim();
    if (prompt.isEmpty && _controller.value.pendingImageBytes == null) return;
    _inputController.clear();
    _scrollToBottom();
    _controller.dispatch(SendMessageIntent(prompt));
  }

  void _showSettingsSheet(ChatState state) {
    final theme = Theme.of(context);
    showModalBottomSheet(
      context: context,
      backgroundColor: theme.colorScheme.surface,
      isScrollControlled: true,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      ),
      builder: (context) {
        return ListenableBuilder(
          listenable: _controller,
          builder: (context, _) {
            final currentState = _controller.value;
            final currentSettings = currentState.settings;
            return SafeArea(
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  vertical: 12,
                  horizontal: 16,
                ),
                child: SingleChildScrollView(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Center(
                        child: Container(
                          width: 36,
                          height: 4,
                          margin: const EdgeInsets.only(bottom: 16),
                          decoration: BoxDecoration(
                            color: theme.colorScheme.outlineVariant,
                            borderRadius: BorderRadius.circular(2),
                          ),
                        ),
                      ),
                      Row(
                        children: [
                          Icon(
                            Icons.settings_outlined,
                            size: 20,
                            color: theme.colorScheme.primary,
                          ),
                          const SizedBox(width: 8),
                          Text(
                            'Settings & Models',
                            style: TextStyle(
                              fontSize: 16,
                              fontWeight: FontWeight.w600,
                              color: theme.colorScheme.onSurface,
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 16),
                      Text(
                        'MODELS',
                        style: TextStyle(
                          fontSize: 11,
                          fontWeight: FontWeight.w600,
                          letterSpacing: 0.8,
                          color: theme.colorScheme.onSurfaceVariant,
                        ),
                      ),
                      const SizedBox(height: 6),
                      ListTile(
                        contentPadding: EdgeInsets.zero,
                        leading: Icon(
                          Icons.cloud_download_outlined,
                          color: theme.colorScheme.primary,
                        ),
                        title: const Text('Downloaded & Catalog Models'),
                        subtitle: const Text(
                          'Switch between cached models or download new ones',
                        ),
                        onTap: () {
                          Navigator.of(context).pop();
                          _pickBundle(_controller.value);
                        },
                      ),
                      ListTile(
                        contentPadding: EdgeInsets.zero,
                        leading: Icon(
                          Icons.folder_open_outlined,
                          color: theme.colorScheme.primary,
                        ),
                        title: const Text('Open Local .gguf File...'),
                        subtitle: const Text(
                          'Pick a model file from disk storage',
                        ),
                        onTap: () {
                          Navigator.of(context).pop();
                          _pickLocalModel();
                        },
                      ),
                      if (currentState.hasModel) ...[
                        ListTile(
                          contentPadding: EdgeInsets.zero,
                          leading: Icon(
                            Icons.eject_outlined,
                            color: theme.colorScheme.error,
                          ),
                          title: Text(
                            'Unload Active Model',
                            style: TextStyle(color: theme.colorScheme.error),
                          ),
                          subtitle: const Text(
                            'Release memory and close model engine',
                          ),
                          onTap: () {
                            Navigator.of(context).pop();
                            _controller.dispatch(const UnloadModelIntent());
                          },
                        ),
                      ],
                      Divider(color: theme.dividerColor, height: 24),
                      Text(
                        'INFERENCE & OPTIMIZATION',
                        style: TextStyle(
                          fontSize: 11,
                          fontWeight: FontWeight.w600,
                          letterSpacing: 0.8,
                          color: theme.colorScheme.onSurfaceVariant,
                        ),
                      ),
                      const SizedBox(height: 8),
                      ListTile(
                        contentPadding: EdgeInsets.zero,
                        title: const Text('Compute Backend'),
                        subtitle: Text(
                          'Choose between Auto (WebGPU / Metal), GPU only, or CPU backend (WASM / Single-core).',
                          style: TextStyle(
                            fontSize: 12,
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                        trailing: DropdownButton<CeraBackend>(
                          value: currentSettings.backend,
                          dropdownColor: theme.colorScheme.surface,
                          underline: const SizedBox.shrink(),
                          items: const [
                            DropdownMenuItem(
                              value: CeraBackend.auto,
                              child: Text('Auto (GPU / Fallback)'),
                            ),
                            DropdownMenuItem(
                              value: CeraBackend.gpu,
                              child: Text('GPU (WebGPU / Metal)'),
                            ),
                            DropdownMenuItem(
                              value: CeraBackend.cpu,
                              child: Text('CPU (WASM / CPU)'),
                            ),
                          ],
                          onChanged: (val) {
                            if (val == null) return;
                            _controller.dispatch(
                              UpdateSettingsIntent(backend: val),
                            );
                          },
                        ),
                      ),
                      const SizedBox(height: 8),
                      SwitchListTile(
                        contentPadding: EdgeInsets.zero,
                        title: const Text('TurboQuant KV Compression'),
                        subtitle: Text(
                          'Compresses KV cache to 3-bit keys / 2-bit values for lower memory footprint and faster multi-turn attention. (Default: Off)',
                          style: TextStyle(
                            fontSize: 12,
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                        value: currentSettings.turboQuant,
                        onChanged: (val) {
                          _controller.dispatch(
                            UpdateSettingsIntent(turboQuant: val),
                          );
                        },
                      ),
                      const SizedBox(height: 8),
                      ListTile(
                        contentPadding: EdgeInsets.zero,
                        title: const Text('Vision Max Image Dimension'),
                        subtitle: Text(
                          'Caps the long side of input images before ViT patch encoding to minimize prompt latency.',
                          style: TextStyle(
                            fontSize: 12,
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                        trailing: DropdownButton<int?>(
                          value: currentSettings.maxImageLongSize,
                          dropdownColor: theme.colorScheme.surface,
                          underline: const SizedBox.shrink(),
                          items: const [
                            DropdownMenuItem(
                              value: 256,
                              child: Text('256 px (Fast)'),
                            ),
                            DropdownMenuItem(
                              value: 512,
                              child: Text('512 px (HD)'),
                            ),
                            DropdownMenuItem(
                              value: null,
                              child: Text('Native / Off'),
                            ),
                          ],
                          onChanged: (val) {
                            _controller.dispatch(
                              UpdateSettingsIntent(
                                maxImageLongSize: val,
                                clearMaxImageLongSize: val == null,
                              ),
                            );
                          },
                        ),
                      ),
                      const SizedBox(height: 8),
                      ListTile(
                        contentPadding: EdgeInsets.zero,
                        title: const Text('Voice Mode'),
                        subtitle: Text(
                          'Select how audio-capable models interact with speech (Speech to Text ASR, Interleaved Voice Chat, Text to Speech TTS, or Text Only).',
                          style: TextStyle(
                            fontSize: 12,
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                        trailing: DropdownButton<AudioChatMode>(
                          value: currentSettings.audioChatMode,
                          dropdownColor: theme.colorScheme.surface,
                          underline: const SizedBox.shrink(),
                          items: const [
                            DropdownMenuItem(
                              value: AudioChatMode.speechToText,
                              child: Text('Speech to Text (ASR)'),
                            ),
                            DropdownMenuItem(
                              value: AudioChatMode.interleaved,
                              child: Text('Voice Chat (Interleaved)'),
                            ),
                            DropdownMenuItem(
                              value: AudioChatMode.textToSpeech,
                              child: Text('Text to Speech (TTS)'),
                            ),
                            DropdownMenuItem(
                              value: AudioChatMode.textOnly,
                              child: Text('Text Only (No Audio)'),
                            ),
                          ],
                          onChanged: (val) {
                            if (val == null) return;
                            _controller.dispatch(
                              UpdateSettingsIntent(audioChatMode: val),
                            );
                          },
                        ),
                      ),
                      if (currentSettings.audioChatMode ==
                              AudioChatMode.interleaved ||
                          currentSettings.audioChatMode ==
                              AudioChatMode.textToSpeech) ...[
                        const SizedBox(height: 8),
                        ListTile(
                          contentPadding: EdgeInsets.zero,
                          title: const Text('Voice Persona'),
                          subtitle: Text(
                            'Select speaker timbre and accent for synthesized speech responses.',
                            style: TextStyle(
                              fontSize: 12,
                              color: theme.colorScheme.onSurfaceVariant,
                            ),
                          ),
                          trailing: DropdownButton<String?>(
                            value: currentSettings.chatVoice,
                            dropdownColor: theme.colorScheme.surface,
                            underline: const SizedBox.shrink(),
                            items: const [
                              DropdownMenuItem(
                                value: null,
                                child: Text('Default Voice'),
                              ),
                              DropdownMenuItem(
                                value: 'Use the US female voice.',
                                child: Text('👩 US Female'),
                              ),
                              DropdownMenuItem(
                                value: 'Use the US male voice.',
                                child: Text('👨 US Male'),
                              ),
                              DropdownMenuItem(
                                value: 'Use the UK female voice.',
                                child: Text('👩 UK Female'),
                              ),
                              DropdownMenuItem(
                                value: 'Use the UK male voice.',
                                child: Text('👨 UK Male'),
                              ),
                            ],
                            onChanged: (val) {
                              _controller.dispatch(
                                UpdateSettingsIntent(
                                  chatVoice: val,
                                  clearChatVoice: val == null,
                                ),
                              );
                            },
                          ),
                        ),
                      ],
                      if (currentState.hasModel) ...[
                        Divider(color: theme.dividerColor, height: 24),
                        Text(
                          'BACKEND & DEVICE',
                          style: TextStyle(
                            fontSize: 11,
                            fontWeight: FontWeight.w600,
                            letterSpacing: 0.8,
                            color: theme.colorScheme.onSurfaceVariant,
                          ),
                        ),
                        const SizedBox(height: 8),
                        Container(
                          padding: const EdgeInsets.all(12),
                          decoration: BoxDecoration(
                            color: theme.colorScheme.outlineVariant.withValues(
                              alpha: 0.25,
                            ),
                            borderRadius: BorderRadius.circular(8),
                            border: Border.all(
                              color: theme.colorScheme.outlineVariant,
                            ),
                          ),
                          child: Row(
                            children: [
                              Icon(
                                Icons.developer_board_rounded,
                                size: 24,
                                color: theme.colorScheme.primary,
                              ),
                              const SizedBox(width: 12),
                              Expanded(
                                child: Column(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: [
                                    Text(
                                      currentState.backend ?? 'Unknown Backend',
                                      style: const TextStyle(
                                        fontSize: 13,
                                        fontWeight: FontWeight.w500,
                                      ),
                                    ),
                                    const SizedBox(height: 2),
                                    Text(
                                      'Active model: ${currentState.loadedModel?.name ?? ""}',
                                      style: TextStyle(
                                        fontSize: 11,
                                        color:
                                            theme.colorScheme.onSurfaceVariant,
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                      const SizedBox(height: 12),
                    ],
                  ),
                ),
              ),
            );
          },
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return ValueListenableBuilder<ChatState>(
      valueListenable: _controller,
      builder: (context, state, _) {
        return Scaffold(
          appBar: AppBar(
            title: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text('Cera'),
                    const SizedBox(width: 8),
                    Flexible(
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 6,
                          vertical: 2,
                        ),
                        decoration: BoxDecoration(
                          color: theme.colorScheme.primaryContainer,
                          borderRadius: BorderRadius.circular(6),
                        ),
                        child: Text(
                          'v0.5.4',
                          overflow: TextOverflow.ellipsis,
                          style: TextStyle(
                            fontSize: 10,
                            fontWeight: FontWeight.bold,
                            color: theme.colorScheme.onPrimaryContainer,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 2),
                Text(
                  state.status,
                  style: TextStyle(
                    fontSize: 11,
                    fontWeight: FontWeight.normal,
                    color: theme.colorScheme.onSurfaceVariant,
                  ),
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ),
            bottom: PreferredSize(
              preferredSize: const Size.fromHeight(3),
              child: state.isLoading
                  ? LinearProgressIndicator(
                      value: state.downloadFraction,
                      minHeight: 3,
                      backgroundColor: theme.colorScheme.outlineVariant,
                      valueColor: AlwaysStoppedAnimation<Color>(
                        theme.colorScheme.primary,
                      ),
                    )
                  : Container(
                      color: theme.colorScheme.outlineVariant,
                      height: 1,
                    ),
            ),
            actions: [
              Padding(
                padding: const EdgeInsets.symmetric(vertical: 8),
                child: SegmentedButton<AppUIMode>(
                  style: SegmentedButton.styleFrom(
                    visualDensity: VisualDensity.compact,
                    padding: const EdgeInsets.symmetric(horizontal: 6),
                    side: BorderSide(color: theme.colorScheme.outlineVariant),
                    selectedBackgroundColor: theme.colorScheme.primary
                        .withValues(alpha: 0.16),
                    selectedForegroundColor: theme.colorScheme.primary,
                  ),
                  showSelectedIcon: false,
                  segments: const [
                    ButtonSegment<AppUIMode>(
                      value: AppUIMode.chat,
                      label: Text('Chat', style: TextStyle(fontSize: 11.5)),
                      icon: Icon(Icons.chat_outlined, size: 14),
                    ),
                    ButtonSegment<AppUIMode>(
                      value: AppUIMode.ttsStudio,
                      label: Text(
                        'TTS Studio',
                        style: TextStyle(fontSize: 11.5),
                      ),
                      icon: Icon(Icons.record_voice_over_outlined, size: 14),
                    ),
                  ],
                  selected: {state.uiMode},
                  onSelectionChanged: (selection) {
                    _controller.dispatch(SetUIModeIntent(selection.first));
                  },
                ),
              ),
              IconButton(
                icon: const Icon(Icons.delete_sweep_rounded),
                tooltip: 'Clear transcript',
                onPressed: state.turns.isEmpty
                    ? null
                    : () => _controller.dispatch(const ClearTranscriptIntent()),
              ),
              IconButton(
                icon: const Icon(Icons.settings_outlined),
                tooltip: 'Settings & Models',
                onPressed: () => _showSettingsSheet(state),
              ),
            ],
          ),
          body: state.uiMode == AppUIMode.ttsStudio
              ? TtsStudioView(
                  state: state,
                  controller: _controller,
                  onOpenCatalog: () => _pickBundle(state),
                )
              : Column(
                  children: [
                    Expanded(
                      child: MessageList(
                        turns: state.turns,
                        scrollController: _scrollController,
                        audioPlayer: _controller.audioPlayer,
                      ),
                    ),
                    if (state.hasModel &&
                        ((state.capabilities?.audioIn ?? false) ||
                            (state.capabilities?.audioOut ?? false)))
                      Container(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 16,
                          vertical: 6,
                        ),
                        decoration: BoxDecoration(
                          color: theme.colorScheme.surface,
                          border: Border(
                            top: BorderSide(
                              color: theme.colorScheme.outlineVariant,
                            ),
                            bottom: BorderSide(
                              color: theme.colorScheme.outlineVariant,
                            ),
                          ),
                        ),
                        child: Row(
                          children: [
                            Icon(
                              Icons.graphic_eq_rounded,
                              size: 15,
                              color: theme.colorScheme.primary,
                            ),
                            const SizedBox(width: 6),
                            Text(
                              'Voice Mode:',
                              style: TextStyle(
                                fontSize: 11.5,
                                fontWeight: FontWeight.w600,
                                color: theme.colorScheme.onSurfaceVariant,
                              ),
                            ),
                            const SizedBox(width: 8),
                            Expanded(
                              child: SingleChildScrollView(
                                scrollDirection: Axis.horizontal,
                                child: Row(
                                  children: [
                                    _AudioModeChip(
                                      label: 'Speech to Text (ASR)',
                                      icon: Icons.transcribe_rounded,
                                      isSelected:
                                          state.settings.audioChatMode ==
                                          AudioChatMode.speechToText,
                                      onTap: () => _controller.dispatch(
                                        const UpdateSettingsIntent(
                                          audioChatMode:
                                              AudioChatMode.speechToText,
                                        ),
                                      ),
                                    ),
                                    const SizedBox(width: 6),
                                    _AudioModeChip(
                                      label: 'Voice Chat',
                                      icon: Icons.record_voice_over_outlined,
                                      isSelected:
                                          state.settings.audioChatMode ==
                                          AudioChatMode.interleaved,
                                      onTap: () => _controller.dispatch(
                                        const UpdateSettingsIntent(
                                          audioChatMode:
                                              AudioChatMode.interleaved,
                                        ),
                                      ),
                                    ),
                                    const SizedBox(width: 6),
                                    _AudioModeChip(
                                      label: 'Text to Speech (TTS)',
                                      icon: Icons.volume_up_outlined,
                                      isSelected:
                                          state.settings.audioChatMode ==
                                          AudioChatMode.textToSpeech,
                                      onTap: () => _controller.dispatch(
                                        const UpdateSettingsIntent(
                                          audioChatMode:
                                              AudioChatMode.textToSpeech,
                                        ),
                                      ),
                                    ),
                                    const SizedBox(width: 6),
                                    _AudioModeChip(
                                      label: 'Text Only',
                                      icon: Icons.chat_bubble_outline_rounded,
                                      isSelected:
                                          state.settings.audioChatMode ==
                                          AudioChatMode.textOnly,
                                      onTap: () => _controller.dispatch(
                                        const UpdateSettingsIntent(
                                          audioChatMode: AudioChatMode.textOnly,
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                            ),
                          ],
                        ),
                      ),
                    MessageComposer(
                      controller: _inputController,
                      isBusy: state.isBusy,
                      isGenerating: state.isGenerating,
                      canAttachImage: state.canAttachImage,
                      canAttachAudio: state.canAttachAudio,
                      pendingImageBytes: state.pendingImageBytes,
                      pendingImageName: state.pendingImageName,
                      onSend: _sendMessage,
                      onStop: () =>
                          _controller.dispatch(const StopGenerationIntent()),
                      onPickImage: _pickImage,
                      onClearImage: () => _controller.dispatch(
                        const ClearAttachedImageIntent(),
                      ),
                      onSendAudio: (pcm, sampleRate) {
                        final text = _inputController.text.trim();
                        _inputController.clear();
                        _controller.dispatch(
                          SendAudioPromptIntent(
                            pcmSamples: pcm,
                            sampleRate: sampleRate,
                            prompt: text,
                          ),
                        );
                      },
                    ),
                  ],
                ),
        );
      },
    );
  }
}

class _AudioModeChip extends StatelessWidget {
  const _AudioModeChip({
    required this.label,
    required this.icon,
    required this.isSelected,
    required this.onTap,
  });

  final String label;
  final IconData icon;
  final bool isSelected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(6),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        decoration: BoxDecoration(
          color: isSelected
              ? theme.colorScheme.primary.withValues(alpha: 0.16)
              : Colors.transparent,
          borderRadius: BorderRadius.circular(6),
          border: Border.all(
            color: isSelected
                ? theme.colorScheme.primary.withValues(alpha: 0.4)
                : theme.colorScheme.outlineVariant,
          ),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              icon,
              size: 13,
              color: isSelected
                  ? theme.colorScheme.primary
                  : theme.colorScheme.onSurfaceVariant,
            ),
            const SizedBox(width: 5),
            Text(
              label,
              style: TextStyle(
                fontSize: 11,
                fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
                color: isSelected
                    ? theme.colorScheme.primary
                    : theme.colorScheme.onSurfaceVariant,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
160
points
132
downloads

Documentation

Documentation
API reference

Publisher

verified publisherhyeons-lab.com

Weekly Downloads

Flutter FFI plugin for the Cera on-device inference engine. Ships prebuilt native libraries for Android, iOS, macOS, Linux and Windows.

Repository (GitHub)
View/report issues

Topics

#llm #inference #ffi #gguf #ai

License

Apache-2.0, MIT (license)

Dependencies

cera_ffi, flutter, flutter_web_plugins

More

Packages that depend on cera_ffi_flutter

Packages that implement cera_ffi_flutter