Build an AI Clinical Reasoning Trainer

Build a Flutter app where AI patients help medical students practice diagnosis.

Introduction

30 Second Summary

Medical students spend years memorizing facts but often struggle when facing a real patient for the first time. The gap between textbook knowledge and clinical reasoning is one of the biggest challenges in medical education.

In this project, you will build ClinIQ, a Flutter mobile app that trains medical students in clinical reasoning through AI-simulated patient encounters powered by Gemini.

What You'll Build

ClinIQ is a Flutter app where users practice diagnosing patients by conversing with an AI-powered simulated patient, tracking differential diagnoses in real-time, and receiving scored evaluations of their clinical thinking.

By the end of this project, you'll have:

  • An AI patient conversation engine with multi-turn context and streaming responses powered by Gemini.
  • A real-time differential diagnosis tracker that updates as the user asks questions during a patient encounter.
  • A performance analytics dashboard backed by Supabase PostgreSQL that tracks weak areas and generates targeted quiz content.
  • Secret Mission: A "Teach Back" mode where the student explains a medical topic and Gemini evaluates their understanding.

Are there any prerequisites?

This project assumes intermediate Flutter experience. You should be comfortable with async/await, basic state management, and making REST API calls.

You also need a working Flutter SDK and a code editor (VS Code or Android Studio) already installed on your machine.

Set Up the Project and Architecture

ClinIQ needs a solid foundation before you start building features. A well-organized project structure separates concerns from day one, making it easier to navigate as the codebase grows across authentication, AI conversations, quizzes, and analytics.

In this step, you will scaffold a new Flutter project, organize it with a feature-first folder structure, and wire up the environment configuration that keeps your credentials safe.

In this step, get ready to:
  • Create the Flutter project with a scalable feature-first folder structure.
  • Add packages for state management, backend, AI, and offline storage.
  • Configure environment variables to keep API credentials out of source control.
Create the Flutter project

