Skip to content
Blog

Building an AI-Powered Fitness Tracker with GenKit

Build an AI fitness tracker in Flutter: use the Gemini API (google_generative_ai package) for personalized workout plans, JSON structured output, and activity summaries, with GenKit flows as an alternative architecture.

Published on August 18, 2026

AI Assistant

Every fitness app faces the same problem: generic, static plans that ignore the athlete using them. What if the app could read your training history and generate a plan tailored to your goal, experience, and available days? That is exactly the kind of task large language models are good at, and Google’s GenKit ecosystem makes it practical to ship. In this post you will build a Flutter fitness tracker that uses the Gemini API through the google_generative_ai Dart package to analyze workout data, generate personalized workout plans, and summarize activity. We will then look at GenKit flows as a more production-ready alternative architecture.

Prerequisites

  • Flutter SDK (3.x) with Dart 3.x.
  • A Gemini API key from Google AI Studio. Store it in a --dart-define so it never lands in source control.
  • Basic familiarity with async/await and JSON in Dart.

Add the SDK to pubspec.yaml:

dependencies:
  google_generative_ai: ^0.4.7

Note that Google now recommends the Firebase Vertex AI SDK for new mobile work, and GenKit’s Dart SDK is in preview, but the google_generative_ai package remains the simplest way to call the Gemini API directly from a Flutter app.

Step 1: Configure the Gemini model

Create a service that owns the GenerativeModel instance. Use String.fromEnvironment so the key is injected at build time with flutter run --dart-define=GEMINI_API_KEY=....

import 'package:google_generative_ai/google_generative_ai.dart';

class GeminiService {
  GeminiService({String? apiKey})
      : _model = GenerativeModel(
          model: 'gemini-1.5-flash',
          apiKey: apiKey ??
              const String.fromEnvironment('GEMINI_API_KEY'),
        );

  final GenerativeModel _model;

  Future<String> prompt(String text) async {
    final response = await _model.generateContent([Content.text(text)]);
    return response.text ?? '';
  }
}

GenerativeModel.generateContent takes a list of Content parts. A single Content.text is all you need for a one-shot request; use multiple parts when you need to mix instructions, history, and user input.

Step 2: Define a workout data model

Before any AI involvement, define plain Dart classes for your domain. Keep them free of any AI-specific code so they stay testable.

class WorkoutEntry {
  const WorkoutEntry({
    required this.exercise,
    required this.sets,
    required this.reps,
    required this.weightKg,
  });

  final String exercise;
  final int sets;
  final int reps;
  final double weightKg;

  factory WorkoutEntry.fromJson(Map<String, dynamic> json) => WorkoutEntry(
        exercise: json['exercise'] as String,
        sets: json['sets'] as int,
        reps: json['reps'] as int,
        weightKg: (json['weightKg'] as num).toDouble(),
      );

  Map<String, dynamic> toJson() => {
        'exercise': exercise,
        'sets': sets,
        'reps': reps,
        'weightKg': weightKg,
      };
}

Step 3: Generate a personalized workout plan with structured output

Raw LLM prose is fine for chat but painful to render in a UI. The Gemini API supports structured output: declare a Schema, set responseMimeType: 'application/json', and the model returns JSON you can decode into typed objects.

final workoutSchema = Schema.object(
  properties: {
    'goal': Schema.string(description: 'The athlete stated goal.'),
    'difficulty': Schema.enumString(
      enumValues: ['beginner', 'intermediate', 'advanced'],
    ),
    'exercises': Schema.array(
      items: Schema.object(
        properties: {
          'name': Schema.string(description: 'Exercise name.'),
          'sets': Schema.integer(description: 'Number of sets.'),
          'reps': Schema.integer(description: 'Reps per set.'),
          'restSeconds': Schema.integer(description: 'Rest between sets.'),
          'notes': Schema.string(description: 'Coaching tip.'),
        },
        requiredProperties: ['name', 'sets', 'reps', 'restSeconds', 'notes'],
      ),
      description: 'List of exercises for the week.',
    ),
  },
  requiredProperties: ['goal', 'difficulty', 'exercises'],
);

Wire the schema into a model configured for JSON output, then build the prompt from the user’s profile and recent history. Passing real workout data grounds the plan in what the athlete has actually been doing.

class WorkoutPlanGenerator {
  WorkoutPlanGenerator(this._apiKey);

  final String _apiKey;

  Future<WorkoutPlan> generate({
    required String goal,
    required String experience,
    required int daysPerWeek,
    required List<WorkoutEntry> history,
  }) async {
    final model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: _apiKey,
      generationConfig: GenerationConfig(
        temperature: 0.7,
        responseMimeType: 'application/json',
        responseSchema: workoutSchema,
      ),
    );

    final prompt = '''
You are a certified personal trainer. Generate a weekly workout plan.
Goal: $goal
Experience: $experience
Training days per week: $daysPerWeek
Recent sessions: ${history.map((e) => e.toJson()).toList()}

Return JSON that matches the provided schema.
''';

    final response = await model.generateContent([Content.text(prompt)]);
    return WorkoutPlan.fromJson(jsonDecode(response.text!));
  }
}

The corresponding WorkoutPlan class decodes the exercises array and exposes the fields the UI will render:

class WorkoutPlan {
  const WorkoutPlan({
    required this.goal,
    required this.difficulty,
    required this.exercises,
  });

  final String goal;
  final String difficulty;
  final List<WorkoutExercise> exercises;

