Skip to main content

cera_ffi_flutter 0.7.1 copy "cera_ffi_flutter: ^0.7.1" to clipboard
cera_ffi_flutter: ^0.7.1 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:shared_preferences/shared_preferences.dart';

import 'package:cera_ffi_flutter/cera_ffi_flutter.dart' hide ModelSource;

import 'app_constants.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/voice_persona_picker.dart';

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

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

  @override
  State<CeraExampleApp> createState() => _CeraExampleAppState();
}

class _CeraExampleAppState extends State<CeraExampleApp> {
  // Light palette (Warm Alabaster & Zinc)
  static const _kLightCanvas = Color(0xFFFAFAFA);
  static const _kLightSurface = Color(0xFFFFFFFF);
  static const _kLightContainerLow = Color(0xFFF2F3F5);
  static const _kLightContainerHigh = Color(0xFFE8EAEE);
  static const _kLightTextPrimary = Color(0xFF141417);
  static const _kLightTextSecondary = Color(0xFF646872);
  static const _kLightPrimary = Color(0xFF18181B);
  static const _kLightOutline = Color(0xFFD4D4D8);
  static const _kLightOutlineVariant = Color(0xFFE4E4E7);

  // Dark palette (Clean Charcoal & Zinc)
  static const _kDarkCanvas = Color(0xFF121215);
  static const _kDarkSurface = Color(0xFF18181C);
  static const _kDarkContainerLow = Color(0xFF1D1D22);
  static const _kDarkContainerHigh = Color(0xFF27272E);
  static const _kDarkTextPrimary = Color(0xFFF4F4F5);
  static const _kDarkTextSecondary = Color(0xFF9CA3AF);
  static const _kDarkPrimary = Color(0xFFF4F4F5);
  static const _kDarkOutline = Color(0xFF3F3F46);
  static const _kDarkOutlineVariant = Color(0xFF27272A);

  ThemeMode _themeMode = ThemeMode.dark;

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

  Future<void> _loadSavedThemeMode() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final saved = prefs.getString(PrefKeys.themeMode);
      if (!mounted || saved == null) return;
      setState(() {
        if (saved == PrefValues.themeLight) {
          _themeMode = ThemeMode.light;
        } else if (saved == PrefValues.themeDark) {
          _themeMode = ThemeMode.dark;
        } else if (saved == PrefValues.themeSystem) {
          _themeMode = ThemeMode.system;
        }
      });
    } catch (_) {}
  }

  Future<void> _toggleThemeMode(Brightness currentBrightness) async {
    final nextMode = currentBrightness == Brightness.dark
        ? ThemeMode.light
        : ThemeMode.dark;
    setState(() => _themeMode = nextMode);
    try {
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString(
        PrefKeys.themeMode,
        nextMode == ThemeMode.light
            ? PrefValues.themeLight
            : PrefValues.themeDark,
      );
    } catch (_) {}
  }

  static ThemeData _buildLightTheme() {
    return ThemeData(
      useMaterial3: true,
      brightness: Brightness.light,
      scaffoldBackgroundColor: _kLightCanvas,
      canvasColor: _kLightCanvas,
      cardColor: _kLightSurface,
      dividerColor: _kLightOutlineVariant,
      colorScheme: const ColorScheme.light(
        primary: _kLightPrimary,
        onPrimary: _kLightSurface,
        primaryContainer: _kLightContainerHigh,
        onPrimaryContainer: _kLightTextPrimary,
        surface: _kLightSurface,
        surfaceContainerLow: _kLightContainerLow,
        surfaceContainerHigh: _kLightContainerHigh,
        onSurface: _kLightTextPrimary,
        onSurfaceVariant: _kLightTextSecondary,
        outline: _kLightOutline,
        outlineVariant: _kLightOutlineVariant,
      ),
      appBarTheme: const AppBarTheme(
        backgroundColor: _kLightCanvas,
        surfaceTintColor: Colors.transparent,
        elevation: 0,
        scrolledUnderElevation: 0,
      ),
    );
  }

  static ThemeData _buildDarkTheme() {
    return ThemeData(
      useMaterial3: true,
      brightness: Brightness.dark,
      scaffoldBackgroundColor: _kDarkCanvas,
      canvasColor: _kDarkCanvas,
      cardColor: _kDarkSurface,
      dividerColor: _kDarkOutlineVariant,
      colorScheme: const ColorScheme.dark(
        primary: _kDarkPrimary,
        onPrimary: _kDarkCanvas,
        primaryContainer: _kDarkContainerHigh,
        onPrimaryContainer: _kDarkTextPrimary,
        surface: _kDarkSurface,
        surfaceContainerLow: _kDarkContainerLow,
        surfaceContainerHigh: _kDarkContainerHigh,
        onSurface: _kDarkTextPrimary,
        onSurfaceVariant: _kDarkTextSecondary,
        outline: _kDarkOutline,
        outlineVariant: _kDarkOutlineVariant,
      ),
      appBarTheme: const AppBarTheme(
        backgroundColor: _kDarkCanvas,
        surfaceTintColor: Colors.transparent,
        elevation: 0,
        scrolledUnderElevation: 0,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: AppStrings.appTitle,
      debugShowCheckedModeBanner: false,
      themeMode: _themeMode,
      theme: _buildLightTheme(),
      darkTheme: _buildDarkTheme(),
      home: ChatPage(onToggleTheme: _toggleThemeMode),
    );
  }
}

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

  final ValueChanged<Brightness>? onToggleTheme;

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

