local_inference_flutter 0.1.0
local_inference_flutter: ^0.1.0 copied to clipboard
Flutter layer of local_inference: a customizable chat UI, model setup, background model download and a device plugin for on-device LLM chat on iOS, Android and macOS.
local_inference_flutter #
The Flutter layer of local_inference: a chat UI you customize, the
model-setup UI, background model download, Listenable adapters and a device-probe plugin (iOS, Android, macOS). It
re-exports local_inference, so an app depends on this package alone. The package's example/ folder holds a runnable
chat app with two tools.
Install #
flutter pub add local_inference_flutter
Then put llamadart's hooks block in the app's pubspec, and run flutter clean:
# llamadart's build hook reads this block from the app's pubspec only (a workspace's root pubspec).
hooks:
user_defines:
llamadart:
llamadart_native_runtimes: [llama_cpp] # without it the app also bundles LiteRT-LM (+35 MB Android, +43 MB iOS)
- Optional:
llamadart_native_pathin the same block, for vendored and checked native bundles (local_inference's README, "Vendored natives"). - Then call
await LocalInferenceFlutter.installDownloaderStorage()first inmain(), set the host settings of "Host setup" below, and rundart run local_inference_flutter:doctor --strictin the app. When the app's model downloads use aBackgroundDownloadConfigof their own (createModelManager(download:),ModelOrigin.manifest(url, download:)), pass the same config:installDownloaderStorage(download: config).LocalInferenceChatwith nodownload:uses the default config, which the call without arguments matches.
Choose your layer #
| Tier | What you get | Start with | Section |
|---|---|---|---|
| 1. One widget | the model setup, the assistant and the chat, made, started and disposed by one widget | LocalInferenceChat(config:, model:) |
One widget |
| 2. Pages over your controllers | ChatPage (a Scaffold) or ChatView (a tab, a panel) over controllers kept above the Navigator; the model list and picker |
AssistantController, ModelSetupController, LocalInferenceScope |
Pages over your own controllers |
| 3. Headless | Listenables for a UI of your own; the core Assistant.states stream for Riverpod or BLoC |
AssistantController |
Headless |
| Model download alone | the platform's model manager, background download, the device probe | LocalInferenceFlutter.createModelManager |
Model download |
| Model layer only | ChatModel (L2) or the engine (L1) in an app, with the device's load options and a prefix store in the caches folder |
final (load, store) = await LocalInferenceFlutter.platformDefaults(); then ChatModel.load(path, options: load, prefixStore: store) |
local_inference's README, "Model layer (L2)"; here, "When the app leaves the foreground" |
Every tier runs an Assistant of local_inference (L3) configured by an AssistantConfig: its tools, prompt, replies
and questions. local_inference's README describes the layers below it (the engine L1, ChatModel L2) and model
delivery. A domain preset, a package that exports the AssistantConfig of one app domain, plugs into every tier.
Compatibility #
| Version | Notes | |
|---|---|---|
| Flutter | 3.44 or newer (Dart 3.12 or newer) | tested on Flutter 3.47.2 and 3.44.8 |
| background_downloader | >=9.5.9 <9.7.0: pub picks 9.5.9 on Flutter 3.44 to 3.46, 9.6.x on 3.47 and newer |
on 9.5.9 the Android Wi-Fi-only rule is kept in Dart, so only while the app runs ("Host setup") |
| local_inference | ^0.1.0 |
llamadart 0.8.23, llama.cpp v0.4.0, dinja 1.0.x |
| Platform | Floor | State on 2026-09-30 |
|---|---|---|
| iOS | 16.4 (the plugin's and llamadart's floor); Swift Package Manager or CocoaPods | a release build passes; an iPhone 16 Pro Max runs the engine and the chat |
| macOS | 14.0 | runs (Apple M1 Pro); the example builds |
| Android | the plugin declares minSdk 24; checkDeviceSupport() requires API 28, arm64-v8a and a 64-bit process before any download |
a release APK builds; nothing has run on an Android device yet |
| Linux, Windows | the plugin declares its Dart class only, for the native libraries' notices ("License and notices") | its channel calls fall back to Dart defaults (MissingPluginException); not tested |
| Web | none | not supported: local_inference uses dart:io |
A consumer app passes flutter test and the macOS and iOS debug builds on Flutter 3.47.2 and 3.44.8, with no
LiteRT-LM library in the apps. The model families and their measured speed are in local_inference's README,
"Compatibility".
Performance and memory #
Measured figures, each with its device and conditions (local_inference's README, "Performance and memory", has the rest).
| What | Figure | Where and how |
|---|---|---|
| A fine-tuned Qwen3.5 4B, Q5_K_M (3.07 GB), iPhone 16 Pro Max | a tool-call turn 1.7-3.1 s after the prefix restore; first token 296-320 ms; decode 7.7-12.2 tok/s | iOS 26.6.1, Release build with the increased-memory-limit entitlement, micro-batch 128 |
| A fine-tuned Qwen3.5 2B, Q5_K_M (1.45 GB), same phone | a turn 1.0-2.0 s, hot and charging | the same build |
| GPU memory of the 4B, same phone | 3,321 MB after load, of a 5,461 MB Metal working set | Metal's allocation read after the load |
| First prefix build (14 tools, 2,054 tokens) | once per model install; 120 MB in the caches folder (4B). 12.1 s with the load on the Mac; not measured on an iPhone yet: an estimate from the measured prefill rate gives about 16-21 s on a cool phone and about 40 s on a hot one | M1 Pro; meanwhile the chat says it happens only the first time with this model |
| Heat | the iPhone reached "serious" within about three minutes of back-to-back turns, then ran at about half speed | iPhone 16 Pro Max |
| App size | an app on the kit, without models: iOS 32.9 MB, macOS 73.7 MB, Android APK (arm64 release) 80.6 MB | release builds, 2026-09-30 |
| Download | one file per model, 3.07 GB for a 4B Q5_K_M (a 2B Q5_K_M is 1.45 GB); on iOS the resource timeout is 24 hours (4 hours would stop below about 1.7 Mbit/s) | BackgroundDownloadConfig.iosResourceTimeout |
| Storage | models in Application Support (iOS, macOS) or noBackupFilesDir (Android), excluded from backups; up to 3 prefix states in the caches folder |
this package's paths |
Host setup #
What a plugin cannot do for the app. The doctor checks most of it and says how to fix each finding:
dart run local_inference_flutter:doctor --strict # in the app folder; exit status 1 on a warning or error
dart run local_inference_flutter:doctor --install-ios-script # copies the ITMS-90208 script to ios/scripts/
dart run local_inference_flutter:doctor --json --verify-natives # for CI: JSON, and the natives against the manifest
local_inference's README, "Doctor", lists every check, the JSON format, and what to do when dart run cannot start
the doctor. The doctor does not check these; check them by hand: installDownloaderStorage() first in main(), the
bg_downloader_* strings, the backup rules, and that nothing else cleans <models>/.partial or <models>/.resume.
The notices of the native libraries need no code, only a license page users can open, as the Dart packages' licenses
do ("License and notices").
- All hosts: llamadart's hooks block in the app pubspec (the workspace root for a workspace),
hooks: user_defines: llamadart: llamadart_native_runtimes: [llama_cpp], thenflutter clean; callawait LocalInferenceFlutter.installDownloaderStorage(download: config)first inmain(), right afterWidgetsFlutterBinding.ensureInitialized()and before anything callsFileDownloader()(a host that creates FileDownloader itself passes itRetainingResumeStorage()on that first call instead, then callsBackgroundModelDownloader.holdUpdates(config.group)). - The model downloader configures background_downloader for the whole app: the free space it checks, and on Android
no cache folder, the temporary folder
<models>/.partialand the foreground setting (never, unless opted in); on iOS the resource timeout, no iCloud backup and the words of the notification actions. A host that runs its own background_downloader tasks setsBackgroundDownloadConfig(configureFileDownloader: false)and configures FileDownloader itself. - iOS:
- deployment target 16.4 (Runner and Podfile) and the
com.apple.developer.kernel.increased-memory-limitentitlement; a signed build also needs the Increased Memory Limit capability on the App ID, and so in its provisioning profile (Signing & Capabilities); - a Run Script phase after "Thin Binary" running a copy of
tool/ios/native_assets_min_os.sh(ITMS-90208), withENABLE_USER_SCRIPT_SANDBOXING = NO; UNUserNotificationCenter.current().delegate = selfin the AppDelegate (download notifications);NSPhotoLibraryUsageDescriptionandNSPhotoLibraryAddUsageDescriptionin Info.plist, which background_downloader needs for an App Store upload, orBYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY=1andBYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY=1in every build environment (Swift Package Manager builds; a CocoaPods build passes-D BYPASS_PERMISSION_...in the Podfile'sOTHER_SWIFT_FLAGSinstead, as background_downloader's doc/permissions.md shows);- FileTimestamp C617.1 in the app's privacy manifest.
- deployment target 16.4 (Runner and Podfile) and the
- Android:
-
the plugin merges INTERNET, POST_NOTIFICATIONS and RUN_USER_INITIATED_JOBS. Keep the last one: from Android 14, with notifications allowed, a download runs as a user-initiated job, which without it would become an expedited job that Android stops after 2 minutes;
-
minSdk 28 is recommended (below it
checkDeviceSupport()refuses before any download), withabiFilters arm64-v8a(plusx86_64for emulators); -
the words of the download notification's buttons and channel are string resources of the host:
bg_downloader_cancel,bg_downloader_pause,bg_downloader_resume,bg_downloader_notification_channel_nameandbg_downloader_notification_channel_description. background_downloader's own are English; this plugin ships none. An app in Italian puts them inandroid/app/src/main/res/values/strings.xml(an app in several languages: onevalues-<language>/folder each):<resources> <string name="bg_downloader_cancel">Annulla</string> <string name="bg_downloader_pause">Pausa</string> <string name="bg_downloader_resume">Riprendi</string> <string name="bg_downloader_notification_channel_name">Download</string> <string name="bg_downloader_notification_channel_description">Avanzamento del download del modello</string> </resources> -
a foreground-service download is opt-in (
AndroidForeground). WorkManager already merges FOREGROUND_SERVICE and theandroidx.work.impl.foreground.SystemForegroundServiceentry; a host that opts in adds FOREGROUND_SERVICE_DATA_SYNC,android:foregroundServiceType="dataSync"on that entry (tools:node="merge") and the Play Console declaration; -
models go to the no-backup folder. Where they stay in Application Support (an install from before the packages, or no plugin), the host's backup rules must leave out
models/; -
Wi-Fi-only is best effort on background_downloader 9.5.9 (Flutter 3.44 to 3.46), which would run a Wi-Fi-only task on any unmetered network: the downloader holds such a task back off Wi-Fi and pauses it when the connection leaves Wi-Fi, but only while the app runs (in the background the unmetered rule applies). The connection reading needs ACCESS_NETWORK_STATE, which WorkManager merges;
-
partial downloads live in
<models>/.partialand the kept resume data of failed ones in<models>/.resume; both are cleaned by the downloader, so leave them out of any cleanup of your own; -
nothing has run on an Android device yet.
-
- macOS: deployment target 14.0;
com.apple.security.network.clientwhen sandboxed. A sandboxed app reads a model file only in its container (the models the manager downloads are there) or one the user picked (com.apple.security.files.user-selected.read-only).
Testing your assistant #
flutter_test stays your dev dependency; the package ships no widget-test helpers.
package:local_inference_flutter/testing.dart re-exports local_inference's test doubles (ScriptedEngine,
ScriptedTurn, scriptedAssistant, InMemoryPrefixStateStore, …), so a test needs no other dependency. A widget
test gives LocalInferenceChat a scripted engine (no llama.cpp in a test) and a prefix store in memory
(path_provider has no folders in a test: without it the chat cannot start), and lets the assistant's work go on
with a helper like pumpUntil. The kit itself (scriptedAssistant for an assistant without a UI, TestToolkit, the
delivery fakes) is described in local_inference's README, "Testing your assistant".
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:local_inference_flutter/local_inference_flutter.dart';
import 'package:local_inference_flutter/testing.dart';
// The config under test; in an app it comes from the app's own code.
final weather = AssistantConfig.basic(
systemPrompt: 'You tell the weather in a few words.',
tools: [
AssistantTool(
name: 'weather',
description: 'The weather in a city.',
parameters: ToolSchema.object({'city': ToolSchema.string()}, required: ['city']),
statusLabel: 'Checking the weather...',
run: (call, context) => ToolResult.ok({'city': call.string('city'), 'sky': 'sunny'}),
),
],
);
void main() {
testWidgets('the chat answers with the weather tool', (tester) async {
final engine = ScriptedEngine(supportsStatePersistence: false)
..outputs.addAll([
const ScriptedTurn.toolCall('weather', {'city': 'Rome'}).output,
'Sunny in Rome.',
]);
// Any file stands for the model: the scripted engine does not read it.
final model = File('${Directory.systemTemp.createTempSync().path}/weather.gguf')..writeAsStringSync('x');
await tester.pumpWidget(
MaterialApp(
home: LocalInferenceChat(
config: weather,
model: ModelOrigin.file(model.path),
engine: () => engine,
prefixStore: InMemoryPrefixStateStore(),
suggestions: const ['Weather in Rome?'],
),
),
);
await pumpUntil(tester, () => find.text('Weather in Rome?').evaluate().isNotEmpty);
await tester.tap(find.text('Weather in Rome?'));
await pumpUntil(tester, () => find.text('Sunny in Rome.').evaluate().isNotEmpty);
});
}
/// Lets the assistant and real I/O go on: short real waits and frames, until [done].
Future<void> pumpUntil(WidgetTester tester, bool Function() done, {int maxSteps = 300}) async {
for (var i = 0; i < maxSteps && !done(); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
await tester.pump();
}
expect(done(), isTrue);
}
Hooks in library packages. flutter test runs llamadart's build hook even when the tests only use
ScriptedEngine, and a package is the root package while it runs its own tests. So a library that depends on this
package (a feature package of a larger app, say) needs the hooks block in its own pubspec too, or its tests download
LiteRT-LM. The block is ignored when an app depends on the library: the app's own block counts. In CI, cache the pub
cache (llamadart keeps the bundle it downloads there) or vendor the natives with llamadart_native_path
(local_inference's README, "Vendored natives").
Chat UI #
A chat on an Assistant of local_inference (its tools, prompt, replies and questions come from the
AssistantConfig), in three tiers. test/readme_examples_test.dart holds the examples of this file and runs them (in
an app, oneWidget and pagesOverControllers are main); the second and third continue the first.
1. One widget #
LocalInferenceChat(config:, model:) makes the model manager, the assistant and the model setup, starts them, and
disposes them when it goes. The first launch asks before downloading (size, device fit), shows the download, then
loads the model by itself. model is ModelOrigin.manifest(url) (a manifest you publish: its format is in
local_inference's README, "Model manifest"), ModelOrigin.catalog(catalog) or ModelOrigin.file(path); it is read
once (for another model, give the widget a new key). A new config reaches the assistant after the turn under way.
import 'package:flutter/material.dart';
import 'package:local_inference_flutter/local_inference_flutter.dart';
final weather = AssistantConfig.basic(
systemPrompt: 'You tell the weather in a few words.',
tools: [
AssistantTool(
name: 'weather',
description: 'The weather in a city.',
parameters: ToolSchema.object({'city': ToolSchema.string()}, required: ['city']),
statusLabel: 'Checking the weather...',
run: (call, context) => ToolResult.ok({'city': call.string('city'), 'sky': 'sunny'}),
),
],
);
Future<void> oneWidget() async {
WidgetsFlutterBinding.ensureInitialized();
await LocalInferenceFlutter.installDownloaderStorage();
runApp(
MaterialApp(
home: LocalInferenceChat(
config: weather,
model: ModelOrigin.manifest(Uri.parse('https://example.org/models/manifest.json')),
assistantName: 'Helper',
welcome: 'Hi! Ask me about the weather.',
suggestions: const ['Weather in Rome?'],
),
),
);
}
- Languages. The UI kit's texts follow the app's locale: Italian for
it, English for any other (LocalInferenceLocalizations.delegateadds them to the app's localizations;LocalInferenceScope(strings:)fixes them). Material's own words, such as the selection menu's Copy, come fromflutter_localizations(GlobalMaterialLocalizations.delegates) in an app that is not in English. What the assistant says (replies, questions, its Yes and No) comes from theAssistantConfigand is never translated by the UI: an Italian chat passesAssistantConfig.basic(..., strings: AssistantStrings.italian, contextLine: ContextLines.italian). - When it fails. A panel names the kind of failure ("the model could not be loaded"), with "Try again". The
cause is
AssistantController.assistant.state.loadError(andPhaseChangedEvent.error); debug builds print it, andChatFeatures(showDetails: true)shows it under the panel. - A model file on macOS. A sandboxed macOS app (the default of
flutter create) reads only its own container and the files the user picked: keep the model in Application Support, let the user pick it (com.apple.security.files.user-selected.read-only), or turn the sandbox off while developing. A model outside the container fails to load with "Model file does not appear to be GGUF … Please verify the download completed correctly": the file is fine, the sandbox refused it.
2. Pages over your own controllers #
ChatPage (a Scaffold with the title and the new-conversation button) and ChatView (no Scaffold: a tab, a panel,
a split view) over an AssistantController and a ModelSetupController the app keeps above its Navigator, in a
LocalInferenceScope: the chat survives pages pushed on top and a page closed and opened again (the messages and the
draft stay). A provider may hold the controllers and pass them to the pages (controller:, setup:); keep a
LocalInferenceScope(assistant: controller, child: …) above them all the same, since the scope applies the lifecycle
policy ("When the app leaves the foreground"). ModelSetupView, ModelLibrary (a settings page's model list, or
ModelLibrary.forAssistant) and showModelPicker work on their own too. The bundled manifest of the example below is
an asset: list it under flutter: assets: in the app's pubspec (its format: local_inference's README, "Model
manifest").
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_inference_flutter/local_inference_flutter.dart';
Future<void> pagesOverControllers() async {
WidgetsFlutterBinding.ensureInitialized();
const download = BackgroundDownloadConfig(group: 'my_app_models', appName: 'Helper');
await LocalInferenceFlutter.installDownloaderStorage(download: download);
final models = await LocalInferenceFlutter.createModelManager(
catalog: ModelCatalog.bundled(() => rootBundle.loadString('assets/models/manifest.json')),
download: download,
);
// An Italian chat: the assistant's own words (its Yes and No) in Italian too.
final assistant = await LocalInferenceFlutter.createAssistantController(
weather.copyWith(strings: AssistantStrings.italian),
);
final setup = ModelSetupController(manager: models, assistant: assistant);
runApp(
MaterialApp(
theme: ThemeData(colorSchemeSeed: Colors.teal, extensions: const [LocalInferenceTheme(bubbleRadius: 20)]),
// Above the Navigator: the chat survives the pages pushed on top, and sheets and dialogs get its texts.
builder: (context, child) => LocalInferenceScope(
assistant: assistant,
setup: setup,
models: models,
strings: LocalInferenceStrings.italian,
child: child!,
),
home: ChatPage(
assistantName: 'Helper',
suggestions: const ['Che tempo fa a Roma?'],
callbacks: ChatCallbacks(
onScreenRequest: (context, dispatch) => Navigator.of(context).pushNamed('/city'),
onReport: (report) => debugPrint(jsonEncode(report.toJson())),
),
builders: ChatBuilders(
assistantMessage: (context, slot) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(child: Icon(Icons.smart_toy)),
Expanded(child: slot.buildDefault()),
],
),
),
),
),
);
}
- Model files of your own. A model file the app has (bundled, or found on the device) is a candidate of the
setup, loaded at start, with or without a model manager:
ModelSetupController(assistant: assistant, candidates: (installed) => [ModelCandidate(path: file), ...installed.map(ModelCandidate.installed)]). WithautoLoad: false, or afterunloadModel(), the chat offers "Load" (SetupStage.modelAvailable). - Screens. A reply may ask for a screen (
AssistantConfig.screenRequests):onScreenRequestopens it 600 ms after the reply, once the turn ended, only while the chat is the visible page (ScreenRequestPolicy: the delay,whenHidden: drop | deferUntilVisible,isVisiblefor a tab host, asked again whenever the chat rebuilds), and again from the reply's chip, unless the list item it named changed meanwhile (onStaleScreenRequest, default a SnackBar). Without it, no chip shows. - Scrolling. The list is lazy and follows new text only while it is at the bottom (48 points,
ChatScrollController(stickThreshold:);double.infinityalways follows); reading further up, nothing moves and a button jumps to the newest message. Sending a message or answering a question goes back to the newest message. A new conversation opens at its newest message. - Compliance. The AI disclosure (EU AI Act, art. 50(1)):
ChatFeatures.aiDisclosure=banner(default),dismissible(onDisclosureDismissed),inWelcomeornone. WithonReport, a reply can be reported from its selection menu or a flag button (reportAffordance; the flag when the text cannot be selected); theReportRequestcarries the reason, the comment, the reply, the user's text of its turn, the conversation id, the model file, the template SHA-256, the runtime and the time (toJson()). Model-written replies carry a semantics hint (ProvenanceMarking; a builder that draws a reply's text itself usesMessageContext.provenanceHint), optionally a badge and a notice on copied text. - Accessibility. Live regions for "Thinking…" and the tool status (emoji are not read), the finished reply read
aloud when a screen reader is on, tooltips (
ChatFeatures.tooltips; the labels stay without them), 48-point tap targets, text scaled to 2.0 (on a small phone the disclosure and the suggestions then scroll within their part of the height, so the messages keep theirs), directional layout (right to left).
3. Headless #
The adapters alone, for a UI of your own: AssistantController.itemIds changes only when messages come or go,
item(id) only when that message changes (while a reply streams, only its own item fires), composer,
pendingActions, phase, draft, events and analytics (usage events that never carry what was written).
Riverpod or BLoC hosts can also use the core Assistant.states stream. Put a LocalInferenceScope(assistant: chat, child: …) above such a UI too (every other parameter is optional): it applies the lifecycle policy ("When the app
leaves the foreground").
class MyChat extends StatelessWidget {
const MyChat(this.chat, {super.key});
final AssistantController chat;
@override
Widget build(BuildContext context) => Column(
children: [
Expanded(
child: ValueListenableBuilder<List<int>>(
valueListenable: chat.itemIds,
builder: (context, ids, _) => ListView.builder(
itemCount: ids.length,
itemBuilder: (context, index) => ValueListenableBuilder<ChatItem>(
key: ValueKey(ids[index]),
valueListenable: chat.item(ids[index]),
builder: (context, item, _) => ListTile(
title: Text(item.thinking && item.text.isEmpty ? '…' : item.text),
subtitle: item.status == null ? null : Text(item.status!),
leading: Icon(item.role == MessageRole.user ? Icons.person : Icons.smart_toy),
),
),
),
),
),
TextField(
controller: chat.draft,
// Enabled during a turn too: a disabled field would close the keyboard after each message.
onSubmitted: (text) {
if (!chat.composer.value.canSend) return;
chat.draft.clear();
chat.send(text);
},
),
],
);
}
Customizing #
Precedence, for the look, the texts and the switches: a widget's own parameter, then LocalInferenceScope, then
the app theme's LocalInferenceTheme extension and LocalInferenceLocalizations.delegate, then the defaults.
- Look.
LocalInferenceTheme(aThemeExtension, every field optional) resolves intoLocalInferenceStylefrom the app'sColorSchemeandTextTheme, so light and dark need no value: accent and user bubblesprimary, textonSurface, secondary textonSurfaceVariant, and the default shapes (bubble radius 14, composer 24, cards 16; user bubbles up to 0.78 and replies up to 0.9 of the list's width). Every progress bar and spinner gets its colors explicitly (progress,progressTrack, andpanelProgressTrackfor the status panels), so an app-wide whiteprogressIndicatorThemecannot hide them. - Texts.
LocalInferenceStringsholdschat,setup(ModelSetupStrings),a11yandformats, in.italianand.english, withcopyWithandforLocale;LocalInferenceScope(stringsOverride:)changes the texts chosen for the locale. Conversation text (replies, questions, Yes and No, tool labels) comes from theAssistantConfigand is never translated by the UI. - Parts.
ChatBuilders(header, welcome, userMessage, assistantMessage, messageText, thinking, toolStatus, screenChip, footer, details, suggestions, confirmation, composer, statusPanel, noModel, disclosure, jumpToLatest, setup) andModelSetupBuilders(consent, progress, noneOffered, catalogUnavailable, tile, confirmDialog): each builder gets a context whosebuildDefault()returns the package's widget, to wrap or decorate. The welcome row is drawn bywelcome, notassistantMessage; the defaultcomposerincludes the divider above it;statusPanelalso draws the model setup's panels inside the chat. - Switches.
ChatFeatures: details, suggestions, confirmation buttons, chips, the disclosure, reports, provenance, selectable text, copy, the new-conversation and stop buttons, jump-to-latest, auto-scroll, announcements, tooltips, haptics, the composer's lines and length, the keyboard's send key and Enter on a desktop. - UI callbacks.
ChatCallbacks:onScreenRequest,onStaleScreenRequest,onLinkTap(http(s) links become tappable),onReport,onCopy,beforeSend,onDisclosureDismissed. Turns, phases, models and conversations come fromAssistantController.events, and analytics fromAssistantController.analyticsandModelSetupController.analytics, whether a chat is on screen or not. - Keys.
LocalInferenceKeysnames the widgets for tests:composerField,sendButton,message(id),confirmAction(kind),screenChip(id),reportAction(id),loadModel,setupDownload,libraryTile(id), …
When the app leaves the foreground #
LocalInferenceScope(lifecycle:) applies a LifecyclePolicy. The defaults:
cancelTurnOnBackground: false: a turn running when the app goes to the background goes on. The system suspends the app, and the reply continues when the app comes back. On an iPhone 16 Pro Max with iOS 27, 20 s at the Home screen or with the phone locked, during a reply and during the first prefix build, paused the reply and then finished it (checked on 2026-10-01). iOS forbids GPU work in the background, so a host that sees turns fail there sets it totrue: the turn then stops when the app is paused and keeps its text so far, while a minimized desktop window keeps its turn.retryPrefixOnResume: true: a model whose load failed while the app was away loads again on return.unloadOnMemoryPressure: false.
LifecyclePolicy.none turns all of them off.
Only LocalInferenceScope applies the policy; LocalInferenceChat makes its own scope. A host without one (the
controllers in a provider, a headless UI) wraps its UI in LocalInferenceScope(assistant: controller, child: …), or
calls controller.stop() itself when the app is paused. A host on ChatModel or the engine cancels its
ChatGeneration or Generation there (an AppLifecycleListener(onPause: …), for example); nothing in the packages
does it for them.
Model download #
LocalInferenceFlutter.createModelManager wires local_inference's ModelManager for the platform: models in
Application Support on iOS and macOS (Android's no-backup folder on Android), the plugin's probe (memory, free disk,
a native SHA-256, the backup flag), and background_downloader on phones (a foreground download on desktop). The
manager reports codes; ModelSetupStrings and LocalizedFormats say them. The catalog below is a manifest bundled as
an asset (listed under flutter: assets: in the app's pubspec) and published at a URL; local_inference's README,
"Model manifest", has its format.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_inference_flutter/local_inference_flutter.dart';
const download = BackgroundDownloadConfig(group: 'my_app_models', appName: 'Assistant');
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LocalInferenceFlutter.installDownloaderStorage(download: download);
final models = await LocalInferenceFlutter.createModelManager(
catalog: ModelCatalog.bundled(
() => rootBundle.loadString('assets/models/manifest.json'),
remote: Uri.parse('https://example.org/models/manifest.json'),
),
download: download,
);
runApp(MaterialApp(home: ModelsPage(models: models)));
await models.init();
}
class ModelsPage extends StatelessWidget {
const ModelsPage({super.key, required this.models});
final ModelManager models;
@override
Widget build(BuildContext context) => Scaffold(
body: ListenableBuilder(
listenable: ChangeSourceListenable(models),
builder: (context, _) => ListView(
children: [
if (models.isReady)
for (final entry in models.entries)
ListTile(
title: Text(entry.displayName),
subtitle: Text(describe(entry)),
onTap: () => models.download(entry.id),
),
],
),
),
);
String describe(ModelEntry entry) {
const strings = ModelSetupStrings.english;
const formats = LocalizedFormats.english;
final problem = models.problemFor(entry);
if (problem != null) return strings.problem(problem, formats);
final status = models.statusOf(entry.id);
final error = status.error;
return switch (status.state) {
ModelState.downloading => strings.tileDownloading(formats.percent(status.progress)),
ModelState.failed when error != null => strings.error(error),
ModelState.ready => strings.tileReady,
_ => formats.bytes(entry.sizeBytes),
};
}
}
installDownloaderStorage(download:)makesRetainingResumeStoragebackground_downloader's storage: a failed download keeps its resume data, and a retry resumes it, also after a relaunch. It must be the app's firstFileDownloader()call; after one made elsewhere a retry starts from byte 0. It also holds the updates of the config's group that arrive before the manager's downloader sets up (a launch in the background to finish a download), so pass it the configcreateModelManagergets, and create andinit()the manager at launch: what arrives before that is held in memory only.- A finished download is checked before it counts: a web page, an answer other than 200 or 206, or a GGUF file
without the GGUF magic fails (
BackgroundDownloadConfig.verifyContent). ModelManagerWatchergivesValueListenables ofready,installedand each model'sstatus.LocalInferenceDeviceanswers the platform, memory, the no-backup folder, the connection (network()) and adiagnostics()snapshot;checkDeviceSupport()applies the Android floor (API 28, arm64-v8a, 64-bit) before any download.platformLoadOptions()gives the engine's load options for the device (the CPU in a simulator).
License and notices #
- local_inference_flutter is released under the MIT License (
LICENSE).NOTICElists the third-party software this package depends on, the native libraries apps bundle and the models the documentation names, each with its license. - The app bundles the native libraries llamadart's hook puts in it (llama.cpp and ggml, and the others of NOTICE,
section 2), whose licenses ask for their notices in every copy. Flutter's license page (
showLicensePage,AboutDialog,AboutListTile) lists them next to the Dart packages with no code in the app: Flutter's generated plugin registrant callsNativeLicenses.registerWith()beforemain()on every platform the plugin declares (on Linux and Windows it declares that Dart class only), which adds oneLicenseEntryper component of local_inference'sNATIVE_LICENSES.txt(its section, verbatim) under the component's names. Registered before the app's own code runs, they come first: the page keepsllama.cpp, the first package it reads, at the top of its list, and sorts the rest. - Users see them only on a license page the app opens: give them a way to it (a settings entry with
AboutListTile, an about dialog), as the licenses of the Dart packages need anyway. The example has none. NativeLicenses.entriesgives the same entries to a notices screen of your own. A widget test callsNativeLicenses.register()itself (a second call adds nothing), since Flutter's test binding registers no licenses and runs no plugin registrant.- What stays the host's: the notices of its other dependencies that are not Dart packages (Flutter collects the LICENSE files of those): native code of its own, and on Android the Java and Kotlin libraries of the plugins, which the page does not list in any Flutter app (NOTICE, section 2, names background_downloader's).
- A pure-Dart host (a server, a tool built with
dart build cli) has no license page: it shipsNATIVE_LICENSES.txtnext tobundle/(local_inference's README, "License and notices"). - A model has its own license (NOTICE, section 3), which applies to the app that downloads or ships it.