  factory WorkoutPlan.fromJson(Map<String, dynamic> json) => WorkoutPlan(
        goal: json['goal'] as String,
        difficulty: json['difficulty'] as String,
        exercises: (json['exercises'] as List)
            .map((e) => WorkoutExercise.fromJson(e as Map<String, dynamic>))
            .toList(),
      );
}

class WorkoutExercise {
  const WorkoutExercise({
    required this.name,
    required this.sets,
    required this.reps,
    required this.restSeconds,
    required this.notes,
  });

  final String name;
  final int sets;
  final int reps;
  final int restSeconds;
  final String notes;

  factory WorkoutExercise.fromJson(Map<String, dynamic> json) => WorkoutExercise(
        name: json['name'] as String,
        sets: json['sets'] as int,
        reps: json['reps'] as int,
        restSeconds: json['restSeconds'] as int,
        notes: json['notes'] as String,
      );
}

Step 4: Summarize activity

The same model can turn a week of raw log entries into a plain-language summary. This time no schema is needed, just a well-scoped prompt:

Future<String> summarizeWeek(List<WorkoutEntry> entries) async {
  final response = await _model.generateContent([
    Content.text('''
Summarize this athlete training week in 3 bullet points.
Focus on volume changes and recovery signals.
Entries: ${entries.map((e) => e.toJson()).toList()}
'''),
  ]);
  return response.text ?? '';
}

Step 5: A minimal UI

A stateful widget that calls the generator and renders the plan keeps the demo small but complete. Show a loading state, then the exercise list.

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

  @override
  State<PlanScreen> createState() => _PlanScreenState();
}

class _PlanScreenState extends State<PlanScreen> {
  late final WorkoutPlanGenerator _generator;
  Future<WorkoutPlan>? _plan;

  @override
  void initState() {
    super.initState();
    _generator = WorkoutPlanGenerator(const String.fromEnvironment('GEMINI_API_KEY'));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My AI Plan')),
      body: FutureBuilder<WorkoutPlan>(
        future: _plan,
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return const Center(child: CircularProgressIndicator());
          }
          final plan = snapshot.data;
          if (plan == null) {
            return Center(
              child: FilledButton(
                onPressed: () {
                  setState(() {
                    _plan = _generator.generate(
                      goal: 'hypertrophy',
                      experience: 'intermediate',
                      daysPerWeek: 4,
                      history: const [],
                    );
                  });
                },
                child: const Text('Generate plan'),
              ),
            );
          }
          return ListView.builder(
            itemCount: plan.exercises.length,
            itemBuilder: (context, index) {
              final exercise = plan.exercises[index];
              return ListTile(
                title: Text(exercise.name),
                subtitle: Text('${exercise.sets}x${exercise.reps} '
                    'rest ${exercise.restSeconds}s - ${exercise.notes}'),
              );
            },
          );
        },
      ),
    );
  }
}

Putting It All Together

The flow is simple: the user taps a button, the app serializes their profile and training history into a prompt, Gemini returns JSON validated against your Schema, and you decode it into WorkoutPlan objects rendered by the FutureBuilder. The pattern scales to any structured AI feature: diet plans, recovery recommendations, form-check summaries. Because parsing is driven by a schema, malformed output surfaces as a decode error instead of corrupting the UI.

Alternative Architecture: GenKit Flows

Calling the Gemini API directly in the app is convenient, but production apps usually want the AI logic behind an endpoint. GenKit is Google’s open-source framework for exactly that, and its Dart SDK (preview) lets you define typed flows that wrap model calls with input/output schemas, streaming, tracing, and deployment.

import 'package:genkit/genkit.dart';
import 'package:genkit_google_genai/genkit_google_genai.dart';
import 'package:schemantic/schemantic.dart';

@Schema()
abstract class $WorkoutInput {
  String get goal;
  String get experience;
  int get daysPerWeek;
}

@Schema()
abstract class $ExercisePlan {
  String get name;
  int get sets;
  int get reps;
}

void main() {
  final ai = Genkit(plugins: [googleAI()]);

  final workoutPlanFlow = ai.defineFlow(
    name: 'workoutPlanFlow',
    inputSchema: WorkoutInput.$schema,
    outputSchema: ExercisePlan.$schema,
    fn: (input, _) async {
      final response = await ai.generate(
        model: googleAI.gemini('gemini-flash-latest'),
        prompt: 'Create a workout plan for ${input.goal} at '
            '${input.experience} level, ${input.daysPerWeek} days a week.',
        outputSchema: ExercisePlan.$schema,
      );
      if (response.output == null) {
        throw Exception('Response does not satisfy schema.');
      }
      return response.output!;
    },
  );
}

Genkit(plugins: [googleAI()]) initializes the framework, ai.generate is the unified model interface, and defineFlow wraps your logic so it can be run with genkit flow:run workoutPlanFlow '{"goal":"hypertrophy"}' and debugged in the Genkit Developer UI. Your Flutter client then POSTs to the deployed flow over HTTP instead of holding an API key on the device, which is a meaningful security win.

Conclusion & Next Steps

You now have a Flutter fitness tracker that analyzes workout data, generates personalized plans, and summarizes training weeks with Gemini, using structured output to keep the UI typed and predictable. To go further: move the flow to a GenKit backend and call it over HTTP, add streaming with generateStream so the plan appears as it is generated, and add evaluation so you can measure plan quality across prompt changes. The pieces you built here map directly onto any AI-powered feature in your app.