class _ChatPageState extends State<ChatPage> {
  static const _kPickModelDialogTitle = 'Choose a .gguf model';
  static const _kPickImageDialogTitle = 'Select an image to attach';
  static const _kUnreadableImageBytesError = 'Could not read image file bytes';

  // Top bar labels & tooltips
  static const _kSwitchModelTooltip = 'Select or download model';
  static const _kSelectModelLabel = 'Select Model';
  static const _kClearTranscriptTooltip = 'New chat';
  static const _kToggleThemeTooltip = 'Toggle light or dark theme';
  static const _kSettingsTooltip = 'Tune & settings';
  static const _kStatusEmerald = Color(0xFF10B981);
  static const _kStatusAmber = Color(0xFFF59E0B);

  // Settings sheet section headers & copy
  static const _kSettingsTitle = 'Tune & Settings';
  static const _kSectionModels = 'MODEL';
  static const _kCatalogModelsTitle = 'Browse Model Catalog';
  static const _kCatalogModelsSubtitle =
      'Switch between cached models or download new ones';
  static const _kOpenLocalTitle = 'Open Local .gguf File';
  static const _kOpenLocalSubtitle = 'Pick a model file from disk storage';
  static const _kUnloadModelTitle = 'Unload Active Model';
  static const _kUnloadModelSubtitle = 'Release memory and close model engine';

  static const _kSectionSampling = 'SAMPLING & GENERATION';
  static const _kSamplingTemperatureTitle = 'Temperature';
  static const _kSamplingAutoHint =
      'Auto uses model defaults (0.0 greedy argmax on WebGPU text, 0.7 on audio).';
  static const _kSamplingGreedyHint =
      'Greedy (0.0): deterministic argmax decoding with GPU fast path.';
  static const _kSamplingStochasticHint =
      'Stochastic (> 0.0): nucleus / top-k sampling for varied responses.';
  static const _kPresetGreedyLabel = 'Greedy (0.0)';
  static const _kMaxOutputTokensTitle = 'Max Output Tokens';
  static const _kMaxOutputTokensSubtitle =
      'Maximum tokens generated per assistant response';

  static const _kSectionInference = 'EXECUTION & MULTIMODAL';
  static const _kComputeBackendTitle = 'Compute Backend';
  static const _kComputeBackendSubtitle =
      'Auto, GPU (WebGPU / Metal), NPU (Hexagon), or CPU';
  static const _kBackendAutoLabel = 'Auto';
  static const _kBackendGpuLabel = 'GPU (WebGPU / Metal)';
  static const _kBackendHexagonLabel = 'NPU (Hexagon)';
  static const _kBackendCpuLabel = 'CPU';
  static const _kTurboQuantTitle = 'TurboQuant KV Compression';
  static const _kTurboQuantSubtitle =
      '3-bit keys / 2-bit values for lower memory footprint';
  static const _kVisionMaxDimTitle = 'Vision Image Resolution';
  static const _kVisionMaxDimSubtitle =
      'Max long-side dimension before ViT patch encoding';
  static const _kImageDimFastLabel = '256 px (Fast)';
  static const _kImageDimHdLabel = '512 px (HD)';
  static const _kImageDimNativeLabel = 'Native';

