Skip to main content

AI integration

Adding generative model features to a Flutter app: where the call should originate, how to stream a response into the interface, how to get structured data back, and what to do when it fails or costs too much.

Contents

Architecture first

There are two shapes, and choosing the wrong one is expensive to undo:

ShapeThe app callsUse when
App to backend to modelYour own APIAlmost always: you control keys, prompts, rate limits, logging, and cost
App to model directlyThe provider SDKPrototypes, internal tools, or a managed SDK designed to hold no secret in the client

The second is tempting because it is fewer moving parts. It also means your prompt, your model choice, and your quota live in an artefact that anyone can decompile, and changing a prompt requires shipping an app update.

Never ship the key

An API key compiled into a Flutter app is extractable — from the binary, from --dart-define values, from network inspection on a rooted device. Treat any key that reaches the client as public.

Consequences, in practice:

  • Put the provider call behind your own endpoint, and authenticate the user to your endpoint.
  • Enforce rate limits and spending caps server-side. A leaked key with no cap is a bill, not an inconvenience.
  • If you use a managed client SDK, use the mode designed for untrusted clients — one that mints short-lived, scoped tokens or proxies through the vendor's own backend — and enable its abuse protection.

See Security and Local storage for token handling on the device.

Provider options

OptionNotes
Provider HTTP API through your backendPortable; you are not tied to a Dart SDK's release cadence
Vendor Dart SDK in the appFastest prototype; check whether it is intended for client use
Firebase-hosted model accessHandles client attestation and keeps the key server-side; ties you to Firebase
On-device modelNo network, no per-call cost, strong privacy; limited capability

Pick the provider on capability, latency, price, and data-handling terms — then isolate it behind an interface in your own code so swapping is a repository change, not a rewrite. Model names, context limits, and prices change frequently; read the provider's current documentation rather than any figure written in a tutorial, including this one.

Calling through your backend

Keep the model behind a repository, exactly like any other remote data source — see Networking and serialization.

abstract interface class AssistantRepository {
Future<Result<String>> complete(String prompt);
Stream<String> stream(String prompt, {CancelToken? cancelToken});
}

class HttpAssistantRepository implements AssistantRepository {
HttpAssistantRepository(this._dio);
final Dio _dio;

@override
Future<Result<String>> complete(String prompt) async {
try {
final response = await _dio.post<Map<String, Object?>>(
'/assistant/complete',
data: {'prompt': prompt},
options: Options(receiveTimeout: const Duration(seconds: 60)),
);
return Ok(response.data!['text']! as String);
} on DioException catch (e) {
return Err(Failure.fromDio(e));
}
}
}

Note the timeout: model calls are slow by the standards of a REST API, and the default timeouts you use elsewhere will cut off a legitimate response.

Streaming into the UI

Streaming is the single biggest perceived-latency improvement available. Have the backend forward server-sent events and expose them as a Dart Stream:

@override
Stream<String> stream(String prompt, {CancelToken? cancelToken}) async* {
final response = await _dio.post<ResponseBody>(
'/assistant/stream',
data: {'prompt': prompt},
cancelToken: cancelToken,
options: Options(responseType: ResponseType.stream),
);

final lines = utf8.decoder
.bind(response.data!.stream.map((chunk) => chunk.toList()))
.transform(const LineSplitter());

await for (final line in lines) {
if (!line.startsWith('data:')) continue;
final payload = line.substring(5).trim();
if (payload == '[DONE]') return;
yield jsonDecode(payload)['delta'] as String;
}
}

In the widget, accumulate rather than rebuilding the whole conversation per token:

StreamBuilder<String>(
stream: _tokens,
builder: (context, snapshot) => Text(_buffer.write(snapshot.data ?? '')),
)

Practical details that make streaming feel right: render partial text immediately, keep the scroll pinned to the bottom only while the user has not scrolled away, and give the user a stop button wired to a CancelToken — see Async and isolates.

Structured output

When you want data rather than prose, ask the provider for JSON (most support a JSON or schema-constrained mode), then parse it with the same machinery as any other response:

@freezed
class ExtractedReceipt with _$ExtractedReceipt {
const factory ExtractedReceipt({
required String merchant,
required double total,
required DateTime date,
@Default(<String>[]) List<String> items,
}) = _ExtractedReceipt;

factory ExtractedReceipt.fromJson(Map<String, Object?> json) =>
_$ExtractedReceiptFromJson(json);
}

Always validate. A model can return well-formed JSON with a nonsensical value, so range-check numbers, verify enums, and treat a parse failure as a normal error path with a retry — not as an exception that crashes the screen.

Multimodal input

For image input, keep the upload off the UI thread and downscale before sending: a 12-megapixel photo costs latency and tokens for no accuracy gain.

final bytes = await Isolate.run(() => _downscaleJpeg(originalBytes, 1024));

Ask for camera and photo permissions with a clear rationale, and tell the user what leaves the device. If the image contains personal data, that is a privacy disclosure, not an implementation detail.

On-device models

Small models can run locally through platform ML runtimes or dedicated Flutter packages. The trade is capability for privacy, offline support, and zero marginal cost.

Worth considering when: the task is narrow (classification, extraction, summarising a short text), the data is sensitive, or the feature must work offline. Not worth it when you need broad reasoning or long context.

Costs: a large model file to download or bundle, meaningful memory use, slower first run, and per-device performance variance. Run inference off the UI thread and measure on a low-end device.

Designing for failure

Model calls fail more often and in more ways than ordinary API calls. Handle all of these explicitly:

FailureResponse
TimeoutCancel and offer retry; never leave a spinner forever
Rate limitedBack off with jitter; tell the user it is temporary
Content filteredShow a specific message; do not present it as a crash
Malformed structured outputRetry once, then fall back to a manual path
OfflineDetect early and skip the call
Slow first tokenShow a token-level progress indicator, not an indeterminate spinner

Give the user a way out at every step: cancel, retry, edit the input, or do the task manually. A feature that can only succeed is a feature that hangs.

Cost and latency

  • Every call costs money proportional to input plus output length. Trim what you send: summarise history rather than resending it, and cap output length.
  • Cache aggressively. Identical inputs should not be billed twice — cache on the server, keyed by a hash of the normalised request.
  • Debounce input-driven calls so typing does not fire a request per keystroke.
  • Enforce per-user quotas server-side, and monitor spend from day one.
  • Measure end-to-end latency and time-to-first-token separately; they need different fixes.

Evaluating quality

Treat prompts as code:

  • Version them, keep them server-side, and change them without an app release.
  • Keep a fixed set of representative inputs with expected properties, and re-run it whenever the prompt or model changes.
  • Assert on properties — "returns valid JSON", "total matches the sum of items", "does not contain the customer's phone number" — rather than exact strings, since output varies between runs.
  • Log inputs and outputs (with consent and appropriate redaction) so you can diagnose complaints.

AI-assisted development

Coding assistants are useful for boilerplate, tests, and unfamiliar APIs. Two cautions specific to Flutter: generated code frequently targets an older API surface (deprecated widget parameters, superseded state management idioms), and package versions in generated snippets are often invented. Compile it, run the analyser, and check the package's own documentation before trusting it.

General background on models and providers is in AI.

Next: Resources.