Wiring the Claude Vision API into a Flutter App: Notes from Plant Doctor

Plant Doctor is a passive-income side project: point your camera at a sick-looking plant, get a diagnosis. The interesting engineering problem wasn't the AI call — it was everything around it.

Plant Doctor takes a photo of a plant, sends it to Claude's vision API, and comes back with a diagnosis and care recommendations, in one of twenty supported languages. Built with Clean Architecture, Riverpod 3.x, GoRouter, and Hive for local storage, with a freemium model gating how many diagnoses a free user gets.

The API call itself is the least interesting part of this project. What actually shaped the architecture was three requirements colliding: the diagnosis has to work when the user has no idea what plant they photographed, the history of past diagnoses has to be readable offline, and free users need a hard, unspoofable usage limit.

Keeping the AI call behind a repository interface

Clean Architecture's whole pitch is that your domain layer shouldn't know or care which vision model is answering the question. In practice, that meant a PlantDiagnosisRepository interface that the rest of the app depends on, with a concrete implementation that talks to Claude's API and maps its response into the app's own domain models.

abstract class PlantDiagnosisRepository {
  Future<DiagnosisResult> diagnose(Uint8List imageBytes, String languageCode);
}

class ClaudeDiagnosisRepository implements PlantDiagnosisRepository {
  final ClaudeApiClient _client;

  @override
  Future<DiagnosisResult> diagnose(Uint8List imageBytes, String languageCode) async {
    final response = await _client.sendVisionRequest(
      imageBytes: imageBytes,
      prompt: _buildDiagnosisPrompt(languageCode),
    );
    return DiagnosisResult.fromClaudeResponse(response);
  }
}

The use case layer (the part that actually orchestrates "check the free-tier limit, then call the repository, then save to history") only ever talks to the abstract PlantDiagnosisRepository. That boundary is what let the freemium logic sit where it belongs.

The freemium gate lives below the repository, not in the UI

An early version of this checked the user's remaining free diagnoses in the widget layer, before calling the use case. That's the wrong place for it — it meant every entry point to diagnosis (camera capture, gallery picker, re-diagnose from history) had to remember to do the check itself, and it's exactly the kind of check a user could route around by finding an entry point that forgot it.

Moving the limit check into the use case, immediately before the repository call, meant there's exactly one place that decision gets made, and no UI code path can accidentally skip it.

Offline history changed the storage decision more than the AI feature did

Diagnoses have to be viewable without a connection — someone diagnosing a plant on a balcony with no signal still needs to see what their last diagnosis said. That's a Hive box of past DiagnosisResult objects, written locally the moment a diagnosis completes, independent of whether the original API call is ever retried or re-verified. The image itself is stored as a local file reference rather than re-fetched, since there's no guarantee the network will be back when the user wants to revisit an old result.

The pattern that generalizes: when a feature depends on a remote AI call but also needs offline recall, the API call and the persisted history are two separate concerns with two separate lifetimes. Design the write-to-history step so it's not coupled to whether the network call can be repeated later.

Twenty languages meant the prompt had to travel, not just the UI strings

Flutter's localization handled the UI text the usual way. The part that needed more thought was that the diagnosis itself — the actual AI-generated response — has to come back in the user's language, which means the language preference travels into the API request as a parameter of the prompt, not just as a display-layer concern. Getting that wrong looks like "the app is localized" while the one part of the screen the user actually cares about reading is still in English.

Takeaways for anyone building an AI feature into a Flutter app

  • Put the AI call behind a repository interface from day one — it keeps your domain and use-case layers testable and makes swapping providers later a non-event.
  • Any usage limit or gate belongs in the use-case layer, at the single choke point before the paid resource is called — never duplicated across UI entry points.
  • If the feature needs offline recall, treat "call the AI" and "persist the result" as separate concerns with separate lifetimes, not one atomic step.
  • If output needs to be localized, the language has to flow into the request itself, not just style the surrounding UI.