  static const _kSectionVoice = 'VOICE & SPEECH';
  static const _kVoiceModeTitle = 'Audio Mode';
  static const _kVoiceModeSubtitle =
      'How audio models handle speech input and output';
  static const _kVoiceModeAsrLabel = 'Speech to Text (ASR)';
  static const _kVoiceModeInterleavedLabel = 'Voice Chat';
  static const _kVoiceModeTtsLabel = 'Text to Speech (TTS)';
  static const _kVoiceModeTextOnlyMenuLabel = 'Text Only';
  static const _kVoicePersonaTitle = 'Voice Persona';
  static const _kVoicePersonaSingleVoiceSubtitle =
      'This audio model uses a single built-in speaker voice (switch to LFM2.5-Audio-1.5B for US/UK speaker personas)';
  static const _kVoicePersonaSingleVoiceBadge = 'Default Voice';
  static const _kVoicePersonaUnavailableSubtitle =
      'Load an audio generation model (such as LFM2.5-Audio-1.5B) to select a speaker voice';
  static const _kVoicePersonaUnavailableBadge = 'No Audio Model';
  static const _kActiveRuntimePrefix = 'Active backend: ';

  static const _kModelPlaceholderToken = '{model}';

  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);
    _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;
      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();
  }

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

  Future<void> _pickBundle(ChatState state) async {
    final loaded = state.loadedModel;
    final bundleModel = loaded is BundleModelSource ? loaded : null;

    final choice = await showDialog<BundleChoice>(
      context: context,
      builder: (_) => BundlePickerDialog(
        currentBundleName: bundleModel?.bundleName,
        currentQuant: bundleModel?.quant,
        onOpenLocalModel: _pickLocalModel,
      ),
    );

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

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

  Future<void> _pickLocalModel() async {
    try {
      final source = await pickModelSource(dialogTitle: _kPickModelDialogTitle);
      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')),
        );
      }
    }
  }

  Future<void> _pickImage() async {
    try {
      final result = await FilePicker.platform.pickFiles(
        type: FileType.image,
        withData: true,
        dialogTitle: _kPickImageDialogTitle,
      );
      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(_kUnreadableImageBytesError)),
          );
        }
        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 _onSelectStarterPrompt(String prompt) {
    _inputController.text = prompt;
    _sendMessage();
  }

  static String _samplingHintFor(double? temp) {
    if (temp == null) return _kSamplingAutoHint;
    if (temp <= 0.0) return _kSamplingGreedyHint;
    return _kSamplingStochasticHint;
  }

  List<String> _resolvedTtsSamples(ChatState state) {
    final rawModelName = state.loadedModel?.name ?? AppStrings.appTitle;
    final cleanModelName = rawModelName
        .split(AppStrings.metaSeparator)
        .first
        .trim();
    final profileSamples = state.audioProfile?.sampleTexts ?? const <String>[];
    if (profileSamples.isEmpty) return const <String>[];
    return [
      for (final s in profileSamples)
        s.replaceAll(_kModelPlaceholderToken, cleanModelName),
    ];
  }

  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(24)),
      ),
      builder: (context) {
        return ListenableBuilder(
          listenable: _controller,
          builder: (context, _) {
            final currentState = _controller.value;
            final currentSettings = currentState.settings;
            final activeTemp = currentSettings.temperature;
            final sliderValue = (activeTemp ?? ChatDefaults.greedyTemperature)
                .clamp(
                  ChatDefaults.greedyTemperature,
                  ChatDefaults.maxSliderTemperature,
                );

            return SafeArea(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
                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.tune_rounded,
                            size: 20,
                            color: theme.colorScheme.onSurface,
                          ),
                          const SizedBox(width: 8),
                          Text(
                            _kSettingsTitle,
                            style: TextStyle(
                              fontSize: 17,
                              fontWeight: FontWeight.w600,
                              color: theme.colorScheme.onSurface,
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 18),
                      _SectionLabel(label: _kSectionModels, theme: theme),
                      const SizedBox(height: 8),
                      _TonalCard(
                        theme: theme,
                        child: Column(
                          children: [
                            if (currentState.hasModel)
                              ListTile(
                                leading: const Icon(
                                  Icons.memory_rounded,
                                  color: _kStatusEmerald,
                                ),
                                title: Text(
                                  currentState.loadedModel?.name ??
                                      AppStrings.unknownModel,
                                  maxLines: 1,
                                  overflow: TextOverflow.ellipsis,
                                  style: const TextStyle(
                                    fontWeight: FontWeight.w600,
                                  ),
                                ),
                                subtitle: Text(
                                  '$_kActiveRuntimePrefix${currentState.backend ?? AppStrings.unknownBackend}',
                                ),
                                trailing: Container(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 9,
                                    vertical: 4,
                                  ),
                                  decoration: BoxDecoration(
                                    color:
                                        theme.colorScheme.surfaceContainerHigh,
                                    borderRadius: BorderRadius.circular(12),
                                  ),
                                  child: Text(
                                    currentState.backend ??
                                        AppStrings.unknownBackend,
                                    style: TextStyle(
                                      fontSize: 11.5,
                                      fontWeight: FontWeight.w600,
                                      color: theme.colorScheme.onSurface,
                                    ),
                                  ),
                                ),
                              ),
                            ListTile(
                              leading: Icon(
                                Icons.cloud_download_outlined,
                                color: theme.colorScheme.onSurface,
                              ),
                              title: const Text(_kCatalogModelsTitle),
                              subtitle: const Text(_kCatalogModelsSubtitle),
                              onTap: () {
                                Navigator.of(context).pop();
                                _pickBundle(_controller.value);
                              },
                            ),
                            ListTile(
                              leading: Icon(
                                Icons.folder_open_outlined,
                                color: theme.colorScheme.onSurface,
                              ),
                              title: const Text(_kOpenLocalTitle),
                              subtitle: const Text(_kOpenLocalSubtitle),
                              onTap: () {
                                Navigator.of(context).pop();
                                _pickLocalModel();
                              },
                            ),
                            if (currentState.hasModel)
                              ListTile(
                                leading: Icon(
                                  Icons.eject_outlined,
                                  color: theme.colorScheme.error,
                                ),
                                title: Text(
                                  _kUnloadModelTitle,
                                  style: TextStyle(
                                    color: theme.colorScheme.error,
                                  ),
                                ),
                                subtitle: const Text(_kUnloadModelSubtitle),
                                onTap: () {
                                  Navigator.of(context).pop();
                                  _controller.dispatch(
                                    const UnloadModelIntent(),
                                  );
                                },
                              ),
                          ],
                        ),
                      ),
                      const SizedBox(height: 18),
                      _SectionLabel(label: _kSectionSampling, theme: theme),
                      const SizedBox(height: 8),
                      _TonalCard(
                        theme: theme,
                        padding: const EdgeInsets.all(16),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Row(
                              mainAxisAlignment: MainAxisAlignment.spaceBetween,
                              children: [
                                Flexible(
                                  child: Text(
                                    _kSamplingTemperatureTitle,
                                    overflow: TextOverflow.ellipsis,
                                    style: TextStyle(
                                      fontSize: 14.5,
                                      fontWeight: FontWeight.w500,
                                      color: theme.colorScheme.onSurface,
                                    ),
                                  ),
                                ),
                                const SizedBox(width: 8),
                                Container(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 9,
                                    vertical: 3,
                                  ),
                                  decoration: BoxDecoration(
                                    color:
                                        theme.colorScheme.surfaceContainerHigh,
                                    borderRadius: BorderRadius.circular(10),
                                  ),
                                  child: Text(
                                    currentSettings.samplingLabel,
                                    style: TextStyle(
                                      fontSize: 12,
                                      fontWeight: FontWeight.w600,
                                      color: theme.colorScheme.onSurface,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                            const SizedBox(height: 4),
                            Text(
                              _samplingHintFor(activeTemp),
                              style: TextStyle(
                                fontSize: 12,
                                color: theme.colorScheme.onSurfaceVariant,
                              ),
                            ),
                            const SizedBox(height: 10),
                            Wrap(
                              spacing: 8,
                              runSpacing: 8,
                              children: [
                                ChoiceChip(
                                  label: const Text(
                                    AppStrings.samplingAuto,
                                    style: TextStyle(fontSize: 12),
                                  ),
                                  selected: activeTemp == null,
                                  onSelected: (_) => _controller.dispatch(
                                    const UpdateSettingsIntent(
                                      clearTemperature: true,
                                    ),
                                  ),
                                ),
                                ChoiceChip(
                                  label: const Text(
                                    _kPresetGreedyLabel,
                                    style: TextStyle(fontSize: 12),
                                  ),
                                  selected:
                                      activeTemp != null &&
                                      (activeTemp -
                                                  ChatDefaults
                                                      .greedyTemperature)
                                              .abs() <
                                          0.01,
                                  onSelected: (_) => _controller.dispatch(
                                    const UpdateSettingsIntent(
                                      temperature:
                                          ChatDefaults.greedyTemperature,
                                    ),
                                  ),
                                ),
                                ChoiceChip(
                                  label: const Text(
                                    AppStrings.samplingBalanced,
                                    style: TextStyle(fontSize: 12),
                                  ),
                                  selected:
                                      activeTemp != null &&
                                      (activeTemp -
                                                  ChatDefaults
                                                      .balancedTemperature)
                                              .abs() <
                                          0.01,
                                  onSelected: (_) => _controller.dispatch(
                                    const UpdateSettingsIntent(
                                      temperature:
                                          ChatDefaults.balancedTemperature,
                                    ),
                                  ),
                                ),
                                ChoiceChip(
                                  label: const Text(
                                    AppStrings.samplingCreative,
                                    style: TextStyle(fontSize: 12),
                                  ),
                                  selected:
                                      activeTemp != null &&
                                      (activeTemp -
                                                  ChatDefaults
                                                      .creativeTemperature)
                                              .abs() <
                                          0.01,
                                  onSelected: (_) => _controller.dispatch(
                                    const UpdateSettingsIntent(
                                      temperature:
                                          ChatDefaults.creativeTemperature,
                                    ),
                                  ),
                                ),
                              ],
                            ),
                            Slider(
                              value: sliderValue,
                              min: ChatDefaults.greedyTemperature,
                              max: ChatDefaults.maxSliderTemperature,
                              divisions: 30,
                              label: sliderValue.toStringAsFixed(2),
                              onChanged: (val) {
                                final rounded = (val * 20).round() / 20.0;
                                _controller.dispatch(
                                  UpdateSettingsIntent(temperature: rounded),
                                );
                              },
                            ),
                            ListTile(
                              contentPadding: EdgeInsets.zero,
                              title: const Text(_kMaxOutputTokensTitle),
                              subtitle: Text(
                                _kMaxOutputTokensSubtitle,
                                style: TextStyle(
                                  fontSize: 12,
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                              ),
                              trailing: DropdownButton<int>(
                                value:
                                    ChatDefaults.maxTokenChoices.contains(
                                      currentSettings.maxTokens,
                                    )
                                    ? currentSettings.maxTokens
                                    : ChatDefaults.maxTokens,
                                dropdownColor: theme.colorScheme.surface,
                                underline: const SizedBox.shrink(),
                                items: [
                                  for (final count
                                      in ChatDefaults.maxTokenChoices)
                                    DropdownMenuItem(
                                      value: count,
                                      child: Text('$count tokens'),
                                    ),
                                ],
                                onChanged: (val) {
                                  if (val == null) return;
                                  _controller.dispatch(
                                    UpdateSettingsIntent(maxTokens: val),
                                  );
                                },
                              ),
                            ),
                          ],
                        ),
                      ),
                      const SizedBox(height: 18),
                      _SectionLabel(label: _kSectionVoice, theme: theme),
                      const SizedBox(height: 8),
                      _TonalCard(
                        theme: theme,
                        padding: const EdgeInsets.symmetric(
                          horizontal: 16,
                          vertical: 8,
                        ),
                        child: Column(
                          children: [
                            ListTile(
                              contentPadding: EdgeInsets.zero,
                              title: const Text(_kVoiceModeTitle),
                              subtitle: Text(
                                _kVoiceModeSubtitle,
                                style: TextStyle(
                                  fontSize: 12,
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                              ),
                              trailing: ConstrainedBox(
                                constraints: const BoxConstraints(
                                  maxWidth: 150,
                                ),
                                child: DropdownButton<AudioChatMode>(
                                  value: currentSettings.audioChatMode,
                                  isExpanded: true,
                                  dropdownColor: theme.colorScheme.surface,
                                  underline: const SizedBox.shrink(),
                                  items: const [
                                    DropdownMenuItem(
                                      value: AudioChatMode.speechToText,
                                      child: Text(
                                        _kVoiceModeAsrLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: AudioChatMode.interleaved,
                                      child: Text(
                                        _kVoiceModeInterleavedLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: AudioChatMode.textToSpeech,
                                      child: Text(
                                        _kVoiceModeTtsLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: AudioChatMode.textOnly,
                                      child: Text(
                                        _kVoiceModeTextOnlyMenuLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                  ],
                                  onChanged: (val) {
                                    if (val == null) return;
                                    _controller.dispatch(
                                      UpdateSettingsIntent(audioChatMode: val),
                                    );
                                  },
                                ),
                              ),
                            ),
                            if (currentState.audioProfile?.hasVoices ?? false)
                              VoicePersonaPicker(
                                profile: currentState.audioProfile,
                                saved: currentSettings.chatVoice,
                                onChanged: (val) {
                                  final hasAudioOut =
                                      currentState.capabilities?.audioOut ??
                                      false;
                                  final hasAudioIn =
                                      currentState.capabilities?.audioIn ??
                                      false;
                                  final shouldEnableSpeech =
                                      hasAudioOut &&
                                      currentSettings.audioChatMode !=
                                          AudioChatMode.interleaved &&
                                      currentSettings.audioChatMode !=
                                          AudioChatMode.textToSpeech;
                                  _controller.dispatch(
                                    UpdateSettingsIntent(
                                      chatVoice: val,
                                      clearChatVoice: val == null,
                                      audioChatMode: shouldEnableSpeech
                                          ? (hasAudioIn
                                                ? AudioChatMode.interleaved
                                                : AudioChatMode.textToSpeech)
                                          : null,
                                    ),
                                  );
                                },
                              )
                            else
                              ListTile(
                                contentPadding: EdgeInsets.zero,
                                title: const Text(_kVoicePersonaTitle),
                                subtitle: Text(
                                  (currentState.capabilities?.audioOut ??
                                              false) ||
                                          currentState.audioProfile != null
                                      ? _kVoicePersonaSingleVoiceSubtitle
                                      : _kVoicePersonaUnavailableSubtitle,
                                  style: TextStyle(
                                    fontSize: 12,
                                    color: theme.colorScheme.onSurfaceVariant,
                                  ),
                                ),
                                trailing: Container(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 9,
                                    vertical: 4,
                                  ),
                                  decoration: BoxDecoration(
                                    color:
                                        theme.colorScheme.surfaceContainerHigh,
                                    borderRadius: BorderRadius.circular(10),
                                  ),
                                  child: Text(
                                    (currentState.capabilities?.audioOut ??
                                                false) ||
                                            currentState.audioProfile != null
                                        ? _kVoicePersonaSingleVoiceBadge
                                        : _kVoicePersonaUnavailableBadge,
                                    style: TextStyle(
                                      fontSize: 11,
                                      fontWeight: FontWeight.w500,
                                      color: theme.colorScheme.onSurfaceVariant,
                                    ),
                                  ),
                                ),
                              ),
                          ],
                        ),
                      ),
                      const SizedBox(height: 18),
                      _SectionLabel(label: _kSectionInference, theme: theme),
                      const SizedBox(height: 8),
                      _TonalCard(
                        theme: theme,
                        padding: const EdgeInsets.symmetric(
                          horizontal: 16,
                          vertical: 8,
                        ),
                        child: Column(
                          children: [
                            ListTile(
                              contentPadding: EdgeInsets.zero,
                              title: const Text(_kComputeBackendTitle),
                              subtitle: Text(
                                currentState.backend != null &&
                                        currentState.backend!.isNotEmpty
                                    ? '$_kActiveRuntimePrefix${currentState.backend}${AppStrings.metaSeparator}$_kComputeBackendSubtitle'
                                    : _kComputeBackendSubtitle,
                                style: TextStyle(
                                  fontSize: 12,
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                              ),
                              trailing: ConstrainedBox(
                                constraints: const BoxConstraints(
                                  maxWidth: 150,
                                ),
                                child: DropdownButton<CeraBackend>(
                                  value: currentSettings.backend,
                                  isExpanded: true,
                                  dropdownColor: theme.colorScheme.surface,
                                  underline: const SizedBox.shrink(),
                                  items: const [
                                    DropdownMenuItem(
                                      value: CeraBackend.auto,
                                      child: Text(
                                        _kBackendAutoLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: CeraBackend.gpu,
                                      child: Text(
                                        _kBackendGpuLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: CeraBackend.hexagon,
                                      child: Text(
                                        _kBackendHexagonLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: CeraBackend.cpu,
                                      child: Text(
                                        _kBackendCpuLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                  ],
                                  onChanged: (val) {
                                    if (val == null) return;
                                    _controller.dispatch(
                                      UpdateSettingsIntent(backend: val),
                                    );
                                  },
                                ),
                              ),
                            ),
                            SwitchListTile(
                              contentPadding: EdgeInsets.zero,
                              title: const Text(_kTurboQuantTitle),
                              subtitle: Text(
                                _kTurboQuantSubtitle,
                                style: TextStyle(
                                  fontSize: 12,
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                              ),
                              value: currentSettings.turboQuant,
                              onChanged: (val) {
                                _controller.dispatch(
                                  UpdateSettingsIntent(turboQuant: val),
                                );
                              },
                            ),
                            ListTile(
                              contentPadding: EdgeInsets.zero,
                              title: const Text(_kVisionMaxDimTitle),
                              subtitle: Text(
                                _kVisionMaxDimSubtitle,
                                style: TextStyle(
                                  fontSize: 12,
                                  color: theme.colorScheme.onSurfaceVariant,
                                ),
                              ),
                              trailing: ConstrainedBox(
                                constraints: const BoxConstraints(
                                  maxWidth: 135,
                                ),
                                child: DropdownButton<int?>(
                                  value: currentSettings.maxImageLongSize,
                                  isExpanded: true,
                                  dropdownColor: theme.colorScheme.surface,
                                  underline: const SizedBox.shrink(),
                                  items: const [
                                    DropdownMenuItem(
                                      value: ChatDefaults.maxImageLongSize,
                                      child: Text(
                                        _kImageDimFastLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: ChatDefaults.hdImageLongSize,
                                      child: Text(
                                        _kImageDimHdLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                    DropdownMenuItem(
                                      value: null,
                                      child: Text(
                                        _kImageDimNativeLabel,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                    ),
                                  ],
                                  onChanged: (val) {
                                    _controller.dispatch(
                                      UpdateSettingsIntent(
                                        maxImageLongSize: val,
                                        clearMaxImageLongSize: val == null,
                                      ),
                                    );
                                  },
                                ),
                              ),
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            );
          },
        );
      },
    );
  }

  PreferredSizeWidget _buildMinimalistAppBar(
    BuildContext context,
    ThemeData theme,
    ChatState state,
    String? loadedModelName,
  ) {
    final isDark = theme.brightness == Brightness.dark;
    final activeBackend = state.backend;
    final statusColor = state.isLoading
        ? _kStatusAmber
        : (state.hasModel
              ? _kStatusEmerald
              : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.45));
    final tooltipText = activeBackend != null && activeBackend.isNotEmpty
        ? '$_kActiveRuntimePrefix$activeBackend${AppStrings.metaSeparator}$_kSwitchModelTooltip'
        : _kSwitchModelTooltip;

    return AppBar(
      automaticallyImplyLeading: false,
      toolbarHeight: 56,
      titleSpacing: 8,
      centerTitle: true,
      leadingWidth: 52,
      leading: Padding(
        padding: const EdgeInsets.only(left: 8),
        child: IconButton(
          icon: Icon(
            Icons.delete_sweep_rounded,
            size: 20,
            color: state.turns.isEmpty
                ? theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.35)
                : theme.colorScheme.onSurfaceVariant,
          ),
          tooltip: _kClearTranscriptTooltip,
          onPressed: state.turns.isEmpty
              ? null
              : () => _controller.dispatch(const ClearTranscriptIntent()),
        ),
      ),
      title: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 340),
        child: Tooltip(
          message: tooltipText,
          child: Material(
            color: theme.colorScheme.surfaceContainerLow,
            borderRadius: BorderRadius.circular(20),
            child: InkWell(
              onTap: () => _pickBundle(state),
              borderRadius: BorderRadius.circular(20),
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  horizontal: 12,
                  vertical: 7,
                ),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Container(
                      width: 7,
                      height: 7,
                      decoration: BoxDecoration(
                        color: statusColor,
                        shape: BoxShape.circle,
                      ),
                    ),
                    const SizedBox(width: 8),
                    Flexible(
                      child: Text(
                        loadedModelName ?? _kSelectModelLabel,
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(
                          fontSize: 13,
                          fontWeight: FontWeight.w600,
                          color: theme.colorScheme.onSurface,
                        ),
                      ),
                    ),
                    if (activeBackend != null && activeBackend.isNotEmpty) ...[
                      const SizedBox(width: 6),
                      ConstrainedBox(
                        constraints: const BoxConstraints(maxWidth: 88),
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 6,
                            vertical: 2,
                          ),
                          decoration: BoxDecoration(
                            color: theme.colorScheme.surfaceContainerHigh,
                            borderRadius: BorderRadius.circular(10),
                          ),
                          child: Text(
                            activeBackend,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                            style: TextStyle(
                              fontSize: 10.5,
                              fontWeight: FontWeight.w600,
                              color: theme.colorScheme.onSurfaceVariant,
                            ),
                          ),
                        ),
                      ),
                    ],
                    const SizedBox(width: 4),
                    Icon(
                      Icons.keyboard_arrow_down_rounded,
                      size: 16,
                      color: theme.colorScheme.onSurfaceVariant,
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
      actions: [
        if (widget.onToggleTheme != null)
          IconButton(
            icon: Icon(
              isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
              size: 19,
              color: theme.colorScheme.onSurfaceVariant,
            ),
            tooltip: _kToggleThemeTooltip,
            visualDensity: VisualDensity.compact,
            onPressed: () => widget.onToggleTheme?.call(theme.brightness),
          ),
        Padding(
          padding: const EdgeInsets.only(right: 8),
          child: IconButton(
            icon: Icon(
              Icons.settings_outlined,
              size: 19,
              color: theme.colorScheme.onSurfaceVariant,
            ),
            tooltip: _kSettingsTooltip,
            visualDensity: VisualDensity.compact,
            onPressed: () => _showSettingsSheet(state),
          ),
        ),
      ],
      bottom: state.isLoading
          ? PreferredSize(
              preferredSize: const Size.fromHeight(2),
              child: LinearProgressIndicator(
                value: state.downloadFraction,
                minHeight: 2,
                backgroundColor: theme.colorScheme.surfaceContainerLow,
                valueColor: AlwaysStoppedAnimation<Color>(
                  theme.colorScheme.primary,
                ),
              ),
            )
          : null,
    );
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return ValueListenableBuilder<ChatState>(
      valueListenable: _controller,
      builder: (context, state, _) {
        final hasAudioIn = state.capabilities?.audioIn ?? false;
        final hasAudioOut = state.capabilities?.audioOut ?? false;
        final loadedModelName = state.loadedModel?.name;
        final isTtsMode =
            hasAudioOut &&
            state.settings.audioChatMode == AudioChatMode.textToSpeech;
        final ttsSamples = _resolvedTtsSamples(state);

        void onSelectVoice(String? voice) {
          final shouldEnableSpeech =
              hasAudioOut &&
              state.settings.audioChatMode != AudioChatMode.interleaved &&
              state.settings.audioChatMode != AudioChatMode.textToSpeech;
          _controller.dispatch(
            UpdateSettingsIntent(
              chatVoice: voice,
              clearChatVoice: voice == null,
              audioChatMode: shouldEnableSpeech
                  ? (hasAudioIn
                        ? AudioChatMode.interleaved
                        : AudioChatMode.textToSpeech)
                  : null,
            ),
          );
        }

        return Scaffold(
          appBar: _buildMinimalistAppBar(
            context,
            theme,
            state,
            loadedModelName,
          ),
          body: Column(
            children: [
              Expanded(
                child: MessageList(
                  turns: state.turns,
                  scrollController: _scrollController,
                  audioPlayer: _controller.audioPlayer,
                  hasModel: state.hasModel,
                  isLoading: state.isLoading,
                  statusText: state.status,
                  activeBackend: state.backend,
                  isTtsMode: isTtsMode,
                  ttsSampleTexts: ttsSamples,
                  audioProfile: state.audioProfile,
                  selectedVoicePrompt: state.settings.chatVoice,
                  onVoiceChanged: onSelectVoice,
                  onOpenCatalog: () => _pickBundle(state),
                  onOpenLocalModel: _pickLocalModel,
                  onSelectPrompt: _onSelectStarterPrompt,
                ),
              ),
              MessageComposer(
                controller: _inputController,
                isBusy: state.isBusy,
                isGenerating: state.isGenerating,
                canAttachImage: state.canAttachImage,
                canAttachAudio: state.canAttachAudio,
                pendingImageBytes: state.pendingImageBytes,
                pendingImageName: state.pendingImageName,
                hasAudioIn: hasAudioIn,
                hasAudioOut: hasAudioOut,
                audioChatMode: state.settings.audioChatMode,
                onAudioChatModeChanged: (mode) => _controller.dispatch(
                  UpdateSettingsIntent(audioChatMode: mode),
                ),
                audioProfile: state.audioProfile,
                selectedVoicePrompt: state.settings.chatVoice,
                onVoiceChanged: onSelectVoice,
                ttsSampleTexts: ttsSamples,
                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 _SectionLabel extends StatelessWidget {
  const _SectionLabel({required this.label, required this.theme});

  final String label;
  final ThemeData theme;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(left: 4),
      child: Text(
        label,
        style: TextStyle(
          fontSize: 11,
          fontWeight: FontWeight.w600,
          letterSpacing: 0.8,
          color: theme.colorScheme.onSurfaceVariant,
        ),
      ),
    );
  }
}

class _TonalCard extends StatelessWidget {
  const _TonalCard({required this.theme, required this.child, this.padding});

  final ThemeData theme;
  final Widget child;
  final EdgeInsetsGeometry? padding;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: padding,
      decoration: BoxDecoration(
        color: theme.colorScheme.surfaceContainerLow,
        borderRadius: BorderRadius.circular(18),
      ),
      child: child,
    );
  }
}
0
likes
160
points
636
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