Every Flutter app starts with the flutter create command. This generates a working app skeleton with a lib/ directory, a default pubspec.yaml, and platform-specific runners for Android, iOS, and more.

  • Open the integrated terminal in your code editor by pressing Ctrl+` (backtick).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Flutter project by running this command:
flutter create cliniq
  • Move into the new project folder by running:
cd cliniq

What does flutter create do?

This command generates a complete Flutter project named cliniq with platform runners (Android, iOS, web, desktop), a default lib/main.dart, and a pubspec.yaml for managing dependencies.

  • Confirm the project was created by running:
ls

You should see folders like android, ios, lib, web, and files like pubspec.yaml in the output.

Seeing 'flutter: command not found'?

Make sure the Flutter SDK is on your system PATH. Close and reopen your terminal, then try again.

help me fix Flutter not being recognized in my terminal

Set up the feature-first folder structure

Instead of grouping all models together, all services together, and all screens together, a feature-first structure groups everything related to one feature in one folder. This keeps related code close and makes it easier to find what you need as the project grows.

  • Create the feature and core directories by running these commands:

macOS

mkdir -p lib/features/auth
mkdir -p lib/features/patient_encounter
mkdir -p lib/features/quiz
mkdir -p lib/features/analytics
mkdir -p lib/core/services
mkdir -p lib/core/models

Windows

mkdir lib/features/auth
mkdir lib/features/patient_encounter
mkdir lib/features/quiz
mkdir lib/features/analytics
mkdir lib/core/services
mkdir lib/core/models

What is feature-first architecture?

Each folder under lib/features/ will contain screens, widgets, state, and logic for one domain of the app. The lib/core/ folder holds shared services and data models that multiple features depend on.

This pattern scales well because adding a new feature means creating one new folder, not scattering files across six different directories.

  • Confirm the folders exist by running:
ls lib/features

You should see analytics, auth, patient_encounter, and quiz listed.

Folders not showing up?

Make sure you are inside the cliniq directory when running the mkdir commands. Run pwd (macOS/Linux) or Get-Location (Windows) to check your current directory.

help me verify I'm in the correct project directory

Add dependencies and configure the environment

ClinIQ depends on several packages for state management, backend communication, AI integration, and local caching. You will replace the default pubspec.yaml with the full dependency list, then set up a .env file to keep your credentials out of version control.

  • Open the cliniq folder in your code editor.
  • Open pubspec.yaml in the editor.
  • Select all the existing content and replace it with the following:
name: cliniq
description: AI Patient Simulator & Clinical Reasoning Trainer
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.0
  supabase_flutter: ^2.0.0
  google_generative_ai: ^0.4.0
  drift: ^2.14.0
  drift_flutter: ^0.1.0
  flutter_dotenv: ^5.1.0
  go_router: ^12.0.0
  fl_chart: ^0.65.0
  shimmer: ^3.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.0
  drift_dev: ^2.14.0
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true
  assets:
    - .env

What are these packages for?

  • flutter_riverpod manages app state across features with dependency injection.
  • supabase_flutter connects to your Supabase backend for authentication and database access.
  • google_generative_ai provides the Gemini API client for AI patient simulation.
  • drift and drift_flutter give you type-safe SQLite for offline caching.
  • flutter_dotenv loads environment variables from a .env file at runtime.
  • go_router handles declarative navigation with auth guards.
  • fl_chart renders performance analytics charts.
  • shimmer adds loading placeholder animations.
  • Save pubspec.yaml.
  • Install all the packages by running this command in your terminal:
flutter pub get

You should see a message ending with something like "Got dependencies" confirming all packages resolved successfully.

Seeing version solving errors?

Make sure the indentation in your pubspec.yaml uses exactly 2 spaces per level. YAML is whitespace-sensitive, so tabs or extra spaces cause parsing failures.

help me fix dependency version solving errors in my Flutter pubspec.yaml

Now create the environment file that stores your API credentials.

  • Create a new file called .env in the root of your cliniq project folder (at the same level as pubspec.yaml).
  • Paste the following content into .env, replacing each placeholder with your actual values:
GEMINI_API_KEY=your_gemini_api_key_here
SUPABASE_URL=your_supabase_project_url_here
SUPABASE_ANON_KEY=your_supabase_anon_key_here

Where do I find these values?

  • GEMINI_API_KEY: Copy from Google AI Studio where you generated your key.
  • SUPABASE_URL: In your Supabase dashboard, go to Settings then API. Copy the Project URL.
  • SUPABASE_ANON_KEY: On the same page, copy the anon public key.
  • Save .env.

Next, prevent this file from being committed to Git.

  • Open .gitignore in your code editor (it is in the project root).
  • Add the following line at the end of the file:
.env
  • Save .gitignore.

Why add .env to .gitignore?

Your .env file contains your Gemini API key and Supabase credentials. Adding it to .gitignore prevents Git from tracking the file, so your secrets never end up in a public repository.

Finally, wire up the app entry point so it loads the environment and initializes Supabase on startup.

  • Open lib/main.dart in your editor.
  • Select all the existing content and replace it with the following:
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');
  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
  runApp(const ProviderScope(child: ClinIQApp()));
}

class ClinIQApp extends StatelessWidget {
  const ClinIQApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ClinIQ',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const Scaffold(body: Center(child: Text('ClinIQ'))),
    );
  }
}

What does this code do?

  • WidgetsFlutterBinding.ensureInitialized() guarantees Flutter's engine is ready before any async work.
  • dotenv.load(fileName: '.env') reads your .env file and makes the values accessible via dotenv.env.
  • Supabase.initialize connects the app to your Supabase project using the URL and anon key from the environment.
  • ProviderScope wraps the app so Riverpod state management works throughout all features.
  • Save lib/main.dart.
  • Run the app on your connected emulator or device by running:
flutter run

You should see a screen with the text "ClinIQ" centered in the middle. This confirms your environment variables loaded and Supabase initialized successfully.

App crashing on launch?

If you see an error about .env not being found, make sure the assets section in your pubspec.yaml includes - .env and that the file exists at the project root.

If the Supabase initialization fails, double-check that your SUPABASE_URL and SUPABASE_ANON_KEY values in .env are correct and have no extra spaces.

help me debug my ClinIQ app crashing on startup

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: pubspec.yaml, .env, and lib/main.dart.

ⓧ I'd like to double check the full code

Here is the full content of each file you should have at this point.

name: cliniq
description: AI Patient Simulator & Clinical Reasoning Trainer
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.0
  supabase_flutter: ^2.0.0
  google_generative_ai: ^0.4.0
  drift: ^2.14.0
  drift_flutter: ^0.1.0
  flutter_dotenv: ^5.1.0
  go_router: ^12.0.0
  fl_chart: ^0.65.0
  shimmer: ^3.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.0
  drift_dev: ^2.14.0
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true
  assets:
    - .env
GEMINI_API_KEY=your_gemini_api_key_here
SUPABASE_URL=your_supabase_project_url_here
SUPABASE_ANON_KEY=your_supabase_anon_key_here
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');
  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
  runApp(const ProviderScope(child: ClinIQApp()));
}

class ClinIQApp extends StatelessWidget {
  const ClinIQApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ClinIQ',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const Scaffold(body: Center(child: Text('ClinIQ'))),
    );
  }
}

Your project skeleton is ready with a clean architecture and all the dependencies you need. Next up, you will set up the Supabase database schema with tables for case sessions, performance scores, and quiz attempts.

Set Up the Supabase Backend

Your Flutter project is connected to Supabase, but the database is empty. ClinIQ needs a structured schema to store patient encounters, track conversations, record scores, and log quiz attempts.

In this step, you will create five database tables, lock them down with Row-Level Security policies, and confirm that email authentication is ready to use.

In this step, get ready to:
  • Create the database schema with five relational tables.
  • Enable Row-Level Security so each user can only access their own data.
  • Confirm email/password authentication is active.
Create the database tables

ClinIQ stores relational data. A user has many case sessions. Each session has many responses (the conversation messages). Performance scores and quiz attempts link back to the user for analytics.

  • Open your Supabase project dashboard in your browser.
  • In the left sidebar, click SQL Editor.
  • Click New query to open a blank editor.
  • Paste the following SQL to create all five tables:
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Users table
CREATE TABLE users (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  email TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Case sessions table
CREATE TABLE case_sessions (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  specialty TEXT NOT NULL,
  difficulty TEXT NOT NULL,
  score INT,
  time_taken_seconds INT,
  missed_items JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Case responses table
CREATE TABLE case_responses (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  session_id UUID NOT NULL REFERENCES case_sessions(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Performance scores table
CREATE TABLE performance_scores (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  topic TEXT NOT NULL,
  score INT NOT NULL,
  source TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Quiz attempts table
CREATE TABLE quiz_attempts (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  topic TEXT NOT NULL,
  correct BOOLEAN NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

What does this SQL do?

This creates five tables that form the data backbone of ClinIQ:

  • users links to Supabase's built-in auth.users table so every authenticated user gets a profile row automatically.
  • case_sessions stores each patient encounter with its specialty, difficulty, final score, and time taken.
  • case_responses holds individual chat messages (student questions and AI patient replies) for each session.
  • performance_scores records topic-level scores from both cases and quizzes for the analytics engine.
  • quiz_attempts logs each individual quiz question attempt (correct or incorrect) for detailed reporting.
  • Click Run in the bottom right of the SQL Editor.
  • You should see a Success. No rows returned message confirming all tables were created.

Seeing a permission error?

Make sure you are running the query in the SQL Editor of your own Supabase project (not a shared or read-only project). The free plan gives you full database admin access within your own project.

If you see a "relation already exists" error, the tables were already created from a previous attempt. You can safely continue to the next substep.

help me debug a SQL error in the Supabase SQL Editor

Enable Row-Level Security

Without Row-Level Security, any authenticated user could read every other user's data. RLS policies lock each row so only the owning user can access it.

  • In the same SQL Editor tab, clear the previous query.
  • Paste and run the following SQL to enable RLS and create access policies:
-- Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE case_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE case_responses ENABLE ROW LEVEL SECURITY;
ALTER TABLE performance_scores ENABLE ROW LEVEL SECURITY;
ALTER TABLE quiz_attempts ENABLE ROW LEVEL SECURITY;

-- RLS Policies
CREATE POLICY "Users can manage own profile" ON users FOR ALL USING (auth.uid() = id);
CREATE POLICY "Users can manage own sessions" ON case_sessions FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can manage own responses" ON case_responses FOR ALL USING (auth.uid() = (SELECT user_id FROM case_sessions WHERE id = session_id));
CREATE POLICY "Users can manage own scores" ON performance_scores FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can manage own quiz attempts" ON quiz_attempts FOR ALL USING (auth.uid() = user_id);

What do these policies do?

Each policy uses auth.uid() to check the currently logged-in user's ID. A row is only visible (or writable) if the user owns it.

The case_responses policy is slightly different. It looks up the user_id through the parent case_sessions row, so a user can only see messages from their own sessions.

  • Click Run.
  • You should see Success. No rows returned again.
  • To verify RLS is active, click Table Editor in the left sidebar, select any table (for example users), and confirm you see a green RLS enabled badge near the table name.

Don't see the RLS badge?

If the Table Editor still shows RLS as disabled, try refreshing your browser. The dashboard caches table metadata and may not reflect changes immediately.

help me verify that Row-Level Security is enabled on my Supabase tables

Confirm email authentication is enabled

Supabase enables email/password authentication by default on hosted projects. You just need to verify this setting is active so your app can sign users up and in.

  • In the left sidebar, click Authentication.
  • Click Sign In / Providers in the sub-navigation.
  • Under Auth Providers, find the Email provider and confirm it shows as enabled.
  • If the toggle is off, click it to enable email/password sign-in, then click Save.

Why Supabase over Firebase for this project?

ClinIQ's data is deeply relational. Users have many case sessions. Sessions have many responses. Performance scores need SQL aggregations (averages by topic, trends over time).

Supabase gives you PostgreSQL natively, so you can write joins and aggregations server-side. Firebase Firestore would require denormalization and client-side aggregation, which is more expensive and harder to maintain as the schema grows.

Finally, save your schema locally for version control.

  • In your project's root folder (cliniq), create a new folder called supabase.
  • Inside supabase/, create a file called schema.sql.
  • Paste the combined SQL from both queries above into this file and save it.

Keep SQL in version control

Saving your schema as a local file means you can recreate the database from scratch at any time. It also documents your data model for anyone reading the codebase.

✔️ Awesome, I've got everything!

Great. Double check you have saved supabase/schema.sql and that all five tables appear in your Supabase Table Editor.

ⓧ I'd like to double check the full code

Here is the complete supabase/schema.sql file. It combines both SQL queries into one reference file:

-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Users table
CREATE TABLE users (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  email TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Case sessions table
CREATE TABLE case_sessions (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  specialty TEXT NOT NULL,
  difficulty TEXT NOT NULL,
  score INT,
  time_taken_seconds INT,
  missed_items JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Case responses table
CREATE TABLE case_responses (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  session_id UUID NOT NULL REFERENCES case_sessions(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Performance scores table
CREATE TABLE performance_scores (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  topic TEXT NOT NULL,
  score INT NOT NULL,
  source TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Quiz attempts table
CREATE TABLE quiz_attempts (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  topic TEXT NOT NULL,
  correct BOOLEAN NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE case_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE case_responses ENABLE ROW LEVEL SECURITY;
ALTER TABLE performance_scores ENABLE ROW LEVEL SECURITY;
ALTER TABLE quiz_attempts ENABLE ROW LEVEL SECURITY;

-- RLS Policies
CREATE POLICY "Users can manage own profile" ON users FOR ALL USING (auth.uid() = id);
CREATE POLICY "Users can manage own sessions" ON case_sessions FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can manage own responses" ON case_responses FOR ALL USING (auth.uid() = (SELECT user_id FROM case_sessions WHERE id = session_id));
CREATE POLICY "Users can manage own scores" ON performance_scores FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can manage own quiz attempts" ON quiz_attempts FOR ALL USING (auth.uid() = user_id);

Your database is ready. Next up, you will build the authentication flow so users can sign up, sign in, and have their identity tied to the data you just structured.

Build the Authentication Flow

Your Supabase backend is ready with a database schema and Row-Level Security policies. Now it's time to connect your Flutter app to that auth system so users can create accounts and sign in.

Authentication is the front door of ClinIQ. Without it, the app can't identify who's using it, which means no personalized case history or performance tracking. In this step, you'll build the full auth flow from state management to UI screens to route protection.

In this step, get ready to:
  • Implement auth state management with Riverpod that listens to Supabase auth changes.
  • Build sign-in and sign-up screens with email authentication.
  • Set up GoRouter with redirect guards that protect routes from unauthenticated users.
Implement auth state management with Riverpod

Before building any screens, you need a state layer that knows whether a user is logged in. Riverpod will hold the current user's auth state and expose it to the rest of the app. Every screen and route guard will read from this single source of truth.

You'll start with a simple data model for the user, then build the StateNotifier that listens to Supabase's auth stream.

  • Create the file lib/features/auth/models/app_user.dart and add the following model:
class AppUser {
  final String id;
  final String email;

  const AppUser({required this.id, required this.email});

  // Create an AppUser from a database row map
  factory AppUser.fromMap(Map<String, dynamic> map) {
    return AppUser(
      id: map['id'] as String,
      email: map['email'] as String,
    );
  }
}

What does this code do?

AppUser is a lightweight data class holding just an id and email. The fromMap factory makes it easy to construct an AppUser from a Supabase database row later.

  • Save app_user.dart.

Now build the auth state provider. This is the core piece that connects Supabase's auth events to your app's UI.

  • Create the file lib/features/auth/providers/auth_provider.dart and add the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/app_user.dart';

// Main auth state provider - exposes the current user as AsyncValue
final authProvider = StateNotifierProvider<AuthNotifier, AsyncValue<AppUser?>>(
  (ref) => AuthNotifier(),
);

// Convenience provider to read the current user without loading states
final authStateProvider = Provider<AppUser?>((ref) {
  return ref.watch(authProvider).valueOrNull;
});

class AuthNotifier extends StateNotifier<AsyncValue<AppUser?>> {
  AuthNotifier() : super(const AsyncValue.loading()) {
    _init();
  }

  final _client = Supabase.instance.client;

  void _init() {
    // Check if a session already exists on app launch
    final session = _client.auth.currentSession;
    if (session != null) {
      state = AsyncValue.data(AppUser(id: session.user.id, email: session.user.email ?? ''));
    } else {
      state = const AsyncValue.data(null);
    }
    // Listen for future auth state changes (sign in, sign out)
    _client.auth.onAuthStateChange.listen((data) {
      final user = data.session?.user;
      if (user != null) {
        state = AsyncValue.data(AppUser(id: user.id, email: user.email ?? ''));
      } else {
        state = const AsyncValue.data(null);
      }
    });
  }

  Future<void> signIn(String email, String password) async {
    state = const AsyncValue.loading();
    try {
      await _client.auth.signInWithPassword(email: email, password: password);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  Future<void> signUp(String email, String password) async {
    state = const AsyncValue.loading();
    try {
      final response = await _client.auth.signUp(email: email, password: password);
      // Insert a row into the users table so RLS policies work
      if (response.user != null) {
        await _client.from('users').insert({'id': response.user!.id, 'email': email});
      }
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  Future<void> signOut() async {
    await _client.auth.signOut();
  }
}

What does this code do?

  • authProvider is a StateNotifierProvider that holds the current user wrapped in AsyncValue. This lets widgets react to loading, error, and data states.
  • authStateProvider is a convenience shortcut that returns the user directly (or null if not logged in). The router will use this for redirect checks.
  • The _init() method checks for an existing session on startup, then subscribes to onAuthStateChange so the app reacts immediately when a user signs in or out.
  • The signUp method creates the auth account and then inserts a row into the users table. This is critical because your RLS policies reference that table.
  • Save auth_provider.dart.

Why StateNotifier instead of a plain Provider?

Auth state changes over time (loading, signed in, signed out, error). A plain Provider only holds a fixed computed value. StateNotifier gives you a mutable state container with an explicit API for transitions, which makes auth actions like signIn and signOut clean to call from UI code.

Build the sign-in and sign-up screens

With the state layer in place, you can now build the UI that lets users enter their credentials. You'll create two screens: one for existing users to sign in, and one for new users to create an account.

  • Create the file lib/features/auth/screens/login_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';

class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  @override
  ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _loading = false;

  Future<void> _signIn() async {
    setState(() => _loading = true);
    await ref.read(authProvider.notifier).signIn(
      _emailController.text.trim(),
      _passwordController.text.trim(),
    );
    if (mounted) setState(() => _loading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ClinIQ - Sign In')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Email input field
            TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
            const SizedBox(height: 16),
            // Password input field
            TextField(controller: _passwordController, decoration: const InputDecoration(labelText: 'Password'), obscureText: true),
            const SizedBox(height: 24),
            // Sign-in button with loading indicator
            _loading
                ? const CircularProgressIndicator()
                : ElevatedButton(onPressed: _signIn, child: const Text('Sign In')),
            // Link to the sign-up screen
            TextButton(onPressed: () => context.go('/signup'), child: const Text('Create an account')),
          ],
        ),
      ),
    );
  }
}

What does this code do?

  • LoginScreen extends ConsumerStatefulWidget so it can access Riverpod providers and also hold local state (the loading flag and text controllers).
  • The _signIn() method calls the auth notifier's signIn method with the trimmed email and password from the text fields.
  • The TextButton at the bottom uses context.go('/signup') to navigate to the sign-up screen via GoRouter.
  • Save login_screen.dart.

Next, create the sign-up screen. It mirrors the login screen but calls signUp instead.

  • Create the file lib/features/auth/screens/signup_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';

class SignupScreen extends ConsumerStatefulWidget {
  const SignupScreen({super.key});

  @override
  ConsumerState<SignupScreen> createState() => _SignupScreenState();
}

class _SignupScreenState extends ConsumerState<SignupScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _loading = false;

  Future<void> _signUp() async {
    setState(() => _loading = true);
    await ref.read(authProvider.notifier).signUp(
      _emailController.text.trim(),
      _passwordController.text.trim(),
    );
    if (mounted) setState(() => _loading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ClinIQ - Create Account')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Email input field
            TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
            const SizedBox(height: 16),
            // Password input field
            TextField(controller: _passwordController, decoration: const InputDecoration(labelText: 'Password'), obscureText: true),
            const SizedBox(height: 24),
            // Sign-up button with loading indicator
            _loading
                ? const CircularProgressIndicator()
                : ElevatedButton(onPressed: _signUp, child: const Text('Sign Up')),
            // Link back to the sign-in screen
            TextButton(onPressed: () => context.go('/login'), child: const Text('Already have an account?')),
          ],
        ),
      ),
    );
  }
}

What does this code do?

SignupScreen follows the same pattern as LoginScreen. The key difference is that _signUp() calls authProvider.notifier.signUp, which creates the Supabase auth account AND inserts a row into the users table.

  • Save signup_screen.dart.

Seeing import errors in login_screen.dart or signup_screen.dart?

  • Make sure auth_provider.dart is saved inside lib/features/auth/providers/. The relative import path depends on the exact folder structure.
  • Check that you have the go_router package in your pubspec.yaml and ran flutter pub get in the previous step.

Why are my auth screen imports not resolving?

Set up GoRouter with redirect guards

Right now, nothing prevents an unauthenticated user from accessing app screens. GoRouter provides a declarative routing system with built-in redirect logic. You'll define routes for the home placeholder, login, and signup, then wire the router into main.dart.

  • Create the file lib/core/router/app_router.dart and add the following code:
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import '../../features/auth/providers/auth_provider.dart';
import '../../features/auth/screens/login_screen.dart';
import '../../features/auth/screens/signup_screen.dart';

final _routerKey = GlobalKey<NavigatorState>();

final GoRouter appRouter = GoRouter(
  navigatorKey: _routerKey,
  initialLocation: '/',
  redirect: (context, state) {
    // Auth guard handled via refreshListenable or manual check
    return null;
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const Scaffold(body: Center(child: Text('Home - Coming Soon'))),
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => const LoginScreen(),
    ),
    GoRoute(
      path: '/signup',
      builder: (context, state) => const SignupScreen(),
    ),
  ],
);

What does this code do?

  • appRouter defines three routes: / (a home placeholder you'll build later), /login, and /signup.
  • The redirect callback is the auth guard hook. It currently returns null (no redirect), but the auth state provider is imported and ready for you to add redirect logic as the app grows.
  • _routerKey gives GoRouter its own navigator so it doesn't conflict with Flutter's default navigation stack.
  • Save app_router.dart.

Finally, update main.dart to use the new router instead of a static home widget.

  • Open lib/main.dart and replace its entire contents with the following:
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'core/router/app_router.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');
  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
  runApp(const ProviderScope(child: ClinIQApp()));
}

class ClinIQApp extends StatelessWidget {
  const ClinIQApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'ClinIQ',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      // Use GoRouter for declarative routing
      routerConfig: appRouter,
    );
  }
}

What changed in main.dart?

  • MaterialApp becomes MaterialApp.router. This tells Flutter to delegate all navigation to GoRouter instead of using the default navigator.
  • The home property is removed and replaced by routerConfig: appRouter. All routes are now defined centrally in app_router.dart.
  • Save main.dart.
  • Run your app with flutter run. You should see the "Home - Coming Soon" text on launch.
  • In your browser or emulator, navigate to /login by typing the route manually or tapping the back button and entering the URL. You should see the sign-in form with email and password fields.

App crashes on launch or shows a blank screen?

  • Check the debug console for import errors. If you see "Target of URI doesn't exist", double-check that app_router.dart is saved in lib/core/router/ (not lib/core/ directly).
  • Make sure your main.dart import path is core/router/app_router.dart with no leading lib/.
  • If you see a Supabase initialization error, verify your .env values are correct and that the file is listed under assets: in pubspec.yaml.

Help me debug my ClinIQ app launch error.

✔️ Awesome, I've got everything!

Great. Double check you've saved all four new files and the updated main.dart.

ⓧ I'd like to double check the full code

Here are all the files you should have at this point.

class AppUser {
  final String id;
  final String email;

  const AppUser({required this.id, required this.email});

  factory AppUser.fromMap(Map<String, dynamic> map) {
    return AppUser(
      id: map['id'] as String,
      email: map['email'] as String,
    );
  }
}
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/app_user.dart';

final authProvider = StateNotifierProvider<AuthNotifier, AsyncValue<AppUser?>>(
  (ref) => AuthNotifier(),
);

final authStateProvider = Provider<AppUser?>((ref) {
  return ref.watch(authProvider).valueOrNull;
});

class AuthNotifier extends StateNotifier<AsyncValue<AppUser?>> {
  AuthNotifier() : super(const AsyncValue.loading()) {
    _init();
  }

  final _client = Supabase.instance.client;

  void _init() {
    final session = _client.auth.currentSession;
    if (session != null) {
      state = AsyncValue.data(AppUser(id: session.user.id, email: session.user.email ?? ''));
    } else {
      state = const AsyncValue.data(null);
    }
    _client.auth.onAuthStateChange.listen((data) {
      final user = data.session?.user;
      if (user != null) {
        state = AsyncValue.data(AppUser(id: user.id, email: user.email ?? ''));
      } else {
        state = const AsyncValue.data(null);
      }
    });
  }

  Future<void> signIn(String email, String password) async {
    state = const AsyncValue.loading();
    try {
      await _client.auth.signInWithPassword(email: email, password: password);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  Future<void> signUp(String email, String password) async {
    state = const AsyncValue.loading();
    try {
      final response = await _client.auth.signUp(email: email, password: password);
      if (response.user != null) {
        await _client.from('users').insert({'id': response.user!.id, 'email': email});
      }
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  Future<void> signOut() async {
    await _client.auth.signOut();
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';

class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  @override
  ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _loading = false;

  Future<void> _signIn() async {
    setState(() => _loading = true);
    await ref.read(authProvider.notifier).signIn(
      _emailController.text.trim(),
      _passwordController.text.trim(),
    );
    if (mounted) setState(() => _loading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ClinIQ - Sign In')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
            const SizedBox(height: 16),
            TextField(controller: _passwordController, decoration: const InputDecoration(labelText: 'Password'), obscureText: true),
            const SizedBox(height: 24),
            _loading
                ? const CircularProgressIndicator()
                : ElevatedButton(onPressed: _signIn, child: const Text('Sign In')),
            TextButton(onPressed: () => context.go('/signup'), child: const Text('Create an account')),
          ],
        ),
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';

class SignupScreen extends ConsumerStatefulWidget {
  const SignupScreen({super.key});

  @override
  ConsumerState<SignupScreen> createState() => _SignupScreenState();
}

class _SignupScreenState extends ConsumerState<SignupScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _loading = false;

  Future<void> _signUp() async {
    setState(() => _loading = true);
    await ref.read(authProvider.notifier).signUp(
      _emailController.text.trim(),
      _passwordController.text.trim(),
    );
    if (mounted) setState(() => _loading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ClinIQ - Create Account')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
            const SizedBox(height: 16),
            TextField(controller: _passwordController, decoration: const InputDecoration(labelText: 'Password'), obscureText: true),
            const SizedBox(height: 24),
            _loading
                ? const CircularProgressIndicator()
                : ElevatedButton(onPressed: _signUp, child: const Text('Sign Up')),
            TextButton(onPressed: () => context.go('/login'), child: const Text('Already have an account?')),
          ],
        ),
      ),
    );
  }
}
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import '../../features/auth/providers/auth_provider.dart';
import '../../features/auth/screens/login_screen.dart';
import '../../features/auth/screens/signup_screen.dart';

final _routerKey = GlobalKey<NavigatorState>();

final GoRouter appRouter = GoRouter(
  navigatorKey: _routerKey,
  initialLocation: '/',
  redirect: (context, state) {
    // Auth guard handled via refreshListenable or manual check
    return null;
  },
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const Scaffold(body: Center(child: Text('Home - Coming Soon'))),
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => const LoginScreen(),
    ),
    GoRoute(
      path: '/signup',
      builder: (context, state) => const SignupScreen(),
    ),
  ],
);
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'core/router/app_router.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');
  await Supabase.initialize(
    url: dotenv.env['SUPABASE_URL']!,
    anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
  );
  runApp(const ProviderScope(child: ClinIQApp()));
}

class ClinIQApp extends StatelessWidget {
  const ClinIQApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'ClinIQ',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      routerConfig: appRouter,
    );
  }
}

Your authentication flow is complete. Users can create accounts, sign in, and navigate between auth screens. Next up, you'll build the AI service layer that powers the patient conversation engine.

Build the AI Service Layer

Your app can authenticate users, but it cannot yet talk to an AI model. The core of ClinIQ is a conversational AI that simulates patients, tracks diagnoses, and evaluates clinical reasoning.

In this step, you will build the AI service layer that connects your app to Gemini. You will define strongly-typed Dart models for every kind of response Gemini returns, then create a service class that handles sending messages, streaming responses token-by-token, updating differential diagnoses, and evaluating completed cases.

In this step, get ready to:
  • Define Dart data models for structured JSON responses from Gemini.
  • Create the GeminiService class with a patient simulation system prompt.
  • Add streaming, diagnosis tracking, and case evaluation methods.
Define the structured response models

Gemini returns free-form text by default. To work with its output programmatically, you will instruct Gemini to respond in JSON and parse that JSON into strongly-typed Dart classes.

You need three response models: one for patient messages, one for differential diagnosis updates, and one for end-of-case evaluations.

  • Create the file lib/core/models/patient_response.dart and add the following code:
import 'dart:convert';

class PatientResponse {
  final String message;
  final String emotion;
  final Map<String, String>? vitalSigns;

  const PatientResponse({
    required this.message,
    required this.emotion,
    this.vitalSigns,
  });

  factory PatientResponse.fromJson(Map<String, dynamic> json) {
    return PatientResponse(
      message: json['message'] as String,
      emotion: json['emotion'] as String? ?? 'neutral',
      vitalSigns: json['vital_signs'] != null
          ? Map<String, String>.from(json['vital_signs'] as Map)
          : null,
    );
  }

  factory PatientResponse.fromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    return PatientResponse.fromJson(jsonDecode(cleaned) as Map<String, dynamic>);
  }
}

What does this code do?

PatientResponse holds the simulated patient's reply: their spoken message, their current emotion (calm, anxious, distressed, etc.), and optionally their vitalSigns when clinically relevant.

The fromRawJson factory strips any markdown code fences that Gemini sometimes wraps around its JSON output, then parses the cleaned string. This defensive parsing prevents crashes when the model adds formatting around its structured response.

  • Save patient_response.dart.
  • You will not see a visible change yet. This model is used in the next chunk.
  • Create the file lib/core/models/diagnosis_update.dart and add the following code:
import 'dart:convert';

class DifferentialDiagnosis {
  final String name;
  final double confidence;

  const DifferentialDiagnosis({required this.name, required this.confidence});

  factory DifferentialDiagnosis.fromJson(Map<String, dynamic> json) {
    return DifferentialDiagnosis(
      name: json['name'] as String,
      confidence: (json['confidence'] as num).toDouble(),
    );
  }
}

class DiagnosisUpdate {
  final List<DifferentialDiagnosis> differentials;

  const DiagnosisUpdate({required this.differentials});

  factory DiagnosisUpdate.fromJson(Map<String, dynamic> json) {
    final list = json['differentials'] as List<dynamic>;
    return DiagnosisUpdate(
      differentials: list.map((e) => DifferentialDiagnosis.fromJson(e as Map<String, dynamic>)).toList(),
    );
  }

  factory DiagnosisUpdate.fromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    return DiagnosisUpdate.fromJson(jsonDecode(cleaned) as Map<String, dynamic>);
  }
}

What does this code do?

This file defines two classes. DifferentialDiagnosis represents a single possible diagnosis with a name and confidence percentage (0.0 to 1.0).

DiagnosisUpdate wraps a list of DifferentialDiagnosis items. The AI will return the top 5 differential diagnoses as the student asks more questions, and this model parses that ranked list.

  • Save diagnosis_update.dart.
  • You will not see a visible change yet. This model is used later by the GeminiService.
  • Create the file lib/core/models/case_evaluation.dart and add the following code:
import 'dart:convert';

class CaseEvaluation {
  final int score;
  final List<String> missedQuestions;
  final List<String> correctReasoningSteps;
  final List<String> suggestedImprovements;

  const CaseEvaluation({
    required this.score,
    required this.missedQuestions,
    required this.correctReasoningSteps,
    required this.suggestedImprovements,
  });

  factory CaseEvaluation.fromJson(Map<String, dynamic> json) {
    return CaseEvaluation(
      score: json['score'] as int,
      missedQuestions: List<String>.from(json['missed_questions'] as List),
      correctReasoningSteps: List<String>.from(json['correct_reasoning_steps'] as List),
      suggestedImprovements: List<String>.from(json['suggested_improvements'] as List),
    );
  }

  factory CaseEvaluation.fromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    return CaseEvaluation.fromJson(jsonDecode(cleaned) as Map<String, dynamic>);
  }
}

What does this code do?

CaseEvaluation holds Gemini's assessment of a completed patient encounter. It includes a numeric score (0-100), a list of missedQuestions the student should have asked, the correctReasoningSteps they demonstrated, and suggestedImprovements for next time.

  • Save case_evaluation.dart.
  • Confirm all three model files exist by running:
ls lib/core/models/

You should see patient_response.dart, diagnosis_update.dart, and case_evaluation.dart in the output.

Don't see all three files?

Make sure you created them inside lib/core/models/, not at the project root. If the models folder does not exist, create it first with mkdir -p lib/core/models and then add the files.

help me find where my model files should go in the project structure

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is patient_response.dart:

import 'dart:convert';

class PatientResponse {
  final String message;
  final String emotion;
  final Map<String, String>? vitalSigns;

  const PatientResponse({
    required this.message,
    required this.emotion,
    this.vitalSigns,
  });

  factory PatientResponse.fromJson(Map<String, dynamic> json) {
    return PatientResponse(
      message: json['message'] as String,
      emotion: json['emotion'] as String? ?? 'neutral',
      vitalSigns: json['vital_signs'] != null
          ? Map<String, String>.from(json['vital_signs'] as Map)
          : null,
    );
  }

  factory PatientResponse.fromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    return PatientResponse.fromJson(jsonDecode(cleaned) as Map<String, dynamic>);
  }
}

Here is diagnosis_update.dart:

import 'dart:convert';

class DifferentialDiagnosis {
  final String name;
  final double confidence;

  const DifferentialDiagnosis({required this.name, required this.confidence});

  factory DifferentialDiagnosis.fromJson(Map<String, dynamic> json) {
    return DifferentialDiagnosis(
      name: json['name'] as String,
      confidence: (json['confidence'] as num).toDouble(),
    );
  }
}

class DiagnosisUpdate {
  final List<DifferentialDiagnosis> differentials;

  const DiagnosisUpdate({required this.differentials});

  factory DiagnosisUpdate.fromJson(Map<String, dynamic> json) {
    final list = json['differentials'] as List<dynamic>;
    return DiagnosisUpdate(
      differentials: list.map((e) => DifferentialDiagnosis.fromJson(e as Map<String, dynamic>)).toList(),
    );
  }

  factory DiagnosisUpdate.fromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    return DiagnosisUpdate.fromJson(jsonDecode(cleaned) as Map<String, dynamic>);
  }
}

Here is case_evaluation.dart:

import 'dart:convert';

class CaseEvaluation {
  final int score;
  final List<String> missedQuestions;
  final List<String> correctReasoningSteps;
  final List<String> suggestedImprovements;

  const CaseEvaluation({
    required this.score,
    required this.missedQuestions,
    required this.correctReasoningSteps,
    required this.suggestedImprovements,
  });

  factory CaseEvaluation.fromJson(Map<String, dynamic> json) {
    return CaseEvaluation(
      score: json['score'] as int,
      missedQuestions: List<String>.from(json['missed_questions'] as List),
      correctReasoningSteps: List<String>.from(json['correct_reasoning_steps'] as List),
      suggestedImprovements: List<String>.from(json['suggested_improvements'] as List),
    );
  }

  factory CaseEvaluation.fromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    return CaseEvaluation.fromJson(jsonDecode(cleaned) as Map<String, dynamic>);
  }
}
Create the GeminiService class

The service class is the single point of contact between your Flutter app and the Gemini API. It initializes a GenerativeModel with a system prompt that tells Gemini how to behave as a simulated patient.

  • Create the file lib/core/services/gemini_service.dart and add the following code:
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../models/patient_response.dart';
import '../models/diagnosis_update.dart';
import '../models/case_evaluation.dart';

const _patientSystemPrompt = '''
You are a simulated patient in a medical education platform. Respond as a realistic patient would.
Always respond with valid JSON in this format:
{
  "message": "your response as the patient",
  "emotion": "one of: calm, anxious, distressed, confused, relieved",
  "vital_signs": {"bp": "120/80", "hr": "72", "temp": "37.0"} // only include when clinically relevant
}
Stay in character. Do not reveal diagnoses. Respond naturally to the student doctor's questions.
''';

// Riverpod provider for dependency injection
final geminiServiceProvider = Provider<GeminiService>((ref) => GeminiService());

class GeminiService {
  late final GenerativeModel _model;

  GeminiService() {
    _model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
      systemInstruction: Content.system(_patientSystemPrompt),
    );
  }

  // Send a message and get a structured patient response
  Future<PatientResponse> sendMessage(List<Content> history, String userMessage) async {
    final chat = _model.startChat(history: history);
    final response = await chat.sendMessage(Content.text(userMessage));
    final text = response.text ?? '{"message": "I\'m not sure how to respond.", "emotion": "confused"}';
    return PatientResponse.fromRawJson(text);
  }
}

What does this code do?

The _patientSystemPrompt tells Gemini to always respond as a patient character and to format every reply as structured JSON. This prompt is attached as a system instruction, so it applies to every conversation without consuming message turns.

The sendMessage method creates a chat session from existing conversation history, sends the student's new question, and parses the response into a PatientResponse object. If Gemini returns nothing, a safe fallback JSON string is used.

The geminiServiceProvider exposes GeminiService through Riverpod so any widget or provider in the app can access it via dependency injection.

  • Save gemini_service.dart.
  • You will not see a UI change yet. The next chunk adds the remaining methods that complete the service.

Seeing an import error on google_generative_ai?

Make sure you ran flutter pub get after adding the google_generative_ai dependency in the previous step. If your IDE still underlines the import, restart the Dart analysis server.

help me fix import errors for google_generative_ai in my Flutter project

Add streaming and evaluation methods

A full patient encounter needs more than basic message sending. You need streaming so responses appear token-by-token (creating a realistic typing effect), a method to generate differential diagnoses from the conversation, and a method to score the student's final performance.

  • In lib/core/services/gemini_service.dart, find the closing brace of the sendMessage method, then add the following methods inside the GeminiService class below it:
  // Stream AI response token-by-token for a typing effect
  Stream<String> streamMessage(List<Content> history, String userMessage) async* {
    final chat = _model.startChat(history: history);
    final stream = chat.sendMessageStream(Content.text(userMessage));
    await for (final chunk in stream) {
      if (chunk.text != null) yield chunk.text!;
    }
  }

  // Get updated differential diagnoses based on the conversation so far
  Future<DiagnosisUpdate> getDiagnosisUpdate(List<Content> history) async {
    final diagModel = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    final prompt = '''
Based on this patient encounter conversation, return the top 5 differential diagnoses.
Respond ONLY with valid JSON:
{
  "differentials": [
    {"name": "Diagnosis Name", "confidence": 0.85},
    ...
  ]
}
Conversation: ${history.map((c) => c.parts.map((p) => (p as TextPart).text).join()).join('\n')}
''';
    final response = await diagModel.generateContent([Content.text(prompt)]);
    return DiagnosisUpdate.fromRawJson(response.text ?? '{"differentials": []}');
  }

  // Evaluate the student's clinical reasoning after they submit a diagnosis
  Future<CaseEvaluation> evaluateCase(
    List<Content> history,
    String finalDiagnosis,
    String treatmentPlan,
  ) async {
    final evalModel = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    final prompt = '''
Evaluate this medical student's clinical reasoning. They diagnosed: $finalDiagnosis. Treatment plan: $treatmentPlan.
Conversation history: ${history.map((c) => c.parts.map((p) => (p as TextPart).text).join()).join('\n')}
Respond ONLY with valid JSON:
{
  "score": 75,
  "missed_questions": ["Should have asked about family history"],
  "correct_reasoning_steps": ["Correctly identified chest pain characteristics"],
  "suggested_improvements": ["Consider ordering ECG earlier"]
}
''';
    final response = await evalModel.generateContent([Content.text(prompt)]);
    return CaseEvaluation.fromRawJson(response.text ?? '{"score": 0, "missed_questions": [], "correct_reasoning_steps": [], "suggested_improvements": []}');
  }

What do these methods do?

streamMessage uses sendMessageStream to yield text chunks as they arrive from Gemini. This creates the "typing" effect in your chat UI where the patient's response builds up character by character.

getDiagnosisUpdate creates a separate model instance (without the patient system prompt) and asks Gemini to analyze the conversation as a medical expert, returning ranked differential diagnoses with confidence scores.

evaluateCase sends the full conversation plus the student's final diagnosis and treatment plan to Gemini for a structured evaluation. The response includes a score, missed questions, correct reasoning steps, and improvement suggestions.

Why separate GenerativeModel instances?

The sendMessage and streamMessage methods use the main _model which has the patient simulation system prompt attached. The diagnosis and evaluation methods need Gemini to respond as a medical expert, not as a patient. Creating fresh GenerativeModel instances without a system prompt lets them operate in a neutral analysis mode.

  • Save gemini_service.dart.
  • Run flutter analyze to confirm there are no type errors or missing imports:
flutter analyze

You should see no issues reported. If the analysis passes, your AI service layer is structurally sound.

Seeing type errors in the analysis?

Check that your imports at the top of gemini_service.dart include all three model files: patient_response.dart, diagnosis_update.dart, and case_evaluation.dart.

Also verify you pasted the new methods inside the GeminiService class braces, not below them. The class closing brace should come after evaluateCase.

help me debug type errors in my GeminiService class

✔️ Awesome, I've got everything!

Great. Double check you have saved gemini_service.dart with all four methods.

ⓧ I'd like to double check the full code

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../models/patient_response.dart';
import '../models/diagnosis_update.dart';
import '../models/case_evaluation.dart';

const _patientSystemPrompt = '''
You are a simulated patient in a medical education platform. Respond as a realistic patient would.
Always respond with valid JSON in this format:
{
  "message": "your response as the patient",
  "emotion": "one of: calm, anxious, distressed, confused, relieved",
  "vital_signs": {"bp": "120/80", "hr": "72", "temp": "37.0"} // only include when clinically relevant
}
Stay in character. Do not reveal diagnoses. Respond naturally to the student doctor's questions.
''';

final geminiServiceProvider = Provider<GeminiService>((ref) => GeminiService());

class GeminiService {
  late final GenerativeModel _model;

  GeminiService() {
    _model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
      systemInstruction: Content.system(_patientSystemPrompt),
    );
  }

  Future<PatientResponse> sendMessage(List<Content> history, String userMessage) async {
    final chat = _model.startChat(history: history);
    final response = await chat.sendMessage(Content.text(userMessage));
    final text = response.text ?? '{"message": "I\'m not sure how to respond.", "emotion": "confused"}';
    return PatientResponse.fromRawJson(text);
  }

  Stream<String> streamMessage(List<Content> history, String userMessage) async* {
    final chat = _model.startChat(history: history);
    final stream = chat.sendMessageStream(Content.text(userMessage));
    await for (final chunk in stream) {
      if (chunk.text != null) yield chunk.text!;
    }
  }

  Future<DiagnosisUpdate> getDiagnosisUpdate(List<Content> history) async {
    final diagModel = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    final prompt = '''
Based on this patient encounter conversation, return the top 5 differential diagnoses.
Respond ONLY with valid JSON:
{
  "differentials": [
    {"name": "Diagnosis Name", "confidence": 0.85},
    ...
  ]
}
Conversation: ${history.map((c) => c.parts.map((p) => (p as TextPart).text).join()).join('\n')}
''';
    final response = await diagModel.generateContent([Content.text(prompt)]);
    return DiagnosisUpdate.fromRawJson(response.text ?? '{"differentials": []}');
  }

  Future<CaseEvaluation> evaluateCase(
    List<Content> history,
    String finalDiagnosis,
    String treatmentPlan,
  ) async {
    final evalModel = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    final prompt = '''
Evaluate this medical student's clinical reasoning. They diagnosed: $finalDiagnosis. Treatment plan: $treatmentPlan.
Conversation history: ${history.map((c) => c.parts.map((p) => (p as TextPart).text).join()).join('\n')}
Respond ONLY with valid JSON:
{
  "score": 75,
  "missed_questions": ["Should have asked about family history"],
  "correct_reasoning_steps": ["Correctly identified chest pain characteristics"],
  "suggested_improvements": ["Consider ordering ECG earlier"]
}
''';
    final response = await evalModel.generateContent([Content.text(prompt)]);
    return CaseEvaluation.fromRawJson(response.text ?? '{"score": 0, "missed_questions": [], "correct_reasoning_steps": [], "suggested_improvements": []}');
  }
}

Your AI service layer is complete. Next up, you will build the patient encounter engine that uses this service to power a full multi-turn conversation between the student and a simulated patient.

Build the Patient Encounter Engine

The core of ClinIQ is the patient encounter. This is where the student asks questions, the AI patient responds with realistic symptoms and emotions, and the conversation flows through clinical stages just like a real appointment.

In this step, you will build the encounter engine that powers this interaction. You will define a state machine that tracks the clinical stages, create a Riverpod provider that manages conversation context, and build a chat UI where the student types questions and sees streaming AI responses.

In this step, get ready to:
  • Build the case state machine with stages flowing from History Taking to Diagnosis to Treatment.
  • Implement multi-turn conversation context so the AI patient remembers previous answers.
  • Create a chat UI with message bubbles, streaming responses, and a stage progress indicator.
Define the encounter state machine

A clinical encounter follows a structured progression. The doctor takes a history, performs an examination, orders investigations, forms a diagnosis, and prescribes treatment. Your state machine encodes these stages as an enum so the app always knows where the student is in the process.

  • Create the folder structure for the patient encounter feature by running these commands:
mkdir -p lib/features/patient_encounter/models
mkdir -p lib/features/patient_encounter/providers
mkdir -p lib/features/patient_encounter/screens
  • Create a new file lib/features/patient_encounter/models/chat_message.dart and add this code:
class ChatMessage {
  final String role;
  final String content;
  final DateTime timestamp;

  const ChatMessage({
    required this.role,
    required this.content,
    required this.timestamp,
  });
}

What does this code do?

ChatMessage represents a single message in the patient-student conversation. The role field is either 'user' (the student) or 'patient' (the AI). This distinction drives how message bubbles are styled in the chat UI.

  • Save chat_message.dart.
  • Now create lib/features/patient_encounter/models/encounter_state.dart and add this code:
import 'package:google_generative_ai/google_generative_ai.dart';
import 'chat_message.dart';

// The clinical encounter stages the student progresses through
enum EncounterStage {
  historyTaking,
  examination,
  investigations,
  diagnosis,
  treatment,
  completed,
}

// Holds the full state of an active encounter
class EncounterState {
  final EncounterStage stage;
  final List<ChatMessage> messages;
  final List<Content> conversationHistory;
  final bool isLoading;
  final String streamingText;

  const EncounterState({
    this.stage = EncounterStage.historyTaking,
    this.messages = const [],
    this.conversationHistory = const [],
    this.isLoading = false,
    this.streamingText = '',
  });

  // Creates a copy with only the specified fields changed
  EncounterState copyWith({
    EncounterStage? stage,
    List<ChatMessage>? messages,
    List<Content>? conversationHistory,
    bool? isLoading,
    String? streamingText,
  }) {
    return EncounterState(
      stage: stage ?? this.stage,
      messages: messages ?? this.messages,
      conversationHistory: conversationHistory ?? this.conversationHistory,
      isLoading: isLoading ?? this.isLoading,
      streamingText: streamingText ?? this.streamingText,
    );
  }
}

What does this code do?

EncounterStage is the enum that defines the six clinical stages. The student starts at historyTaking and progresses forward through the encounter.

EncounterState holds everything the UI needs to render: the current stage, visible chat messages, the full conversation history sent to Gemini, and whether a streaming response is in progress. The copyWith method enables immutable state updates. Riverpod detects these new objects and rebuilds the UI automatically.

  • Save encounter_state.dart.

Why a state machine for clinical stages?

Real clinical encounters have a defined structure. Students need to learn this flow. By encoding stages as an enum with a linear progression, the app can guide the student, restrict actions that do not make sense at a given stage, and score how well they handled each phase.

Seeing an import error on Content?

The Content class comes from the google_generative_ai package. Make sure the import at the top of the file matches exactly: import 'package:google_generative_ai/google_generative_ai.dart';.

help me fix import errors in my encounter_state.dart file

Create the conversation provider

The provider is the brain of the encounter. It connects your state machine to the Gemini AI service, manages the full conversation history, and streams responses token-by-token so the student sees the patient "thinking" in real time.

  • Create lib/features/patient_encounter/providers/encounter_provider.dart and add this code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../models/encounter_state.dart';
import '../models/chat_message.dart';
import '../../../core/services/gemini_service.dart';

// Exposes the EncounterNotifier to the widget tree
final encounterProvider = StateNotifierProvider<EncounterNotifier, EncounterState>(
  (ref) => EncounterNotifier(ref.read(geminiServiceProvider)),
);

class EncounterNotifier extends StateNotifier<EncounterState> {
  final GeminiService _geminiService;

  EncounterNotifier(this._geminiService) : super(const EncounterState());

  // Sends the student's message to Gemini and streams the response
  Future<void> sendMessage(String text) async {
    if (text.trim().isEmpty) return;
    final userMessage = ChatMessage(role: 'user', content: text, timestamp: DateTime.now());
    state = state.copyWith(
      messages: [...state.messages, userMessage],
      isLoading: true,
      streamingText: '',
    );

    String fullResponse = '';
    try {
      await for (final chunk in _geminiService.streamMessage(state.conversationHistory, text)) {
        fullResponse += chunk;
        state = state.copyWith(streamingText: fullResponse, isLoading: false);
      }
      final patientMessage = ChatMessage(role: 'patient', content: fullResponse, timestamp: DateTime.now());
      // Append both user and model messages to the history for context
      final newHistory = [
        ...state.conversationHistory,
        Content.text(text),
        Content('model', [TextPart(fullResponse)]),
      ];
      state = state.copyWith(
        messages: [...state.messages, patientMessage],
        conversationHistory: newHistory,
        streamingText: '',
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(isLoading: false, streamingText: '');
    }
  }

  // Moves the encounter to the next clinical stage
  void advanceStage() {
    final stages = EncounterStage.values;
    final currentIndex = stages.indexOf(state.stage);
    if (currentIndex < stages.length - 1) {
      state = state.copyWith(stage: stages[currentIndex + 1]);
    }
  }

  // Resets the encounter back to the initial state
  void resetEncounter() {
    state = const EncounterState();
  }
}

What does this code do?

sendMessage is where multi-turn context happens. Each time the student sends a message, the method streams the AI response chunk-by-chunk (so it appears letter by letter in the UI), then appends BOTH the user message and the AI response to conversationHistory. This list gets passed to Gemini on the next call, so the patient remembers everything said before.

advanceStage moves through the enum values sequentially. resetEncounter returns to the initial state so the student can start a new case.

  • Save encounter_provider.dart.

Why stream instead of waiting for the full response?

Gemini can take a few seconds to generate a full response. Streaming shows text appearing in real time, which feels natural in a chat interface. It also gives the student feedback that the AI is working. Without streaming, the UI would freeze with a loading spinner until the entire response arrives.

Red underlines on GeminiService imports?

Check that your relative import path matches your folder structure exactly. The import ../../../core/services/gemini_service.dart goes up three levels from providers/ to lib/, then down into core/services/.

help me fix my import path for gemini_service.dart

Build the chat UI

The encounter screen is where the student interacts with the simulated patient. It displays a stage progress indicator at the top, message bubbles in the center, and a text input at the bottom.

  • Create lib/features/patient_encounter/screens/encounter_screen.dart and start with the main screen widget:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/encounter_provider.dart';
import '../models/encounter_state.dart';

class EncounterScreen extends ConsumerStatefulWidget {
  const EncounterScreen({super.key});

  @override
  ConsumerState<EncounterScreen> createState() => _EncounterScreenState();
}

class _EncounterScreenState extends ConsumerState<EncounterScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();

  // Auto-scrolls to the newest message
  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(encounterProvider);
    ref.listen(encounterProvider, (_, __) => _scrollToBottom());

    return Scaffold(
      appBar: AppBar(
        title: const Text('Patient Encounter'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(40),
          child: _StageIndicator(stage: state.stage),
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              itemCount: state.messages.length + (state.streamingText.isNotEmpty ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == state.messages.length && state.streamingText.isNotEmpty) {
                  return _MessageBubble(role: 'patient', content: state.streamingText, isStreaming: true);
                }
                final msg = state.messages[index];
                return _MessageBubble(role: msg.role, content: msg.content);
              },
            ),
          ),
          if (state.isLoading) const LinearProgressIndicator(),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'Ask the patient a question...',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (text) {
                      ref.read(encounterProvider.notifier).sendMessage(text);
                      _controller.clear();
                    },
                  ),
                ),
                const SizedBox(width: 8),
                IconButton(
                  icon: const Icon(Icons.send),
                  onPressed: () {
                    ref.read(encounterProvider.notifier).sendMessage(_controller.text);
                    _controller.clear();
                  },
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(bottom: 8),
            child: ElevatedButton(
              onPressed: () => ref.read(encounterProvider.notifier).advanceStage(),
              child: const Text('Advance to Next Stage'),
            ),
          ),
        ],
      ),
    );
  }
}

What does this code do?

EncounterScreen is a ConsumerStatefulWidget so it can watch the Riverpod provider for state changes. The ref.watch(encounterProvider) call rebuilds the UI whenever the encounter state changes.

The ListView.builder renders message bubbles efficiently. It adds an extra item at the end when streamingText is not empty, showing the response as it arrives from Gemini. The ref.listen call triggers auto-scrolling whenever new content appears.

  • Now add the two helper widgets below the EncounterScreen class in the same file. First, add the stage indicator:
// Shows which clinical stage the student is currently in
class _StageIndicator extends StatelessWidget {
  final EncounterStage stage;
  const _StageIndicator({required this.stage});

  @override
  Widget build(BuildContext context) {
    final labels = ['History', 'Exam', 'Investigations', 'Diagnosis', 'Treatment', 'Done'];
    final index = EncounterStage.values.indexOf(stage);
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: List.generate(labels.length, (i) => Text(
          labels[i],
          style: TextStyle(
            fontSize: 10,
            fontWeight: i == index ? FontWeight.bold : FontWeight.normal,
            color: i == index ? Colors.teal : Colors.grey,
          ),
        )),
      ),
    );
  }
}

What does this code do?

_StageIndicator renders a row of stage labels in the app bar. The current stage is highlighted in bold teal while the others are grey. This gives the student a constant visual reminder of where they are in the clinical workflow.

  • Add the message bubble widget below _StageIndicator in the same file:
// Renders a single chat bubble, styled differently for user vs patient
class _MessageBubble extends StatelessWidget {
  final String role;
  final String content;
  final bool isStreaming;
  const _MessageBubble({required this.role, required this.content, this.isStreaming = false});

  @override
  Widget build(BuildContext context) {
    final isUser = role == 'user';
    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.all(12),
        constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
        decoration: BoxDecoration(
          color: isUser ? Colors.teal : Colors.grey[200],
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text(
          content,
          style: TextStyle(color: isUser ? Colors.white : Colors.black87),
        ),
      ),
    );
  }
}

What does this code do?

_MessageBubble aligns user messages to the right (teal background) and patient messages to the left (grey background). The maxWidth constraint keeps bubbles from stretching across the full screen width, which makes the chat feel like a real messaging app.

  • Save encounter_screen.dart.

Seeing errors about undefined classes?

Make sure all four imports at the top of the file are present. The screen imports from both ../providers/encounter_provider.dart and ../models/encounter_state.dart. If you are missing either, the EncounterStage type and encounterProvider variable will show as undefined.

help me fix undefined class errors in my encounter screen

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files in the patient_encounter feature folder.

ⓧ I'd like to double check the full code

Here is the full encounter_screen.dart file for comparison:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/encounter_provider.dart';
import '../models/encounter_state.dart';

class EncounterScreen extends ConsumerStatefulWidget {
  const EncounterScreen({super.key});

  @override
  ConsumerState<EncounterScreen> createState() => _EncounterScreenState();
}

class _EncounterScreenState extends ConsumerState<EncounterScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(encounterProvider);
    ref.listen(encounterProvider, (_, __) => _scrollToBottom());

    return Scaffold(
      appBar: AppBar(
        title: const Text('Patient Encounter'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(40),
          child: _StageIndicator(stage: state.stage),
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              itemCount: state.messages.length + (state.streamingText.isNotEmpty ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == state.messages.length && state.streamingText.isNotEmpty) {
                  return _MessageBubble(role: 'patient', content: state.streamingText, isStreaming: true);
                }
                final msg = state.messages[index];
                return _MessageBubble(role: msg.role, content: msg.content);
              },
            ),
          ),
          if (state.isLoading) const LinearProgressIndicator(),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'Ask the patient a question...',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (text) {
                      ref.read(encounterProvider.notifier).sendMessage(text);
                      _controller.clear();
                    },
                  ),
                ),
                const SizedBox(width: 8),
                IconButton(
                  icon: const Icon(Icons.send),
                  onPressed: () {
                    ref.read(encounterProvider.notifier).sendMessage(_controller.text);
                    _controller.clear();
                  },
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(bottom: 8),
            child: ElevatedButton(
              onPressed: () => ref.read(encounterProvider.notifier).advanceStage(),
              child: const Text('Advance to Next Stage'),
            ),
          ),
        ],
      ),
    );
  }
}

class _StageIndicator extends StatelessWidget {
  final EncounterStage stage;
  const _StageIndicator({required this.stage});

  @override
  Widget build(BuildContext context) {
    final labels = ['History', 'Exam', 'Investigations', 'Diagnosis', 'Treatment', 'Done'];
    final index = EncounterStage.values.indexOf(stage);
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: List.generate(labels.length, (i) => Text(
          labels[i],
          style: TextStyle(
            fontSize: 10,
            fontWeight: i == index ? FontWeight.bold : FontWeight.normal,
            color: i == index ? Colors.teal : Colors.grey,
          ),
        )),
      ),
    );
  }
}

class _MessageBubble extends StatelessWidget {
  final String role;
  final String content;
  final bool isStreaming;
  const _MessageBubble({required this.role, required this.content, this.isStreaming = false});

  @override
  Widget build(BuildContext context) {
    final isUser = role == 'user';
    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.all(12),
        constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
        decoration: BoxDecoration(
          color: isUser ? Colors.teal : Colors.grey[200],
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text(
          content,
          style: TextStyle(color: isUser ? Colors.white : Colors.black87),
        ),
      ),
    );
  }
}
Wire up the encounter route and test

The screen exists, but there is no way to navigate to it yet. You need to register the encounter route in your router and give the user a button to start a case.

  • In lib/core/router/app_router.dart, add a new GoRoute with the path '/encounter' that builds an EncounterScreen() widget.
  • Add the import for encounter_screen.dart at the top of your router file.
  • On your home screen (the widget that renders at the '/' route), add an ElevatedButton with the text 'Start Case'.
  • In the button's onPressed callback, navigate to the encounter screen using context.go('/encounter').
  • Save both files.
  • Run your app by running:
flutter run
  • Tap Start Case on the home screen.
  • You should see the Patient Encounter screen with a stage indicator showing History highlighted in teal, an empty chat area, and a text input field at the bottom.
  • Type a question like What brings you in today? and tap the send button.
  • You should see your message appear as a teal bubble on the right, followed by the patient's response streaming in as a grey bubble on the left.
  • Ask a follow-up question. The patient should respond with context from the first answer, proving that multi-turn conversation history is working.

How does multi-turn context work?

Every time the student sends a message, the provider appends both the user text and the AI response to conversationHistory. On the next message, this full history is passed to Gemini via streamMessage. Gemini reads the entire conversation and responds in context.

This is what makes the patient feel realistic. If the student asks about chest pain, then follows up with "how long has it been going on?", the patient knows the student is still talking about the chest pain. Without this history, every message would be treated as a brand new conversation.

Not getting a response from the patient?

Check that your GEMINI_API_KEY in the .env file is valid and has not expired. Also confirm your device or emulator has internet access. If you see an error in the debug console mentioning the API key, regenerate it from Google AI Studio.

help me debug why my Gemini API calls are failing in my Flutter app

Your encounter engine is live. The student can now have a realistic conversation with the AI patient and move through clinical stages. Next up, you will build the differential diagnosis tracker that updates in real time as the student asks questions.

Add the Differential Diagnosis Tracker

Your patient encounter engine can simulate a conversation, but right now the student has no feedback on how their questions are shaping the clinical picture.

In this step, you will build a differential diagnosis tracker that analyzes the conversation in real-time. After each question the student asks, Gemini evaluates the conversation history and returns the top-5 most likely diagnoses with confidence percentages. The tracker updates live as the encounter progresses.

In this step, get ready to:
  • Create a diagnosis provider that calls Gemini to analyze the conversation.
  • Build a color-coded panel widget that displays the differential diagnosis list.
  • Integrate the tracker into the encounter screen with automatic refresh.
Create the diagnosis provider

The diagnosis provider is a Riverpod StateNotifier that holds the current differential diagnosis state. When the student asks a question, this provider sends the full conversation history to Gemini and receives an updated set of diagnoses.

  • Create a new file at lib/features/patient_encounter/providers/diagnosis_provider.dart and add the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../../../core/models/diagnosis_update.dart';
import '../../../core/services/gemini_service.dart';

final diagnosisProvider = StateNotifierProvider<DiagnosisNotifier, DiagnosisUpdate>(
  (ref) => DiagnosisNotifier(ref.read(geminiServiceProvider)),
);

class DiagnosisNotifier extends StateNotifier<DiagnosisUpdate> {
  final GeminiService _geminiService;

  DiagnosisNotifier(this._geminiService) : super(const DiagnosisUpdate(differentials: []));

  // Sends the conversation history to Gemini and updates the differential list
  Future<void> refresh(List<Content> history) async {
    if (history.isEmpty) return;
    try {
      final update = await _geminiService.getDiagnosisUpdate(history);
      state = update;
    } catch (_) {}
  }
}

What does this code do?

The diagnosisProvider is a global provider that any widget can watch. It exposes a DiagnosisUpdate object containing the list of differential diagnoses.

The refresh method takes the full conversation history and passes it to getDiagnosisUpdate. The empty-history guard prevents unnecessary API calls when no conversation has started yet.

  • Save diagnosis_provider.dart.

Seeing import errors?

Make sure you have your DiagnosisUpdate model defined at lib/core/models/diagnosis_update.dart with a differentials list property. Also confirm that geminiServiceProvider is exported from lib/core/services/gemini_service.dart.

Help me fix my diagnosis_provider imports

Build the diagnosis panel widget

The panel needs to communicate confidence at a glance. You will use color-coded animated containers: green for high confidence, orange for moderate, and red for low probability diagnoses.

  • Create a new file at lib/features/patient_encounter/widgets/diagnosis_panel.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/diagnosis_provider.dart';

class DiagnosisPanelWidget extends ConsumerWidget {
  const DiagnosisPanelWidget({super.key});

  // Maps confidence value to a traffic-light color
  Color _confidenceColor(double confidence) {
    if (confidence >= 0.7) return Colors.green;
    if (confidence >= 0.4) return Colors.orange;
    return Colors.red;
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final diagnosis = ref.watch(diagnosisProvider);
    if (diagnosis.differentials.isEmpty) {
      return const Padding(
        padding: EdgeInsets.all(16),
        child: Text('No differentials yet. Start asking questions.', style: TextStyle(color: Colors.grey)),
      );
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Padding(
          padding: EdgeInsets.all(12),
          child: Text('Differential Diagnoses', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
        ),
        ...diagnosis.differentials.map((d) => AnimatedContainer(
          duration: const Duration(milliseconds: 400),
          margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
          padding: const EdgeInsets.all(10),
          decoration: BoxDecoration(
            color: _confidenceColor(d.confidence).withOpacity(0.15),
            border: Border.all(color: _confidenceColor(d.confidence)),
            borderRadius: BorderRadius.circular(8),
          ),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Expanded(child: Text(d.name, style: const TextStyle(fontWeight: FontWeight.w500))),
              Text('${(d.confidence * 100).toStringAsFixed(0)}%',
                  style: TextStyle(color: _confidenceColor(d.confidence), fontWeight: FontWeight.bold)),
            ],
          ),
        )),
      ],
    );
  }
}

What does this code do?

  • _confidenceColor maps the confidence value to green (>= 0.7), orange (>= 0.4), or red (< 0.4). This gives instant visual feedback on how likely each diagnosis is.
  • AnimatedContainer animates transitions over 400ms. When confidence values change between refreshes, the containers smoothly shift colors instead of snapping.
  • The empty-state message guides the learner to start asking questions before any diagnoses appear.
  • Save diagnosis_panel.dart.

Seeing a type error on d.name or d.confidence?

The widget expects each item in diagnosis.differentials to have a name (String) and confidence (double) property. Check that your DifferentialDiagnosis model class has both of these fields defined.

Help me fix my DifferentialDiagnosis model

Integrate the tracker into the encounter screen

The panel and provider exist, but they are not connected to the encounter screen yet. You need to display the panel in the UI and trigger a refresh after every AI response.

  • Open lib/features/patient_encounter/screens/encounter_screen.dart.
  • Find the existing imports at the top of the file and add these two new imports below them:
import '../providers/diagnosis_provider.dart';
import '../widgets/diagnosis_panel.dart';

What does this add?

These imports bring in the diagnosisProvider (so the screen can trigger refreshes) and DiagnosisPanelWidget (so the panel can render in the widget tree).

Next, you need a helper method that sends the message and then automatically refreshes the diagnosis tracker with the updated conversation history.

  • Inside the _EncounterScreenState class, find the _scrollToBottom method. Add the following new method directly below it:
void _sendMessage() {
  final text = _controller.text.trim();
  if (text.isEmpty) return;
  // Send the message, then refresh the differential diagnosis list
  ref.read(encounterProvider.notifier).sendMessage(text).then((_) {
    final history = ref.read(encounterProvider).conversationHistory;
    ref.read(diagnosisProvider.notifier).refresh(history);
  });
  _controller.clear();
}

What does this code do?

_sendMessage centralizes the send-and-refresh logic. After sendMessage completes (the .then callback), it reads the latest conversation history and passes it to the diagnosis provider's refresh method. This ensures the differential list always reflects the most recent exchange.

Now update the build method to wire everything together.

  • In the build method, find this line inside the Column children:
if (state.isLoading) const LinearProgressIndicator(),
  • Add the DiagnosisPanelWidget directly below it, so the two lines look like this:
if (state.isLoading) const LinearProgressIndicator(),
const DiagnosisPanelWidget(),
  • Now find the TextField's onSubmitted callback. It currently looks like this:
onSubmitted: (text) {
  ref.read(encounterProvider.notifier).sendMessage(text);
  _controller.clear();
},
  • Replace it with this single-line callback that uses your new _sendMessage method:
onSubmitted: (_) => _sendMessage(),
  • Find the IconButton for sending messages. It currently looks like this:
IconButton(
  icon: const Icon(Icons.send),
  onPressed: () {
    ref.read(encounterProvider.notifier).sendMessage(_controller.text);
    _controller.clear();
  },
),
  • Replace it with this streamlined version:
IconButton(icon: const Icon(Icons.send), onPressed: _sendMessage),

Why consolidate into _sendMessage?

Both the text field's submit action and the send button were duplicating the same logic. By extracting _sendMessage, you have a single place that handles sending text, clearing the input, and triggering the diagnosis refresh.

  • Save encounter_screen.dart.
  • Hot restart your app, navigate to a patient encounter, and type a question like "What symptoms brought you in today?".
  • After the AI patient responds, you should see the Differential Diagnoses panel appear below the chat with color-coded entries showing diagnosis names and confidence percentages.

Panel stays empty after asking a question?

Check that your GeminiService.getDiagnosisUpdate method is correctly parsing the JSON response into a DiagnosisUpdate with a non-empty differentials list. Add a print statement inside the refresh method to confirm it receives data.

Also verify the .then callback in _sendMessage is actually firing. If sendMessage throws an error, the .then block will be skipped.

Help me debug why my diagnosis panel is not updating

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files.

ⓧ I'd like to double check the full code

Here is the full code for all three files.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../../../core/models/diagnosis_update.dart';
import '../../../core/services/gemini_service.dart';

final diagnosisProvider = StateNotifierProvider<DiagnosisNotifier, DiagnosisUpdate>(
  (ref) => DiagnosisNotifier(ref.read(geminiServiceProvider)),
);

class DiagnosisNotifier extends StateNotifier<DiagnosisUpdate> {
  final GeminiService _geminiService;

  DiagnosisNotifier(this._geminiService) : super(const DiagnosisUpdate(differentials: []));

  Future<void> refresh(List<Content> history) async {
    if (history.isEmpty) return;
    try {
      final update = await _geminiService.getDiagnosisUpdate(history);
      state = update;
    } catch (_) {}
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/diagnosis_provider.dart';

class DiagnosisPanelWidget extends ConsumerWidget {
  const DiagnosisPanelWidget({super.key});

  Color _confidenceColor(double confidence) {
    if (confidence >= 0.7) return Colors.green;
    if (confidence >= 0.4) return Colors.orange;
    return Colors.red;
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final diagnosis = ref.watch(diagnosisProvider);
    if (diagnosis.differentials.isEmpty) {
      return const Padding(
        padding: EdgeInsets.all(16),
        child: Text('No differentials yet. Start asking questions.', style: TextStyle(color: Colors.grey)),
      );
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Padding(
          padding: EdgeInsets.all(12),
          child: Text('Differential Diagnoses', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
        ),
        ...diagnosis.differentials.map((d) => AnimatedContainer(
          duration: const Duration(milliseconds: 400),
          margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
          padding: const EdgeInsets.all(10),
          decoration: BoxDecoration(
            color: _confidenceColor(d.confidence).withOpacity(0.15),
            border: Border.all(color: _confidenceColor(d.confidence)),
            borderRadius: BorderRadius.circular(8),
          ),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Expanded(child: Text(d.name, style: const TextStyle(fontWeight: FontWeight.w500))),
              Text('${(d.confidence * 100).toStringAsFixed(0)}%',
                  style: TextStyle(color: _confidenceColor(d.confidence), fontWeight: FontWeight.bold)),
            ],
          ),
        )),
      ],
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/encounter_provider.dart';
import '../providers/diagnosis_provider.dart';
import '../models/encounter_state.dart';
import '../widgets/diagnosis_panel.dart';

class EncounterScreen extends ConsumerStatefulWidget {
  const EncounterScreen({super.key});

  @override
  ConsumerState<EncounterScreen> createState() => _EncounterScreenState();
}

class _EncounterScreenState extends ConsumerState<EncounterScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  void _sendMessage() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    ref.read(encounterProvider.notifier).sendMessage(text).then((_) {
      final history = ref.read(encounterProvider).conversationHistory;
      ref.read(diagnosisProvider.notifier).refresh(history);
    });
    _controller.clear();
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(encounterProvider);
    ref.listen(encounterProvider, (_, __) => _scrollToBottom());

    return Scaffold(
      appBar: AppBar(
        title: const Text('Patient Encounter'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(40),
          child: _StageIndicator(stage: state.stage),
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              itemCount: state.messages.length + (state.streamingText.isNotEmpty ? 1 : 0),
              itemBuilder: (context, index) {
                if (index == state.messages.length && state.streamingText.isNotEmpty) {
                  return _MessageBubble(role: 'patient', content: state.streamingText, isStreaming: true);
                }
                final msg = state.messages[index];
                return _MessageBubble(role: msg.role, content: msg.content);
              },
            ),
          ),
          if (state.isLoading) const LinearProgressIndicator(),
          const DiagnosisPanelWidget(),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'Ask the patient a question...',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _sendMessage(),
                  ),
                ),
                const SizedBox(width: 8),
                IconButton(icon: const Icon(Icons.send), onPressed: _sendMessage),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(bottom: 8),
            child: ElevatedButton(
              onPressed: () => ref.read(encounterProvider.notifier).advanceStage(),
              child: const Text('Advance to Next Stage'),
            ),
          ),
        ],
      ),
    );
  }
}

class _StageIndicator extends StatelessWidget {
  final EncounterStage stage;
  const _StageIndicator({required this.stage});

  @override
  Widget build(BuildContext context) {
    final labels = ['History', 'Exam', 'Investigations', 'Diagnosis', 'Treatment', 'Done'];
    final index = EncounterStage.values.indexOf(stage);
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: List.generate(labels.length, (i) => Text(
          labels[i],
          style: TextStyle(
            fontSize: 10,
            fontWeight: i == index ? FontWeight.bold : FontWeight.normal,
            color: i == index ? Colors.teal : Colors.grey,
          ),
        )),
      ),
    );
  }
}

class _MessageBubble extends StatelessWidget {
  final String role;
  final String content;
  final bool isStreaming;
  const _MessageBubble({required this.role, required this.content, this.isStreaming = false});

  @override
  Widget build(BuildContext context) {
    final isUser = role == 'user';
    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.all(12),
        constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
        decoration: BoxDecoration(
          color: isUser ? Colors.teal : Colors.grey[200],
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text(
          content,
          style: TextStyle(color: isUser ? Colors.white : Colors.black87),
        ),
      ),
    );
  }
}

Your differential diagnosis tracker is live. Every question the student asks now feeds into Gemini's analysis, and the panel updates with color-coded confidence levels. Next up, you will build the case evaluation system that scores the student's final diagnosis and clinical reasoning.

Implement Case Evaluation and Scoring

The patient encounter so far lets students ask questions and track differentials. The missing piece is the payoff: when a student commits to a final diagnosis and treatment plan, they need feedback on their clinical reasoning.

In this step, you will build the submission and evaluation flow. The student enters their diagnosis and treatment, Gemini evaluates the full conversation, and the results are saved to Supabase for future analytics.

In this step, get ready to:
  • Build the submission screen where students enter their final diagnosis and treatment plan.
  • Create the evaluation provider that sends the conversation to Gemini and saves scored results to Supabase.
  • Display the evaluation results and wire up the new routes.
Create the submission form and screen

Before you can evaluate a student's reasoning, you need a structured way to capture their final answers. A simple data class holds the diagnosis and treatment plan strings.

  • Create a new file lib/features/patient_encounter/models/submission_form.dart and add this model:
class SubmissionForm {
  final String finalDiagnosis;
  final String treatmentPlan;

  const SubmissionForm({required this.finalDiagnosis, required this.treatmentPlan});
}

What does this code do?

This is a simple data class that groups the two pieces of information the student submits at the end of a case: their final diagnosis and their treatment plan. Using a dedicated class keeps the submission data strongly typed rather than passing loose strings around.

  • Save submission_form.dart.
  • Now create the submission screen at lib/features/patient_encounter/screens/submission_screen.dart with this code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/encounter_provider.dart';
import '../providers/evaluation_provider.dart';
import '../../auth/providers/auth_provider.dart';

class SubmissionScreen extends ConsumerStatefulWidget {
  const SubmissionScreen({super.key});

  @override
  ConsumerState<SubmissionScreen> createState() => _SubmissionScreenState();
}

class _SubmissionScreenState extends ConsumerState<SubmissionScreen> {
  final _diagnosisController = TextEditingController();
  final _treatmentController = TextEditingController();

  Future<void> _submit() async {
    final history = ref.read(encounterProvider).conversationHistory;
    final user = ref.read(authStateProvider);
    if (user == null) return;
    await ref.read(evaluationProvider.notifier).submitCase(
      history,
      _diagnosisController.text.trim(),
      _treatmentController.text.trim(),
      user.id,
    );
    if (mounted) context.go('/evaluation');
  }

  @override
  Widget build(BuildContext context) {
    final evalState = ref.watch(evaluationProvider);
    return Scaffold(
      appBar: AppBar(title: const Text('Submit Your Diagnosis')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            TextField(
              controller: _diagnosisController,
              decoration: const InputDecoration(labelText: 'Final Diagnosis', border: OutlineInputBorder()),
              maxLines: 2,
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _treatmentController,
              decoration: const InputDecoration(labelText: 'Treatment Plan', border: OutlineInputBorder()),
              maxLines: 4,
            ),
            const SizedBox(height: 24),
            evalState.isLoading
                ? const CircularProgressIndicator()
                : ElevatedButton(onPressed: _submit, child: const Text('Submit Case')),
          ],
        ),
      ),
    );
  }
}

What does this screen do?

The submission screen presents two text fields: one for the student's final diagnosis and one for their treatment plan. When the student taps Submit Case, the _submit method reads the full conversation history from the encounter provider and the current user from the auth provider.

It passes everything to the evaluation provider, which handles the Gemini call and database save. After a successful submission, the app navigates to /evaluation to show results.

  • Save submission_screen.dart.

Seeing import errors?

Make sure evaluation_provider.dart exists before running the app. You will create it in the next substep. If your IDE shows red underlines for authStateProvider, confirm that lib/features/auth/providers/auth_provider.dart exports that provider.

help me fix import errors in my submission screen

Build the evaluation provider

The evaluation provider is the orchestrator for this entire flow. It calls Gemini to evaluate the student's reasoning, then persists the results to two Supabase tables so the analytics dashboard can use them later.

  • Create lib/features/patient_encounter/providers/evaluation_provider.dart with this code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../core/models/case_evaluation.dart';
import '../../../core/services/gemini_service.dart';
import 'package:google_generative_ai/google_generative_ai.dart';

final evaluationProvider = StateNotifierProvider<EvaluationNotifier, AsyncValue<CaseEvaluation?>>(
  (ref) => EvaluationNotifier(ref.read(geminiServiceProvider)),
);

class EvaluationNotifier extends StateNotifier<AsyncValue<CaseEvaluation?>> {
  final GeminiService _geminiService;
  final _supabase = Supabase.instance.client;

  EvaluationNotifier(this._geminiService) : super(const AsyncValue.data(null));

  Future<void> submitCase(
    List<Content> history,
    String diagnosis,
    String treatment,
    String userId,
  ) async {
    state = const AsyncValue.loading();
    try {
      final evaluation = await _geminiService.evaluateCase(history, diagnosis, treatment);
      await _supabase.from('case_sessions').insert({
        'user_id': userId,
        'specialty': 'General',
        'difficulty': 'Medium',
        'score': evaluation.score,
        'time_taken_seconds': 0,
        'missed_items': evaluation.missedQuestions,
      });
      await _supabase.from('performance_scores').insert({
        'user_id': userId,
        'topic': 'General',
        'score': evaluation.score,
        'source': 'case',
      });
      state = AsyncValue.data(evaluation);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

What does this provider do?

The EvaluationNotifier manages an AsyncValue<CaseEvaluation?> state that starts as null. When submitCase is called, it transitions to loading, sends the conversation history plus the student's answers to geminiService.evaluateCase, and awaits a structured CaseEvaluation response.

After receiving the evaluation, it inserts a row into case_sessions (with score, specialty, and missed items) and a row into performance_scores (for the analytics engine to aggregate later). If anything fails, the error state propagates to the UI.

  • Save evaluation_provider.dart.

Getting RLS policy errors on insert?

If you see a permission denied error when submitting, check that your Row-Level Security policies on case_sessions and performance_scores allow inserts where user_id matches the authenticated user's auth.uid(). You set these up in Step 2.

help me debug RLS policy errors when inserting case evaluation data into Supabase

Display results and connect routing

The evaluation screen shows the student their score, what they got right, what they missed, and suggestions for improvement. Once that screen exists, you need to register the new routes and add a way to reach the submission screen from the encounter.

  • Create lib/features/patient_encounter/screens/evaluation_screen.dart with this code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/evaluation_provider.dart';

class EvaluationScreen extends ConsumerWidget {
  const EvaluationScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final evalState = ref.watch(evaluationProvider);
    return evalState.when(
      loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
      error: (e, _) => Scaffold(body: Center(child: Text('Error: $e'))),
      data: (evaluation) {
        if (evaluation == null) return const Scaffold(body: Center(child: Text('No evaluation data.')));
        return Scaffold(
          appBar: AppBar(title: const Text('Case Evaluation')),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Center(child: Text('Score: ${evaluation.score}/100', style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.teal))),
                const SizedBox(height: 24),
                _Section(title: 'Correct Reasoning', items: evaluation.correctReasoningSteps, color: Colors.green),
                _Section(title: 'Missed Questions', items: evaluation.missedQuestions, color: Colors.red),
                _Section(title: 'Suggested Improvements', items: evaluation.suggestedImprovements, color: Colors.orange),
                const SizedBox(height: 24),
                ElevatedButton(onPressed: () => context.go('/'), child: const Text('Back to Home')),
              ],
            ),
          ),
        );
      },
    );
  }
}

class _Section extends StatelessWidget {
  final String title;
  final List<String> items;
  final Color color;
  const _Section({required this.title, required this.items, required this.color});

  @override
  Widget build(BuildContext context) {
    if (items.isEmpty) return const SizedBox.shrink();
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(title, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: color)),
        const SizedBox(height: 8),
        ...items.map((item) => Padding(
          padding: const EdgeInsets.only(bottom: 4),
          child: Row(children: [Icon(Icons.circle, size: 8, color: color), const SizedBox(width: 8), Expanded(child: Text(item))]),
        )),
        const SizedBox(height: 16),
      ],
    );
  }
}

What does this screen do?

The EvaluationScreen watches the evaluationProvider and uses AsyncValue.when to handle loading, error, and data states. When the evaluation arrives, it displays the score prominently at the top in teal.

The _Section widget renders a color-coded list of items (green for correct reasoning, red for missed questions, orange for improvements). If a section has no items, it renders nothing.

  • Save evaluation_screen.dart.

Now you need to register the two new screens as routes and add a way for the student to reach the submission screen.

  • Open your router configuration file (where you defined the /encounter route).
  • Add two new GoRoute entries for /submission and /evaluation:
GoRoute(
  path: '/submission',
  builder: (context, state) => const SubmissionScreen(),
),
GoRoute(
  path: '/evaluation',
  builder: (context, state) => const EvaluationScreen(),
),

What does this do?

Each GoRoute maps a URL path to a screen widget. When the app calls context.go('/submission'), GoRouter renders the SubmissionScreen. After submission completes, context.go('/evaluation') takes the student to their results.

  • Add the necessary imports for SubmissionScreen and EvaluationScreen at the top of your router file.
  • Save your router file.

Finally, update the encounter screen so the student can navigate to submission when they reach the treatment stage.

  • In lib/features/patient_encounter/screens/encounter_screen.dart, add this import at the top of the file:
import 'package:go_router/go_router.dart';
  • Find the Padding widget that contains the Advance to Next Stage button. Find this code:
Padding(
  padding: const EdgeInsets.only(bottom: 8),
  child: ElevatedButton(
    onPressed: () => ref.read(encounterProvider.notifier).advanceStage(),
    child: const Text('Advance to Next Stage'),
  ),
),
  • Replace that entire Padding widget with the following, which shows a Submit Case button when the stage is treatment and the advance button otherwise:
if (state.stage == EncounterStage.treatment)
  Padding(
    padding: const EdgeInsets.only(bottom: 8),
    child: ElevatedButton(
      onPressed: () => context.go('/submission'),
      child: const Text('Submit Case'),
    ),
  )
else
  Padding(
    padding: const EdgeInsets.only(bottom: 8),
    child: ElevatedButton(
      onPressed: () => ref.read(encounterProvider.notifier).advanceStage(),
      child: const Text('Advance to Next Stage'),
    ),
  ),

Why swap the button at the treatment stage?

The encounter flows through stages: History, Exam, Investigations, Diagnosis, Treatment. Once a student reaches the treatment stage, there is nowhere further to advance. Instead of a meaningless advance button, the UI switches to Submit Case which navigates to the submission screen where they commit their final answers.

  • Save encounter_screen.dart.
  • Hot restart your app, start a patient encounter, advance to the treatment stage, and confirm you see the Submit Case button.
  • Tap Submit Case, enter a diagnosis and treatment plan, and tap Submit Case on the submission screen. You should see the evaluation screen with your score and feedback sections.

Seeing a blank evaluation screen?

If the evaluation screen shows "No evaluation data", the Gemini call may have failed silently. Check your terminal for network errors. Confirm your Gemini API key is set correctly in your .env file and that geminiService.evaluateCase returns a valid CaseEvaluation object.

help me debug why my evaluation screen shows no data after submitting a case

✔️ Awesome, I've got everything!

Great. Double check that all four new files are saved.

ⓧ I'd like to double check the full code

Here is the full code for each new file created in this step.

class SubmissionForm {
  final String finalDiagnosis;
  final String treatmentPlan;

  const SubmissionForm({required this.finalDiagnosis, required this.treatmentPlan});
}
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../core/models/case_evaluation.dart';
import '../../../core/services/gemini_service.dart';
import 'package:google_generative_ai/google_generative_ai.dart';

final evaluationProvider = StateNotifierProvider<EvaluationNotifier, AsyncValue<CaseEvaluation?>>(
  (ref) => EvaluationNotifier(ref.read(geminiServiceProvider)),
);

class EvaluationNotifier extends StateNotifier<AsyncValue<CaseEvaluation?>> {
  final GeminiService _geminiService;
  final _supabase = Supabase.instance.client;

  EvaluationNotifier(this._geminiService) : super(const AsyncValue.data(null));

  Future<void> submitCase(
    List<Content> history,
    String diagnosis,
    String treatment,
    String userId,
  ) async {
    state = const AsyncValue.loading();
    try {
      final evaluation = await _geminiService.evaluateCase(history, diagnosis, treatment);
      await _supabase.from('case_sessions').insert({
        'user_id': userId,
        'specialty': 'General',
        'difficulty': 'Medium',
        'score': evaluation.score,
        'time_taken_seconds': 0,
        'missed_items': evaluation.missedQuestions,
      });
      await _supabase.from('performance_scores').insert({
        'user_id': userId,
        'topic': 'General',
        'score': evaluation.score,
        'source': 'case',
      });
      state = AsyncValue.data(evaluation);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/evaluation_provider.dart';

class EvaluationScreen extends ConsumerWidget {
  const EvaluationScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final evalState = ref.watch(evaluationProvider);
    return evalState.when(
      loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
      error: (e, _) => Scaffold(body: Center(child: Text('Error: $e'))),
      data: (evaluation) {
        if (evaluation == null) return const Scaffold(body: Center(child: Text('No evaluation data.')));
        return Scaffold(
          appBar: AppBar(title: const Text('Case Evaluation')),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Center(child: Text('Score: ${evaluation.score}/100', style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.teal))),
                const SizedBox(height: 24),
                _Section(title: 'Correct Reasoning', items: evaluation.correctReasoningSteps, color: Colors.green),
                _Section(title: 'Missed Questions', items: evaluation.missedQuestions, color: Colors.red),
                _Section(title: 'Suggested Improvements', items: evaluation.suggestedImprovements, color: Colors.orange),
                const SizedBox(height: 24),
                ElevatedButton(onPressed: () => context.go('/'), child: const Text('Back to Home')),
              ],
            ),
          ),
        );
      },
    );
  }
}

class _Section extends StatelessWidget {
  final String title;
  final List<String> items;
  final Color color;
  const _Section({required this.title, required this.items, required this.color});

  @override
  Widget build(BuildContext context) {
    if (items.isEmpty) return const SizedBox.shrink();
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(title, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: color)),
        const SizedBox(height: 8),
        ...items.map((item) => Padding(
          padding: const EdgeInsets.only(bottom: 4),
          child: Row(children: [Icon(Icons.circle, size: 8, color: color), const SizedBox(width: 8), Expanded(child: Text(item))]),
        )),
        const SizedBox(height: 16),
      ],
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/encounter_provider.dart';
import '../providers/evaluation_provider.dart';
import '../../auth/providers/auth_provider.dart';

class SubmissionScreen extends ConsumerStatefulWidget {
  const SubmissionScreen({super.key});

  @override
  ConsumerState<SubmissionScreen> createState() => _SubmissionScreenState();
}

class _SubmissionScreenState extends ConsumerState<SubmissionScreen> {
  final _diagnosisController = TextEditingController();
  final _treatmentController = TextEditingController();

  Future<void> _submit() async {
    final history = ref.read(encounterProvider).conversationHistory;
    final user = ref.read(authStateProvider);
    if (user == null) return;
    await ref.read(evaluationProvider.notifier).submitCase(
      history,
      _diagnosisController.text.trim(),
      _treatmentController.text.trim(),
      user.id,
    );
    if (mounted) context.go('/evaluation');
  }

  @override
  Widget build(BuildContext context) {
    final evalState = ref.watch(evaluationProvider);
    return Scaffold(
      appBar: AppBar(title: const Text('Submit Your Diagnosis')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            TextField(
              controller: _diagnosisController,
              decoration: const InputDecoration(labelText: 'Final Diagnosis', border: OutlineInputBorder()),
              maxLines: 2,
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _treatmentController,
              decoration: const InputDecoration(labelText: 'Treatment Plan', border: OutlineInputBorder()),
              maxLines: 4,
            ),
            const SizedBox(height: 24),
            evalState.isLoading
                ? const CircularProgressIndicator()
                : ElevatedButton(onPressed: _submit, child: const Text('Submit Case')),
          ],
        ),
      ),
    );
  }
}

Your app now closes the loop on patient encounters. Students can commit to a diagnosis, receive AI-powered feedback, and have their results persisted for tracking. Next up, you will build the analytics dashboard to surface patterns in these scores over time.

Build the Performance Analytics Dashboard

Your case evaluation engine now scores each encounter and saves the results to Supabase. Raw scores sitting in a database are useful, but they don't tell the learner where they're strong or where they need to focus.

In this step, you'll build a performance analytics dashboard that queries historical case data, visualizes score trends over time, and identifies the learner's weakest topics. This turns scattered results into actionable insight.

In this step, get ready to:
  • Create an analytics service that queries Supabase for historical case data.
  • Build a dashboard screen with line charts, bar charts, and summary statistics.
  • Implement a Weak Areas algorithm that identifies the 3 lowest-scoring topics.
Create the analytics service with Supabase queries

The dashboard needs data before it can display anything. You'll create an AnalyticsService class that encapsulates all the Supabase queries the dashboard depends on.

  • Create a new folder at lib/features/analytics/services/.
  • Inside that folder, create a file called analytics_service.dart and paste the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final analyticsServiceProvider = Provider<AnalyticsService>((ref) => AnalyticsService());

class AnalyticsService {
  final _supabase = Supabase.instance.client;

  Future<Map<String, double>> getAverageScoreByTopic(String userId) async {
    final response = await _supabase
        .from('performance_scores')
        .select('topic, score')
        .eq('user_id', userId);
    final Map<String, List<int>> grouped = {};
    for (final row in response as List<dynamic>) {
      final topic = row['topic'] as String;
      final score = row['score'] as int;
      grouped.putIfAbsent(topic, () => []).add(score);
    }
    return grouped.map((topic, scores) =>
        MapEntry(topic, scores.reduce((a, b) => a + b) / scores.length));
  }

  Future<List<double>> getScoreTrend(String userId) async {
    final response = await _supabase
        .from('performance_scores')
        .select('score')
        .eq('user_id', userId)
        .order('created_at', ascending: false)
        .limit(20);
    final scores = (response as List<dynamic>)
        .map((row) => (row['score'] as int).toDouble())
        .toList();
    return scores.reversed.toList();
  }

  Future<int> getTotalCasesCompleted(String userId) async {
    final response = await _supabase
        .from('case_sessions')
        .select('id')
        .eq('user_id', userId);
    return (response as List<dynamic>).length;
  }

  Future<List<String>> getWeakAreas(String userId) async {
    final averages = await getAverageScoreByTopic(userId);
    final sorted = averages.entries.toList()
      ..sort((a, b) => a.value.compareTo(b.value));
    return sorted.take(3).map((e) => e.key).toList();
  }
}

What does this service do?

This class contains four methods that power the entire dashboard:

  • getAverageScoreByTopic fetches all performance scores for the user, groups them by topic, and computes the average for each.
  • getScoreTrend retrieves the last 20 scores in reverse chronological order, then reverses again so the chart reads left-to-right (oldest to newest).
  • getTotalCasesCompleted counts all rows in case_sessions for the user.
  • getWeakAreas calls getAverageScoreByTopic, sorts ascending by score, and returns the 3 lowest-scoring topic names.
  • Save analytics_service.dart.

Why does getWeakAreas live in the service?

The weak areas algorithm is derived entirely from the same query data the other methods use. Keeping it in the service avoids duplicating the Supabase call and keeps the provider layer thin. The providers just forward to the service.

Seeing a type error on the response?

Supabase's select() returns a List<dynamic> by default. Make sure you cast the response as List<dynamic> before iterating, and cast individual fields (row['score'] as int) when reading values.

help me fix a type error in my AnalyticsService Supabase query

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final analyticsServiceProvider = Provider<AnalyticsService>((ref) => AnalyticsService());

class AnalyticsService {
  final _supabase = Supabase.instance.client;

  Future<Map<String, double>> getAverageScoreByTopic(String userId) async {
    final response = await _supabase
        .from('performance_scores')
        .select('topic, score')
        .eq('user_id', userId);
    final Map<String, List<int>> grouped = {};
    for (final row in response as List<dynamic>) {
      final topic = row['topic'] as String;
      final score = row['score'] as int;
      grouped.putIfAbsent(topic, () => []).add(score);
    }
    return grouped.map((topic, scores) =>
        MapEntry(topic, scores.reduce((a, b) => a + b) / scores.length));
  }

  Future<List<double>> getScoreTrend(String userId) async {
    final response = await _supabase
        .from('performance_scores')
        .select('score')
        .eq('user_id', userId)
        .order('created_at', ascending: false)
        .limit(20);
    final scores = (response as List<dynamic>)
        .map((row) => (row['score'] as int).toDouble())
        .toList();
    return scores.reversed.toList();
  }

  Future<int> getTotalCasesCompleted(String userId) async {
    final response = await _supabase
        .from('case_sessions')
        .select('id')
        .eq('user_id', userId);
    return (response as List<dynamic>).length;
  }

  Future<List<String>> getWeakAreas(String userId) async {
    final averages = await getAverageScoreByTopic(userId);
    final sorted = averages.entries.toList()
      ..sort((a, b) => a.value.compareTo(b.value));
    return sorted.take(3).map((e) => e.key).toList();
  }
}
Build the analytics screen with fl_chart

With the data layer ready, you need Riverpod providers to bridge the service into the UI. Then you'll build the dashboard screen using fl_chart for the line and bar charts.

  • Create a new folder at lib/features/analytics/providers/.
  • Inside that folder, create analytics_provider.dart with the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/analytics_service.dart';

final scoreTrendProvider = FutureProvider.family<List<double>, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getScoreTrend(userId),
);

final averageScoreByTopicProvider = FutureProvider.family<Map<String, double>, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getAverageScoreByTopic(userId),
);

final totalCasesProvider = FutureProvider.family<int, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getTotalCasesCompleted(userId),
);

final weakAreasProvider = FutureProvider.family<List<String>, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getWeakAreas(userId),
);

What is FutureProvider.family?

FutureProvider.family creates a parameterized provider. Each unique userId passed in gets its own cached result. This means the dashboard data is fetched once per user session and only re-fetched if explicitly invalidated.

The .family modifier turns the provider into a function you call with the parameter: ref.watch(scoreTrendProvider(userId)).

  • Save analytics_provider.dart.
  • Create a new folder at lib/features/analytics/screens/.
  • Inside that folder, create analytics_screen.dart with the following code:
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../providers/analytics_provider.dart';

class AnalyticsScreen extends ConsumerWidget {
  const AnalyticsScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(authStateProvider);
    if (user == null) return const Scaffold(body: Center(child: Text('Not signed in')));
    final userId = user.id;

    final trendAsync = ref.watch(scoreTrendProvider(userId));
    final topicAsync = ref.watch(averageScoreByTopicProvider(userId));
    final totalAsync = ref.watch(totalCasesProvider(userId));
    final weakAsync = ref.watch(weakAreasProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Performance Analytics')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(child: _StatCard(label: 'Total Cases', valueAsync: totalAsync.when(data: (v) => '$v', loading: () => '...', error: (_, __) => 'N/A'))),
                const SizedBox(width: 12),
                Expanded(child: _StatCard(label: 'Avg Score', valueAsync: topicAsync.when(
                  data: (m) => m.isEmpty ? 'N/A' : '${(m.values.reduce((a, b) => a + b) / m.length).toStringAsFixed(1)}',
                  loading: () => '...',
                  error: (_, __) => 'N/A',
                ))),
              ],
            ),
            const SizedBox(height: 24),
            const Text('Score Trend', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            const SizedBox(height: 8),
            SizedBox(
              height: 200,
              child: trendAsync.when(
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (e, _) => Text('Error: $e'),
                data: (scores) => scores.isEmpty
                    ? const Center(child: Text('No data yet'))
                    : LineChart(LineChartData(
                        lineBarsData: [LineChartBarData(
                          spots: scores.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value)).toList(),
                          isCurved: true,
                          color: Colors.teal,
                          dotData: const FlDotData(show: false),
                        )],
                        titlesData: const FlTitlesData(show: false),
                        borderData: FlBorderData(show: false),
                      )),
              ),
            ),
            const SizedBox(height: 24),
            const Text('Score by Topic', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            const SizedBox(height: 8),
            SizedBox(
              height: 200,
              child: topicAsync.when(
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (e, _) => Text('Error: $e'),
                data: (topics) => topics.isEmpty
                    ? const Center(child: Text('No data yet'))
                    : BarChart(BarChartData(
                        barGroups: topics.entries.toList().asMap().entries.map((e) =>
                          BarChartGroupData(x: e.key, barRods: [BarChartRodData(toY: e.value.value, color: Colors.teal)])
                        ).toList(),
                        titlesData: const FlTitlesData(show: false),
                        borderData: FlBorderData(show: false),
                      )),
              ),
            ),
            const SizedBox(height: 24),
            const Text('Weak Areas', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            weakAsync.when(
              loading: () => const CircularProgressIndicator(),
              error: (e, _) => Text('Error: $e'),
              data: (areas) => areas.isEmpty
                  ? const Text('Complete more cases to see weak areas.')
                  : Column(children: areas.map((a) => ListTile(
                      leading: const Icon(Icons.warning_amber, color: Colors.orange),
                      title: Text(a),
                    )).toList()),
            ),
          ],
        ),
      ),
    );
  }
}

class _StatCard extends StatelessWidget {
  final String label;
  final String valueAsync;
  const _StatCard({required this.label, required this.valueAsync});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Text(valueAsync, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.teal)),
            Text(label, style: const TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

What does the dashboard display?

The screen is structured in four visual sections from top to bottom:

  • Summary cards at the top show total cases completed and overall average score.
  • A LineChart plots the last 20 scores as a curved teal line. Each score becomes a FlSpot with the index as x and the score as y.
  • A BarChart shows average score per topic. Each topic gets one bar with height equal to its average.
  • A Weak Areas section lists the 3 lowest-scoring topics with warning icons.

Every section handles loading, error, and empty states gracefully using Riverpod's .when() pattern.

  • Save analytics_screen.dart.

Chart not rendering or showing overflow?

Make sure each chart is wrapped in a SizedBox with a fixed height. Without an explicit height constraint, fl_chart cannot determine how much space to use and throws a layout error.

help me fix a chart rendering issue in my Flutter analytics screen

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/analytics_service.dart';

final scoreTrendProvider = FutureProvider.family<List<double>, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getScoreTrend(userId),
);

final averageScoreByTopicProvider = FutureProvider.family<Map<String, double>, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getAverageScoreByTopic(userId),
);

final totalCasesProvider = FutureProvider.family<int, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getTotalCasesCompleted(userId),
);

final weakAreasProvider = FutureProvider.family<List<String>, String>(
  (ref, userId) => ref.read(analyticsServiceProvider).getWeakAreas(userId),
);
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../providers/analytics_provider.dart';

class AnalyticsScreen extends ConsumerWidget {
  const AnalyticsScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(authStateProvider);
    if (user == null) return const Scaffold(body: Center(child: Text('Not signed in')));
    final userId = user.id;

    final trendAsync = ref.watch(scoreTrendProvider(userId));
    final topicAsync = ref.watch(averageScoreByTopicProvider(userId));
    final totalAsync = ref.watch(totalCasesProvider(userId));
    final weakAsync = ref.watch(weakAreasProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Performance Analytics')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(child: _StatCard(label: 'Total Cases', valueAsync: totalAsync.when(data: (v) => '$v', loading: () => '...', error: (_, __) => 'N/A'))),
                const SizedBox(width: 12),
                Expanded(child: _StatCard(label: 'Avg Score', valueAsync: topicAsync.when(
                  data: (m) => m.isEmpty ? 'N/A' : '${(m.values.reduce((a, b) => a + b) / m.length).toStringAsFixed(1)}',
                  loading: () => '...',
                  error: (_, __) => 'N/A',
                ))),
              ],
            ),
            const SizedBox(height: 24),
            const Text('Score Trend', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            const SizedBox(height: 8),
            SizedBox(
              height: 200,
              child: trendAsync.when(
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (e, _) => Text('Error: $e'),
                data: (scores) => scores.isEmpty
                    ? const Center(child: Text('No data yet'))
                    : LineChart(LineChartData(
                        lineBarsData: [LineChartBarData(
                          spots: scores.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value)).toList(),
                          isCurved: true,
                          color: Colors.teal,
                          dotData: const FlDotData(show: false),
                        )],
                        titlesData: const FlTitlesData(show: false),
                        borderData: FlBorderData(show: false),
                      )),
              ),
            ),
            const SizedBox(height: 24),
            const Text('Score by Topic', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            const SizedBox(height: 8),
            SizedBox(
              height: 200,
              child: topicAsync.when(
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (e, _) => Text('Error: $e'),
                data: (topics) => topics.isEmpty
                    ? const Center(child: Text('No data yet'))
                    : BarChart(BarChartData(
                        barGroups: topics.entries.toList().asMap().entries.map((e) =>
                          BarChartGroupData(x: e.key, barRods: [BarChartRodData(toY: e.value.value, color: Colors.teal)])
                        ).toList(),
                        titlesData: const FlTitlesData(show: false),
                        borderData: FlBorderData(show: false),
                      )),
              ),
            ),
            const SizedBox(height: 24),
            const Text('Weak Areas', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            weakAsync.when(
              loading: () => const CircularProgressIndicator(),
              error: (e, _) => Text('Error: $e'),
              data: (areas) => areas.isEmpty
                  ? const Text('Complete more cases to see weak areas.')
                  : Column(children: areas.map((a) => ListTile(
                      leading: const Icon(Icons.warning_amber, color: Colors.orange),
                      title: Text(a),
                    )).toList()),
            ),
          ],
        ),
      ),
    );
  }
}

class _StatCard extends StatelessWidget {
  final String label;
  final String valueAsync;
  const _StatCard({required this.label, required this.valueAsync});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Text(valueAsync, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.teal)),
            Text(label, style: const TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}
Connect the analytics dashboard to navigation

The dashboard exists but isn't reachable yet. You need to register a route and give users a button to get there.

  • Open your GoRouter configuration file (where you defined routes for /, /login, /encounter, etc.).
  • Add a new GoRoute with path '/analytics' that returns the AnalyticsScreen widget.
  • Import analytics_screen.dart at the top of your router file.
  • Open your home screen file.
  • Add a button or ListTile that calls context.go('/analytics') when tapped.

What should the route look like?

The route follows the same pattern as your other routes. Here is the shape:

GoRoute(
  path: '/analytics',
  builder: (context, state) => const AnalyticsScreen(),
),

Place it alongside the existing /evaluation route in your route list.

  • Save both your router file and home screen file.
  • Hot restart your app, sign in, and tap the new analytics button on the home screen.
  • You should see the Performance Analytics screen with summary cards at the top. If you have completed cases, the line chart and bar chart render your score data.

Seeing 'No data yet' on every chart?

This is expected if you haven't completed any cases yet. Go back to the home screen, run through a full patient encounter, submit a diagnosis, and then return to the analytics screen. The providers will re-fetch on the next visit.

help me understand why my analytics dashboard shows no data

Your analytics dashboard is live. You can now see exactly where clinical reasoning is strong and where it needs work. Next up, you'll use those weak areas to generate targeted quizzes that fill the gaps.

Build the AI Quiz Generator

Your analytics dashboard identifies weak areas, but knowing your weaknesses is only half the battle. The real value comes from targeted practice that forces you to confront those gaps.

In this step, you will build an adaptive quiz generator that takes the learner's weak areas from the analytics engine and prompts Gemini to generate USMLE-style multiple choice questions targeting those specific topics. The quiz screen provides immediate feedback with color-coded answers and explanations, and saves results back to Supabase so performance tracking stays up to date.

In this step, get ready to:
  • Create the quiz data models and a service that prompts Gemini for targeted questions.
  • Build the quiz state manager and an interactive quiz screen with immediate feedback.
  • Connect the quiz to your analytics dashboard so learners can launch it from their weak areas.
Create the quiz data models and service

The quiz system needs two data models. One represents a single question with its answer options. The other tracks the overall quiz session state (which question you are on, what you have answered).

  • Create a new file at lib/core/models/quiz_question.dart.
  • Add the following code to define the QuizQuestion model:
import 'dart:convert';

class QuizQuestion {
  final String question;
  final List<String> options;
  final int correctIndex;
  final String explanation;
  final String topic;

  const QuizQuestion({
    required this.question,
    required this.options,
    required this.correctIndex,
    required this.explanation,
    required this.topic,
  });

  // Parse a single question from a JSON map
  factory QuizQuestion.fromJson(Map<String, dynamic> json) {
    return QuizQuestion(
      question: json['question'] as String,
      options: List<String>.from(json['options'] as List),
      correctIndex: json['correct_index'] as int,
      explanation: json['explanation'] as String,
      topic: json['topic'] as String? ?? 'General',
    );
  }

  // Parse Gemini's raw response into a list of questions
  static List<QuizQuestion> listFromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    final decoded = jsonDecode(cleaned);
    final list = decoded['questions'] as List<dynamic>;
    return list.map((e) => QuizQuestion.fromJson(e as Map<String, dynamic>)).toList();
  }
}

What does this code do?

QuizQuestion holds everything about a single MCQ: the question text, four answer options, which index is correct, an explanation, and the medical topic it belongs to.

The listFromRawJson static method handles a common Gemini quirk. Sometimes the model wraps JSON in markdown code fences. The regex strips those fences before parsing so the app does not crash on unexpected formatting.

  • Save quiz_question.dart.
  • Now create a second file at lib/core/models/quiz_session.dart and add the following code:
import 'quiz_question.dart';

class QuizSession {
  final List<QuizQuestion> questions;
  final int currentIndex;
  final List<int?> selectedAnswers;
  final bool isComplete;

  const QuizSession({
    this.questions = const [],
    this.currentIndex = 0,
    this.selectedAnswers = const [],
    this.isComplete = false,
  });

  // Immutable copy method for state updates
  QuizSession copyWith({
    List<QuizQuestion>? questions,
    int? currentIndex,
    List<int?>? selectedAnswers,
    bool? isComplete,
  }) {
    return QuizSession(
      questions: questions ?? this.questions,
      currentIndex: currentIndex ?? this.currentIndex,
      selectedAnswers: selectedAnswers ?? this.selectedAnswers,
      isComplete: isComplete ?? this.isComplete,
    );
  }
}

What does this code do?

QuizSession tracks the state of an entire quiz attempt. It holds the list of questions, which question the user is currently viewing, their selected answers (nullable because they might not have answered yet), and whether the quiz is complete.

The copyWith method enables immutable state updates. Instead of mutating the session directly, you create a new copy with only the changed fields. This is essential for Riverpod's state change detection.

  • Save quiz_session.dart.

With both models defined, you can now build the service that talks to Gemini and Supabase.

  • Create the folder path lib/features/quiz/services/ if it does not already exist.
  • Create a new file at lib/features/quiz/services/quiz_service.dart and add the following code:
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../core/models/quiz_question.dart';

final quizServiceProvider = Provider<QuizService>((ref) => QuizService());

class QuizService {
  final _supabase = Supabase.instance.client;

  // Generate 5 targeted MCQs using Gemini
  Future<List<QuizQuestion>> generateQuiz(List<String> weakAreas) async {
    final model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    final topicsStr = weakAreas.join(', ');
    final prompt = '''
Generate 5 USMLE-style multiple choice questions targeting these medical topics: $topicsStr.
Respond ONLY with valid JSON:
{
  "questions": [
    {
      "question": "A 45-year-old man presents with...",
      "options": ["Option A", "Option B", "Option C", "Option D"],
      "correct_index": 0,
      "explanation": "The correct answer is A because...",
      "topic": "Cardiology"
    }
  ]
}
''';
    final response = await model.generateContent([Content.text(prompt)]);
    return QuizQuestion.listFromRawJson(response.text ?? '{"questions": []}');
  }

  // Save a single quiz answer to Supabase
  Future<void> saveAttempt(String userId, String topic, bool correct) async {
    await _supabase.from('quiz_attempts').insert({
      'user_id': userId,
      'topic': topic,
      'correct': correct,
    });
  }
}

What does this code do?

  • generateQuiz takes the learner's weak areas and builds a prompt that instructs Gemini to return exactly 5 USMLE-style questions as structured JSON. The prompt includes an example of the expected format so Gemini knows the exact schema.
  • The response is parsed through QuizQuestion.listFromRawJson which strips any markdown fences and decodes the JSON into strongly-typed Dart objects.
  • saveAttempt writes each individual answer to the quiz_attempts table in Supabase. This feeds back into the analytics engine so future weak-area calculations reflect quiz performance.
  • Save quiz_service.dart.
  • Your project should have no red squiggles in the three new files. If you see import errors, check that your file paths match the folder structure above.

Seeing import errors?

Make sure the folder structure matches exactly: lib/core/models/ for the models and lib/features/quiz/services/ for the service.

If google_generative_ai is unresolved, confirm it is listed in your pubspec.yaml dependencies and run flutter pub get again.

help me fix import errors in my quiz service file

Build the quiz provider and screen

The quiz provider manages the session lifecycle: loading questions from Gemini, tracking selected answers, advancing through questions, and saving results when the quiz finishes.

  • Create the folder lib/features/quiz/providers/ if it does not exist.
  • Create lib/features/quiz/providers/quiz_provider.dart and add the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/quiz_session.dart';
import '../services/quiz_service.dart';

final quizProvider = StateNotifierProvider<QuizNotifier, AsyncValue<QuizSession>>(
  (ref) => QuizNotifier(ref.read(quizServiceProvider)),
);

class QuizNotifier extends StateNotifier<AsyncValue<QuizSession>> {
  final QuizService _quizService;

  QuizNotifier(this._quizService) : super(const AsyncValue.data(QuizSession()));

  // Fetch questions from Gemini based on weak areas
  Future<void> loadQuiz(List<String> weakAreas) async {
    state = const AsyncValue.loading();
    try {
      final questions = await _quizService.generateQuiz(weakAreas);
      state = AsyncValue.data(QuizSession(
        questions: questions,
        selectedAnswers: List.filled(questions.length, null),
      ));
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  // Record which answer the user picked
  void selectAnswer(int questionIndex, int answerIndex) {
    state.whenData((session) {
      final updated = List<int?>.from(session.selectedAnswers)..[questionIndex] = answerIndex;
      state = AsyncValue.data(session.copyWith(selectedAnswers: updated));
    });
  }

  // Advance to the next question or mark quiz complete
  void nextQuestion() {
    state.whenData((session) {
      if (session.currentIndex < session.questions.length - 1) {
        state = AsyncValue.data(session.copyWith(currentIndex: session.currentIndex + 1));
      } else {
        state = AsyncValue.data(session.copyWith(isComplete: true));
      }
    });
  }

  // Save all answers to Supabase
  Future<void> saveResults(String userId) async {
    await state.whenData((session) async {
      for (int i = 0; i < session.questions.length; i++) {
        final q = session.questions[i];
        final selected = session.selectedAnswers[i];
        if (selected != null) {
          await _quizService.saveAttempt(userId, q.topic, selected == q.correctIndex);
        }
      }
    });
  }
}

What does this code do?

  • The provider wraps QuizSession in AsyncValue so the UI can distinguish between loading, error, and data states without manual flags.
  • loadQuiz sets the state to loading, calls the service, then populates the session with questions and an empty answers list.
  • selectAnswer locks in a choice for a specific question index. Once selected, the UI disables further taps on that question.
  • nextQuestion increments the index or marks the quiz complete if the user is on the last question.
  • Save quiz_provider.dart.
  • Now create lib/features/quiz/screens/quiz_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/quiz_provider.dart';
import '../../auth/providers/auth_provider.dart';

class QuizScreen extends ConsumerWidget {
  final List<String> weakAreas;
  const QuizScreen({super.key, required this.weakAreas});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final quizAsync = ref.watch(quizProvider);
    final user = ref.watch(authStateProvider);

    return quizAsync.when(
      loading: () => const Scaffold(body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Generating quiz...')]))),
      error: (e, _) => Scaffold(body: Center(child: Text('Error: $e'))),
      data: (session) {
        // Trigger quiz generation if questions haven't loaded
        if (session.questions.isEmpty) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            ref.read(quizProvider.notifier).loadQuiz(weakAreas);
          });
          return const Scaffold(body: Center(child: CircularProgressIndicator()));
        }
        // Show results when quiz is complete
        if (session.isComplete) {
          if (user != null) {
            ref.read(quizProvider.notifier).saveResults(user.id);
          }
          final correct = session.selectedAnswers.asMap().entries.where((e) => e.value == session.questions[e.key].correctIndex).length;
          return Scaffold(
            appBar: AppBar(title: const Text('Quiz Complete')),
            body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
              Text('$correct / ${session.questions.length}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)),
              const Text('Questions Correct'),
              const SizedBox(height: 24),
              ElevatedButton(onPressed: () => context.go('/analytics'), child: const Text('Back to Analytics')),
            ])),
          );
        }
        // Display the current question
        final q = session.questions[session.currentIndex];
        final selected = session.selectedAnswers[session.currentIndex];
        return Scaffold(
          appBar: AppBar(
            title: Text('Question ${session.currentIndex + 1} of ${session.questions.length}'),
            bottom: PreferredSize(
              preferredSize: const Size.fromHeight(4),
              child: LinearProgressIndicator(value: (session.currentIndex + 1) / session.questions.length),
            ),
          ),
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(q.question, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                const SizedBox(height: 16),
                ...q.options.asMap().entries.map((e) {
                  Color? color;
                  if (selected != null) {
                    if (e.key == q.correctIndex) color = Colors.green[100];
                    else if (e.key == selected) color = Colors.red[100];
                  }
                  return GestureDetector(
                    onTap: selected == null ? () => ref.read(quizProvider.notifier).selectAnswer(session.currentIndex, e.key) : null,
                    child: AnimatedContainer(
                      duration: const Duration(milliseconds: 300),
                      margin: const EdgeInsets.only(bottom: 8),
                      padding: const EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: color ?? Colors.grey[100],
                        border: Border.all(color: Colors.grey[300]!),
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text(e.value),
                    ),
                  );
                }),
                if (selected != null) ...[const SizedBox(height: 12), Text(q.explanation, style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey))],
                const Spacer(),
                if (selected != null) ElevatedButton(onPressed: () => ref.read(quizProvider.notifier).nextQuestion(), child: const Text('Next')),
              ],
            ),
          ),
        );
      },
    );
  }
}

What does this code do?

QuizScreen handles three visual states based on the session data:

  • Empty questions: triggers quiz generation via addPostFrameCallback (avoids calling the notifier during the build phase).
  • Quiz complete: counts correct answers, saves results to Supabase, and shows a score summary with a button back to analytics.
  • Active question: shows the question text, four answer options, a progress bar, and immediate color feedback. Green highlights the correct answer. Red highlights an incorrect selection. After selection, the explanation appears and a Next button advances the quiz.
  • Save quiz_screen.dart.

Seeing errors about authStateProvider?

Make sure the import path ../../auth/providers/auth_provider.dart matches where you defined authStateProvider in Step 3. If your auth provider lives at a different path, update the import accordingly.

help me fix the authStateProvider import in my quiz screen

Connect the quiz to your analytics dashboard

The quiz screen is ready, but learners need a way to reach it. You will add a route and a button on the analytics screen that passes the weak areas to the quiz.

  • Open your GoRouter configuration file (where you defined your app routes).
  • Add a new route for /quiz that builds a QuizScreen and passes the weak areas. Add the following route alongside your existing routes:
// Add this import at the top of your router file
import '../features/quiz/screens/quiz_screen.dart';

// Add this GoRoute inside your routes list
GoRoute(
  path: '/quiz',
  builder: (context, state) {
    final weakAreas = state.extra as List<String>? ?? [];
    return QuizScreen(weakAreas: weakAreas);
  },
),

What does this code do?

The route uses GoRouter's state.extra to receive the weak areas list passed from the analytics screen. If no data is passed, it defaults to an empty list so the screen does not crash.

  • Save your router file.
  • Now open lib/features/analytics/screens/analytics_screen.dart.
  • Find the weak areas section at the bottom of the Column. Locate this code:
            const Text('Weak Areas', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
            weakAsync.when(
              loading: () => const CircularProgressIndicator(),
              error: (e, _) => Text('Error: $e'),
              data: (areas) => areas.isEmpty
                  ? const Text('Complete more cases to see weak areas.')
                  : Column(children: areas.map((a) => ListTile(
                      leading: const Icon(Icons.warning_amber, color: Colors.orange),
                      title: Text(a),
                    )).toList()),
            ),
  • Add a Start Quiz button after the weak areas display. Insert the following code immediately after the weakAsync.when(...) block, still inside the Column children list:
            // Start Quiz button - navigates to quiz with weak areas
            const SizedBox(height: 24),
            weakAsync.when(
              loading: () => const SizedBox.shrink(),
              error: (_, __) => const SizedBox.shrink(),
              data: (areas) => areas.isEmpty
                  ? const SizedBox.shrink()
                  : ElevatedButton.icon(
                      onPressed: () => context.go('/quiz', extra: areas),
                      icon: const Icon(Icons.quiz),
                      label: const Text('Start Quiz'),
                    ),
            ),
  • Add the GoRouter import at the top of the analytics screen file:
import 'package:go_router/go_router.dart';

What does this code do?

The button only appears when weak areas exist (no point quizzing without data). When tapped, it navigates to /quiz and passes the weak areas list via GoRouter's extra parameter. The quiz screen receives these topics and uses them to prompt Gemini for targeted questions.

  • Save analytics_screen.dart.
  • Run your app and navigate to the analytics screen. If you have completed enough cases to have weak areas, you should see a Start Quiz button below the weak areas list.
  • Tap Start Quiz. You should see a loading spinner with the text "Generating quiz..." followed by a question card with four answer options.
  • Tap an answer. The correct option highlights green and an incorrect selection highlights red. An explanation appears below the options.

Quiz hangs on the loading screen?

Check that your GEMINI_API_KEY is set correctly in your .env file. The Gemini call will silently fail if the key is missing or invalid.

If you see a JSON parsing error in the debug console, Gemini may have returned malformed output. Try running the app again. The listFromRawJson method strips common formatting issues, but occasionally the model needs a second attempt.

help me debug why my quiz is not loading

✔️ Awesome, I've got everything!

Great. Double check you have saved all five new files and the two modified files (router and analytics screen).

ⓧ I'd like to double check the full code

Here are the five new files in full:

import 'dart:convert';

class QuizQuestion {
  final String question;
  final List<String> options;
  final int correctIndex;
  final String explanation;
  final String topic;

  const QuizQuestion({
    required this.question,
    required this.options,
    required this.correctIndex,
    required this.explanation,
    required this.topic,
  });

  factory QuizQuestion.fromJson(Map<String, dynamic> json) {
    return QuizQuestion(
      question: json['question'] as String,
      options: List<String>.from(json['options'] as List),
      correctIndex: json['correct_index'] as int,
      explanation: json['explanation'] as String,
      topic: json['topic'] as String? ?? 'General',
    );
  }

  static List<QuizQuestion> listFromRawJson(String raw) {
    final cleaned = raw.replaceAll(RegExp(r'```json|```'), '').trim();
    final decoded = jsonDecode(cleaned);
    final list = decoded['questions'] as List<dynamic>;
    return list.map((e) => QuizQuestion.fromJson(e as Map<String, dynamic>)).toList();
  }
}
import 'quiz_question.dart';

class QuizSession {
  final List<QuizQuestion> questions;
  final int currentIndex;
  final List<int?> selectedAnswers;
  final bool isComplete;

  const QuizSession({
    this.questions = const [],
    this.currentIndex = 0,
    this.selectedAnswers = const [],
    this.isComplete = false,
  });

  QuizSession copyWith({
    List<QuizQuestion>? questions,
    int? currentIndex,
    List<int?>? selectedAnswers,
    bool? isComplete,
  }) {
    return QuizSession(
      questions: questions ?? this.questions,
      currentIndex: currentIndex ?? this.currentIndex,
      selectedAnswers: selectedAnswers ?? this.selectedAnswers,
      isComplete: isComplete ?? this.isComplete,
    );
  }
}
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../core/models/quiz_question.dart';

final quizServiceProvider = Provider<QuizService>((ref) => QuizService());

class QuizService {
  final _supabase = Supabase.instance.client;

  Future<List<QuizQuestion>> generateQuiz(List<String> weakAreas) async {
    final model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    final topicsStr = weakAreas.join(', ');
    final prompt = '''
Generate 5 USMLE-style multiple choice questions targeting these medical topics: $topicsStr.
Respond ONLY with valid JSON:
{
  "questions": [
    {
      "question": "A 45-year-old man presents with...",
      "options": ["Option A", "Option B", "Option C", "Option D"],
      "correct_index": 0,
      "explanation": "The correct answer is A because...",
      "topic": "Cardiology"
    }
  ]
}
''';
    final response = await model.generateContent([Content.text(prompt)]);
    return QuizQuestion.listFromRawJson(response.text ?? '{"questions": []}');
  }

  Future<void> saveAttempt(String userId, String topic, bool correct) async {
    await _supabase.from('quiz_attempts').insert({
      'user_id': userId,
      'topic': topic,
      'correct': correct,
    });
  }
}
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/quiz_session.dart';
import '../services/quiz_service.dart';

final quizProvider = StateNotifierProvider<QuizNotifier, AsyncValue<QuizSession>>(
  (ref) => QuizNotifier(ref.read(quizServiceProvider)),
);

class QuizNotifier extends StateNotifier<AsyncValue<QuizSession>> {
  final QuizService _quizService;

  QuizNotifier(this._quizService) : super(const AsyncValue.data(QuizSession()));

  Future<void> loadQuiz(List<String> weakAreas) async {
    state = const AsyncValue.loading();
    try {
      final questions = await _quizService.generateQuiz(weakAreas);
      state = AsyncValue.data(QuizSession(
        questions: questions,
        selectedAnswers: List.filled(questions.length, null),
      ));
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  void selectAnswer(int questionIndex, int answerIndex) {
    state.whenData((session) {
      final updated = List<int?>.from(session.selectedAnswers)..[questionIndex] = answerIndex;
      state = AsyncValue.data(session.copyWith(selectedAnswers: updated));
    });
  }

  void nextQuestion() {
    state.whenData((session) {
      if (session.currentIndex < session.questions.length - 1) {
        state = AsyncValue.data(session.copyWith(currentIndex: session.currentIndex + 1));
      } else {
        state = AsyncValue.data(session.copyWith(isComplete: true));
      }
    });
  }

  Future<void> saveResults(String userId) async {
    await state.whenData((session) async {
      for (int i = 0; i < session.questions.length; i++) {
        final q = session.questions[i];
        final selected = session.selectedAnswers[i];
        if (selected != null) {
          await _quizService.saveAttempt(userId, q.topic, selected == q.correctIndex);
        }
      }
    });
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/quiz_provider.dart';
import '../../auth/providers/auth_provider.dart';

class QuizScreen extends ConsumerWidget {
  final List<String> weakAreas;
  const QuizScreen({super.key, required this.weakAreas});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final quizAsync = ref.watch(quizProvider);
    final user = ref.watch(authStateProvider);

    return quizAsync.when(
      loading: () => const Scaffold(body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Generating quiz...')]))),
      error: (e, _) => Scaffold(body: Center(child: Text('Error: $e'))),
      data: (session) {
        if (session.questions.isEmpty) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            ref.read(quizProvider.notifier).loadQuiz(weakAreas);
          });
          return const Scaffold(body: Center(child: CircularProgressIndicator()));
        }
        if (session.isComplete) {
          if (user != null) {
            ref.read(quizProvider.notifier).saveResults(user.id);
          }
          final correct = session.selectedAnswers.asMap().entries.where((e) => e.value == session.questions[e.key].correctIndex).length;
          return Scaffold(
            appBar: AppBar(title: const Text('Quiz Complete')),
            body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
              Text('$correct / ${session.questions.length}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)),
              const Text('Questions Correct'),
              const SizedBox(height: 24),
              ElevatedButton(onPressed: () => context.go('/analytics'), child: const Text('Back to Analytics')),
            ])),
          );
        }
        final q = session.questions[session.currentIndex];
        final selected = session.selectedAnswers[session.currentIndex];
        return Scaffold(
          appBar: AppBar(
            title: Text('Question ${session.currentIndex + 1} of ${session.questions.length}'),
            bottom: PreferredSize(
              preferredSize: const Size.fromHeight(4),
              child: LinearProgressIndicator(value: (session.currentIndex + 1) / session.questions.length),
            ),
          ),
          body: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(q.question, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                const SizedBox(height: 16),
                ...q.options.asMap().entries.map((e) {
                  Color? color;
                  if (selected != null) {
                    if (e.key == q.correctIndex) color = Colors.green[100];
                    else if (e.key == selected) color = Colors.red[100];
                  }
                  return GestureDetector(
                    onTap: selected == null ? () => ref.read(quizProvider.notifier).selectAnswer(session.currentIndex, e.key) : null,
                    child: AnimatedContainer(
                      duration: const Duration(milliseconds: 300),
                      margin: const EdgeInsets.only(bottom: 8),
                      padding: const EdgeInsets.all(12),
                      decoration: BoxDecoration(
                        color: color ?? Colors.grey[100],
                        border: Border.all(color: Colors.grey[300]!),
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: Text(e.value),
                    ),
                  );
                }),
                if (selected != null) ...[const SizedBox(height: 12), Text(q.explanation, style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey))],
                const Spacer(),
                if (selected != null) ElevatedButton(onPressed: () => ref.read(quizProvider.notifier).nextQuestion(), child: const Text('Next')),
              ],
            ),
          ),
        );
      },
    );
  }
}

Your app now has a complete adaptive learning loop. Weak areas feed into quiz generation, quiz results save back to Supabase, and the analytics recalculate on the next visit. Next up, you will add offline caching with Drift so the app works without an internet connection.

Add Local Caching with Drift

Your app works perfectly when the user has an internet connection. But what happens when they're on a subway, in a hospital basement, or somewhere with spotty Wi-Fi?

In this step, you'll add a local-first architecture using Drift. Every case session and score writes to a local SQLite database first, then syncs to Supabase in the background. If the network is unavailable, changes queue up and sync automatically when connectivity returns.

In this step, get ready to:
  • Define Drift table classes that mirror your key Supabase tables.
  • Build a sync service that writes locally first and queues background Supabase upserts.
  • Connect the sync service to your existing evaluation flow for offline resilience.
Set up the Drift database tables

Drift uses Dart classes to define your database schema. Each class that extends Table becomes a SQLite table, and Drift's code generator creates all the boilerplate for type-safe queries.

  • Create a new file at lib/core/database/app_database.dart and add the following code:
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'app_database.g.dart';

// Mirrors the case_sessions table in Supabase
class CachedCases extends Table {
  TextColumn get id => text()();
  TextColumn get userId => text()();
  TextColumn get specialty => text()();
  TextColumn get difficulty => text()();
  IntColumn get score => integer().nullable()();
  TextColumn get createdAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}

// Mirrors the performance_scores table in Supabase
class CachedScores extends Table {
  TextColumn get id => text()();
  TextColumn get userId => text()();
  TextColumn get topic => text()();
  IntColumn get score => integer()();
  TextColumn get source => text()();
  TextColumn get createdAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}

// Holds changes that haven't synced to Supabase yet
class PendingSync extends Table {
  TextColumn get id => text()();
  TextColumn get tableName => text()();
  TextColumn get payload => text()();
  TextColumn get createdAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}

// Registers all tables and opens the local SQLite database
@DriftDatabase(tables: [CachedCases, CachedScores, PendingSync])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(driftDatabase(name: 'cliniq_local'));

  @override
  int get schemaVersion => 1;
}

What does this code do?

Each table class defines columns that mirror a Supabase table. CachedCases stores completed case sessions. CachedScores stores performance scores.

PendingSync is the queue. When the app can't reach Supabase, it stores the table name and JSON payload here. The sync service reads this queue later and pushes each entry to Supabase.

The @DriftDatabase annotation tells the code generator which tables to include. The driftDatabase(name: 'cliniq_local') function from drift_flutter opens a platform-appropriate SQLite file automatically.

The part 'app_database.g.dart' directive points to the generated file that Drift creates with the actual query methods.

  • Save app_database.dart.
  • Generate the Drift code by running this command in your terminal:
dart run build_runner build
  • You should see output ending with a line like Succeeded after followed by a duration. The file lib/core/database/app_database.g.dart now exists.

Build runner failing?

  • If you see conflicting output errors, run dart run build_runner build --delete-conflicting-outputs instead.
  • Make sure build_runner and drift_dev are listed under dev_dependencies in your pubspec.yaml.
  • Check that the part 'app_database.g.dart'; line matches the filename exactly.

Why won't build_runner generate my Drift database?

Implement the sync service

The sync service is the bridge between your local Drift database and Supabase. It follows the optimistic local write pattern: save to SQLite immediately, then attempt a background sync to the cloud.

  • Create a new file at lib/core/services/sync_service.dart and add the providers and class setup:
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';

// Provide a single AppDatabase instance across the app
final appDatabaseProvider = Provider<AppDatabase>((ref) => AppDatabase());

// Provide the SyncService with access to the database
final syncServiceProvider = Provider<SyncService>((ref) {
  return SyncService(ref.read(appDatabaseProvider));
});

class SyncService {
  final AppDatabase _db;
  final _supabase = Supabase.instance.client;
  final _uuid = const Uuid();

  SyncService(this._db);

What does this setup do?

The appDatabaseProvider creates a single AppDatabase instance that the entire app shares. The syncServiceProvider injects that database into the SyncService class.

The Uuid instance generates unique IDs for each local record so they can be matched to their Supabase counterparts later.

  • Below the constructor, add the writeCase and writeScore methods:
  // Write a case session to local DB and queue for Supabase sync
  Future<void> writeCase(Map<String, dynamic> caseData) async {
    final id = _uuid.v4();
    await _db.into(_db.cachedCases).insert(CachedCasesCompanion(
      id: Value(id),
      userId: Value(caseData['user_id'] as String),
      specialty: Value(caseData['specialty'] as String),
      difficulty: Value(caseData['difficulty'] as String),
      score: Value(caseData['score'] as int?),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await _db.into(_db.pendingSync).insert(PendingSyncCompanion(
      id: Value(_uuid.v4()),
      tableName: const Value('case_sessions'),
      payload: Value(jsonEncode({...caseData, 'id': id})),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await syncPending();
  }

  // Write a performance score to local DB and queue for Supabase sync
  Future<void> writeScore(Map<String, dynamic> scoreData) async {
    final id = _uuid.v4();
    await _db.into(_db.cachedScores).insert(CachedScoresCompanion(
      id: Value(id),
      userId: Value(scoreData['user_id'] as String),
      topic: Value(scoreData['topic'] as String),
      score: Value(scoreData['score'] as int),
      source: Value(scoreData['source'] as String),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await _db.into(_db.pendingSync).insert(PendingSyncCompanion(
      id: Value(_uuid.v4()),
      tableName: const Value('performance_scores'),
      payload: Value(jsonEncode({...scoreData, 'id': id})),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await syncPending();
  }

What does this code do?

Both methods follow the same pattern. First, they insert the data into the local Drift table so it's immediately available offline. Then they add an entry to the PendingSync table with the raw JSON payload and the target Supabase table name.

After queuing, each method calls syncPending() to attempt an immediate push. If that fails (no internet), the entry stays in the queue for later.

  • Below the writeScore method, add the syncPending method and close the class:
  // Attempt to push all queued changes to Supabase
  Future<void> syncPending() async {
    try {
      final pending = await _db.select(_db.pendingSync).get();
      for (final item in pending) {
        try {
          final payload = jsonDecode(item.payload) as Map<String, dynamic>;
          await _supabase.from(item.tableName).upsert(payload);
          // Remove from queue on successful sync
          await (_db.delete(_db.pendingSync)..where((t) => t.id.equals(item.id))).go();
        } catch (_) {
          // Keep in queue for next sync attempt
        }
      }
    } catch (_) {
      // Offline - will retry later
    }
  }
}

How does offline detection work?

The outer try/catch handles the case where the device is completely offline. The inner try/catch handles individual row failures (e.g., a conflict on one row shouldn't block syncing the rest).

When Supabase is unreachable, the pending entries simply stay in the queue. The next time writeCase or writeScore is called, syncPending() fires again and retries all queued entries.

  • Save sync_service.dart.

Seeing import errors?

  • Make sure the uuid package is in your pubspec.yaml dependencies. If not, run flutter pub add uuid.
  • Verify that app_database.g.dart was generated in the previous substep. Without it, the CachedCasesCompanion and similar classes won't exist.

Why am I seeing unresolved imports in my sync service?

Connect the sync service to your evaluation flow

The sync service is ready, but nothing uses it yet. Your EvaluationNotifier currently writes case sessions and scores directly to Supabase. You need to route those writes through SyncService instead.

  • Open your EvaluationNotifier (in your patient encounter feature providers).
  • Add the SyncService as a dependency by importing sync_service.dart and passing it to the notifier through ref.read(syncServiceProvider).
  • Find the line(s) where you call _supabase.from('case_sessions').insert(...) and replace them with a call to _syncService.writeCase(caseData).
  • Find the line(s) where you call _supabase.from('performance_scores').insert(...) and replace them with _syncService.writeScore(scoreData).

Why route writes through SyncService?

By funneling all writes through SyncService, you get offline resilience for free. The notifier doesn't need to know whether the device is online or offline. It just calls writeCase and the sync service handles the rest.

This is the local-first pattern: optimistic writes to SQLite guarantee the user never loses data, and background sync keeps Supabase in sync when connectivity allows.

  • Save all modified files.
  • Run your app and complete a patient case. Check your Supabase dashboard to confirm the case session appeared in the case_sessions table.
  • To test offline mode: enable airplane mode on your emulator, complete another case, then disable airplane mode. The queued case should sync within a few seconds of reconnection.

Case not appearing in Supabase?

  • Verify your EvaluationNotifier is passing a Map<String, dynamic> with keys matching the Supabase column names (user_id, specialty, difficulty, score).
  • Check that your Row-Level Security policies on Supabase allow upserts for the authenticated user.
  • Look at the Supabase logs page for any rejected inserts.

Why aren't my locally cached cases syncing to Supabase?

✔️ Awesome, I've got everything!

Great. Double check you've saved both files and that app_database.g.dart was generated successfully.

ⓧ I'd like to double check the full code

Here is the complete app_database.dart:

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

part 'app_database.g.dart';

class CachedCases extends Table {
  TextColumn get id => text()();
  TextColumn get userId => text()();
  TextColumn get specialty => text()();
  TextColumn get difficulty => text()();
  IntColumn get score => integer().nullable()();
  TextColumn get createdAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}

class CachedScores extends Table {
  TextColumn get id => text()();
  TextColumn get userId => text()();
  TextColumn get topic => text()();
  IntColumn get score => integer()();
  TextColumn get source => text()();
  TextColumn get createdAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}

class PendingSync extends Table {
  TextColumn get id => text()();
  TextColumn get tableName => text()();
  TextColumn get payload => text()();
  TextColumn get createdAt => text()();

  @override
  Set<Column> get primaryKey => {id};
}

@DriftDatabase(tables: [CachedCases, CachedScores, PendingSync])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(driftDatabase(name: 'cliniq_local'));

  @override
  int get schemaVersion => 1;
}

Here is the complete sync_service.dart:

import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';

final appDatabaseProvider = Provider<AppDatabase>((ref) => AppDatabase());

final syncServiceProvider = Provider<SyncService>((ref) {
  return SyncService(ref.read(appDatabaseProvider));
});

class SyncService {
  final AppDatabase _db;
  final _supabase = Supabase.instance.client;
  final _uuid = const Uuid();

  SyncService(this._db);

  Future<void> writeCase(Map<String, dynamic> caseData) async {
    final id = _uuid.v4();
    await _db.into(_db.cachedCases).insert(CachedCasesCompanion(
      id: Value(id),
      userId: Value(caseData['user_id'] as String),
      specialty: Value(caseData['specialty'] as String),
      difficulty: Value(caseData['difficulty'] as String),
      score: Value(caseData['score'] as int?),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await _db.into(_db.pendingSync).insert(PendingSyncCompanion(
      id: Value(_uuid.v4()),
      tableName: const Value('case_sessions'),
      payload: Value(jsonEncode({...caseData, 'id': id})),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await syncPending();
  }

  Future<void> writeScore(Map<String, dynamic> scoreData) async {
    final id = _uuid.v4();
    await _db.into(_db.cachedScores).insert(CachedScoresCompanion(
      id: Value(id),
      userId: Value(scoreData['user_id'] as String),
      topic: Value(scoreData['topic'] as String),
      score: Value(scoreData['score'] as int),
      source: Value(scoreData['source'] as String),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await _db.into(_db.pendingSync).insert(PendingSyncCompanion(
      id: Value(_uuid.v4()),
      tableName: const Value('performance_scores'),
      payload: Value(jsonEncode({...scoreData, 'id': id})),
      createdAt: Value(DateTime.now().toIso8601String()),
    ));
    await syncPending();
  }

  Future<void> syncPending() async {
    try {
      final pending = await _db.select(_db.pendingSync).get();
      for (final item in pending) {
        try {
          final payload = jsonDecode(item.payload) as Map<String, dynamic>;
          await _supabase.from(item.tableName).upsert(payload);
          await (_db.delete(_db.pendingSync)..where((t) => t.id.equals(item.id))).go();
        } catch (_) {
          // Keep in queue for next sync attempt
        }
      }
    } catch (_) {
      // Offline - will retry later
    }
  }
}

Your app now has a local-first architecture. Data writes hit SQLite instantly, so the user never waits for a network round-trip. Supabase stays in sync silently in the background. This is the kind of resilient architecture that separates polished apps from fragile ones.

Secret mission

Add Teach Back Mode

What if your app could evaluate how well a student actually understands a topic, not just whether they can answer questions about it? In this secret mission, you'll build a "Teach Back" mode where the student explains a medical concept in their own words and Gemini evaluates their understanding. Then Gemini plays a confused student, asking follow-up questions that probe the gaps.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Supabase and Google AI Studio free tiers, so there are no ongoing costs while your resources sit idle.

Supabase free tier pausing

Supabase pauses free-tier projects after 1 week of inactivity. You can unpause them from the dashboard at any time, but your app will not respond to API calls while paused.

Resources you used:

  • Supabase project (cloud database, auth, and API).
  • Google AI Studio API key (Gemini API access).
  • Local Flutter project folder with Drift SQLite database.
  • Local .env file containing your API keys.

Keep everything running

No action needed. Choose this if you are still actively building or plan to come back soon.

  • Your Supabase project stays live on the free tier. It will auto-pause after 1 week of inactivity, but you can unpause it from the dashboard.
  • Your Gemini API key remains active on the free tier with no charges.
  • Your local project files and Drift database stay on your machine.

Pause - I'll come back to this later

Free up local resources while keeping your cloud services intact. Your Supabase project will auto-pause on its own after 1 week of inactivity.

  • Stop any running Flutter dev server by pressing Ctrl+C in the terminal.
  • Your Supabase project will auto-pause after 1 week of inactivity. No manual action needed.
  • Your Gemini API key stays active but incurs no charges on the free tier.
  • Keep your local project folder intact so you can resume later.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your data.

  • Delete your Supabase project by opening the Supabase dashboard, navigating to your project's Settings page, selecting General, scrolling to the bottom, and clicking Delete project. This permanently removes your database and auth users.
  • Delete your Gemini API key by opening the Google Cloud API Credentials page. Find your API key, click the Actions icon (three dots), and select Delete API key. Confirm by clicking Delete in the modal.
  • Delete your local project files by running the following commands in your terminal.

macOS

rm -rf ~/Desktop/cliniq

Windows

rmdir /s /q %USERPROFILE%\Desktop\cliniq
  • Remove the .env values from your system environment if you exported them to your shell profile.

That's a Wrap!

That's a Wrap!

Nice work! You just built ClinIQ, an AI-powered clinical reasoning trainer that solves a real problem for medical students.

You've learned how to:

  • Build a multi-turn AI patient conversation engine powered by Gemini with streaming responses and realistic patient personalities.
  • Create a real-time differential diagnosis tracker that updates confidence percentages as clinical questions are asked during a patient encounter.
  • Design a Supabase-backed performance analytics system with SQL aggregations and an adaptive AI quiz generator that targets weak areas.
  • Secret Mission: Add a Teach Back mode where Gemini evaluates a student's explanation and probes gaps in understanding with follow-up questions.

Ready to quiz yourself?