# How to Create an Interactive Learning Platform in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 60-90 min
- Compatibility: FlutterFlow Standard+ (Custom Actions for progress calculations)
- Last updated: March 2026

## TL;DR

Build a learning platform with a course catalog GridView backed by Firestore, an enrollment collection to track each user's progress, and three lesson types: video (YouTube embed), text (rich content), and quiz (scored questions). Store a computed completionPercentage field on the enrollment document and update it via FieldValue.increment rather than recalculating on every render. Generate completion certificates using a Cloud Function PDF generator triggered when completionPercentage reaches 100.

## Building a Learning Platform That Keeps Students Engaged

Online learning platforms share a well-defined pattern: a catalog of courses, a lesson sequence inside each course, progress tracking, and a reward (certificate) at completion. FlutterFlow can build all of these pieces using its visual builder for the UI and Firestore for the data layer. The key architectural decision is where to compute progress — computing it on every render from raw lesson completion data is expensive and slow. Storing a pre-computed completionPercentage on the enrollment document and updating it incrementally is the right approach for a platform that needs to scale.

## Before you start

- A FlutterFlow project with Firebase Auth and Firestore connected
- Firebase Blaze plan (for Cloud Functions used in certificate generation)
- FlutterFlow Standard plan or higher for Custom Actions
- Basic understanding of Firestore collections and subcollections

## Step-by-step guide

### 1. Design the Firestore Schema for Courses and Lessons

Create a 'courses' top-level collection with fields: title (String), description (String), thumbnailUrl (String), instructorName (String), totalLessons (Integer), estimatedMinutes (Integer), difficulty (String), category (String), publishedAt (Timestamp), isPublished (Boolean). Each course has a 'lessons' subcollection with: lessonIndex (Integer), title (String), type (String — 'video', 'text', 'quiz'), videoUrl (String, nullable), textContent (String, nullable), estimatedMinutes (Integer). Create an 'enrollments' collection at users/{userId}/enrollments/{courseId} with: enrolledAt (Timestamp), completedLessons (Array of lesson document IDs), completionPercentage (Double, 0-100), completedAt (Timestamp, nullable), certificateUrl (String, nullable).

**Expected result:** Your Firestore schema has courses, lessons subcollection, and user enrollments collection ready to receive data.

### 2. Build the Course Catalog GridView

Create a Course Catalog page with a GridView widget set to 2 columns (3 on tablet breakpoint). Each grid card shows: the course thumbnail image (16:9 aspect ratio), category label, course title, instructor name, estimated time, difficulty badge, and an enrollment status indicator. For the enrollment indicator, create a Firestore query on the current user's enrollments/{courseId} document — if it exists, show a 'Continue' button with progress bar; if it doesn't exist, show an 'Enroll' button. Add a search bar at the top to filter courses by title using FlutterFlow's local filtering action. Add a category filter row with horizontally scrolling chips.

**Expected result:** Course catalog displays all published courses in a grid with enrollment status shown on each card. Search and category filtering work without additional Firestore reads.

### 3. Build the Lesson Viewer with Three Lesson Types

Create a Lesson page that accepts a lessonId page parameter. Load the lesson document from Firestore and conditionally show the correct lesson type widget using Conditional widgets based on the lesson's 'type' field. Video lessons: use a YouTube player URL in a WebView widget (or the youtube_player_flutter Custom Widget for better control). Text lessons: use a scrollable Column with RichText for formatted content or a flutter_html Custom Widget for HTML content. Quiz lessons: use a Column with question Text, multiple choice RadioButton options, a Submit button, and a score display. Each lesson type includes a 'Mark Complete' button at the bottom that triggers the progress update action.

**Expected result:** Lesson page renders correctly for all three lesson types. Users can watch video lessons, read text content, submit quiz answers, and mark each lesson complete.

### 4. Update Enrollment Progress With FieldValue.increment

Create a Custom Action called 'markLessonComplete' that takes the courseId and lessonId. It first checks if the lesson is already in the completedLessons array to prevent duplicate completions. If not already completed, it performs a Firestore update using arrayUnion to add the lessonId to completedLessons, and calculates the new completionPercentage based on the course's totalLessons field. It also updates a lastAccessedAt timestamp. The action returns the new completionPercentage. After calling this action, update a Page State variable with the result for an immediate progress bar update.

```
import 'package:cloud_firestore/cloud_firestore.dart';

Future<double> markLessonComplete(
  String userId,
  String courseId,
  String lessonId,
  int totalLessons,
) async {
  final enrollmentRef = FirebaseFirestore.instance
      .collection('users')
      .doc(userId)
      .collection('enrollments')
      .doc(courseId);

  final enrollmentDoc = await enrollmentRef.get();
  if (!enrollmentDoc.exists) {
    debugPrint('Enrollment not found for course: $courseId');
    return 0.0;
  }

  final data = enrollmentDoc.data()!;
  final completedLessons = List<String>.from(
    (data['completedLessons'] as List? ?? []).map((e) => e.toString()),
  );

  // Already completed — return current percentage
  if (completedLessons.contains(lessonId)) {
    return (data['completionPercentage'] as num?)?.toDouble() ?? 0.0;
  }

  // Add lesson and recalculate percentage
  final newCompletedCount = completedLessons.length + 1;
  final newPercentage = totalLessons > 0
      ? (newCompletedCount / totalLessons) * 100
      : 0.0;

  final updateData = <String, dynamic>{
    'completedLessons': FieldValue.arrayUnion([lessonId]),
    'completionPercentage': newPercentage,
    'lastAccessedAt': FieldValue.serverTimestamp(),
  };

  if (newPercentage >= 100) {
    updateData['completedAt'] = FieldValue.serverTimestamp();
  }

  await enrollmentRef.update(updateData);
  return newPercentage;
}
```

**Expected result:** Marking a lesson complete updates the enrollment document with the new percentage. The course card in the catalog immediately reflects the updated progress.

### 5. Generate Completion Certificates With a Cloud Function

Create a Firebase Cloud Function triggered by a Firestore onCreate event on enrollment documents where completedAt is set. The function uses the pdfkit npm package to generate a certificate PDF: it fetches the user's name and email from the users collection, the course title from the courses collection, and generates a formatted PDF with the user's name, course name, completion date, and a unique certificate ID. Upload the PDF to Firebase Storage and update the enrollment document's certificateUrl field. In your FlutterFlow app, show a 'Download Certificate' button when certificateUrl is not null.

**Expected result:** When a user completes all lessons, a certificate PDF is automatically generated, stored in Firebase Storage, and a download link appears in their course completion screen.

## Complete code example

File: `certificate_generator.js`

```javascript
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const PDFDocument = require('pdfkit');
const { Storage } = require('@google-cloud/storage');

if (!admin.apps.length) admin.initializeApp();
const storage = new Storage();

exports.generateCertificate = functions.firestore
  .document('users/{userId}/enrollments/{courseId}')
  .onUpdate(async (change, context) => {
    const newData = change.after.data();
    const oldData = change.before.data();

    // Only trigger when completedAt is newly set
    if (!newData.completedAt || oldData.completedAt) return null;
    if (newData.certificateUrl) return null; // Already generated

    const { userId, courseId } = context.params;
    const db = admin.firestore();

    try {
      // Fetch user and course data
      const [userDoc, courseDoc] = await Promise.all([
        db.collection('users').doc(userId).get(),
        db.collection('courses').doc(courseId).get(),
      ]);

      if (!userDoc.exists || !courseDoc.exists) return null;

      const userName = userDoc.data().displayName || 'Student';
      const courseName = courseDoc.data().title;
      const completedDate = newData.completedAt.toDate().toLocaleDateString(
        'en-US', { year: 'numeric', month: 'long', day: 'numeric' }
      );
      const certId = `CERT-${userId.slice(0, 6).toUpperCase()}-${courseId.slice(0, 6).toUpperCase()}`;

      // Generate PDF in memory
      const doc = new PDFDocument({ size: 'A4', layout: 'landscape' });
      const chunks = [];
      doc.on('data', chunk => chunks.push(chunk));

      await new Promise(resolve => {
        doc.on('end', resolve);
        doc.font('Helvetica-Bold').fontSize(36)
          .text('Certificate of Completion', { align: 'center' });
        doc.moveDown();
        doc.font('Helvetica').fontSize(18)
          .text(`This certifies that`, { align: 'center' });
        doc.font('Helvetica-Bold').fontSize(28)
          .text(userName, { align: 'center' });
        doc.font('Helvetica').fontSize(18)
          .text(`has successfully completed`, { align: 'center' });
        doc.font('Helvetica-Bold').fontSize(22)
          .text(courseName, { align: 'center' });
        doc.font('Helvetica').fontSize(14)
          .text(`${completedDate} | Certificate ID: ${certId}`, { align: 'center' });
        doc.end();
      });

      const pdfBuffer = Buffer.concat(chunks);
      const filePath = `certificates/${userId}/${courseId}.pdf`;
      const bucket = storage.bucket(admin.app().options.storageBucket);
      const file = bucket.file(filePath);
      await file.save(pdfBuffer, { contentType: 'application/pdf' });
      await file.makePublic();
      const publicUrl = `https://storage.googleapis.com/${bucket.name}/${filePath}`;

      await change.after.ref.update({ certificateUrl: publicUrl, certificateId: certId });
      return null;
    } catch (error) {
      console.error('Certificate generation failed:', error);
      return null;
    }
  });
```

## Common mistakes

- **Calculating progress by counting completedLessons array length on every lesson render** — If the completedLessons array is on a different document than the one being displayed, calculating it on every render requires a Firestore read per page load. With 50 students viewing 20 lessons, that's 1,000 reads for a simple progress display. Fix: Store the pre-computed completionPercentage field on the enrollment document. Update it in the markLessonComplete action. Reading it is one field read per page load.
- **Not checking for duplicate lesson completion before incrementing progress** — If a user navigates back to a completed lesson and taps 'Mark Complete' again, the progress calculation adds the lesson twice. With 10 total lessons and 8 completed, a duplicate adds +10% making the percentage 90% instead of 80%. Fix: Check if the lessonId already exists in the completedLessons array before updating. If it does, return the existing percentage without modifying the document.
- **Loading all lessons in a course on the course detail page** — A course with 30 lessons requires 30 Firestore document reads just to show the lesson list on the course detail page. Reading full lesson content (including text fields) makes this even more expensive. Fix: Store a lightweight lesson index: lessonIndex, title, type, and estimatedMinutes — no content fields. Load only these lightweight fields for the course outline view. Load full lesson content only when a specific lesson is opened.

## Best practices

- Store pre-computed completionPercentage on the enrollment document — never calculate it from array length on render
- Create a lightweight lesson list view that excludes content fields, loaded separately only when a lesson is opened
- Auto-mark video lessons complete at 90% watch time rather than requiring manual button taps
- Display completion percentage as both a progress bar and a numeric label for accessibility
- Generate certificates server-side via Cloud Function — never trust client-side completion claims
- Use arrayUnion for completedLessons updates to prevent duplicate entries automatically
- Show a course recommendation section after completion to keep users engaged with your platform

## Frequently asked questions

### Can I sell courses with a paywall in my FlutterFlow learning platform?

Yes. Add a 'isPaid' Boolean and 'price' field to course documents. Create an enrollment flow that checks isPaid — free courses enroll directly, paid courses redirect to a Stripe Checkout Session via a Cloud Function. Create the enrollment document only after the Stripe webhook confirms payment success. Check for a valid enrollment document before allowing access to lesson content.

### How do I add video lessons if I don't want to use YouTube?

Upload course videos to Firebase Storage and store the download URL in the lesson document. Use the video_player Flutter package as a Custom Widget in FlutterFlow to play the videos. Note that Firebase Storage egress costs add up with video content — for production platforms, consider a dedicated video hosting service like Vimeo, Mux, or Cloudflare Stream, which offer better video compression and CDN delivery.

### How do I prevent users from skipping lessons to access later content?

Add a 'prerequisiteLessonId' field to each lesson. In your lesson navigation logic, check if the prerequisite lesson is in the completedLessons array before navigating to the next lesson. Show a locked state (greyed out with a lock icon) for inaccessible lessons in the course outline.

### How do I display a course completion percentage in the catalog without an extra Firestore read?

Query the user's enrollments collection when the course catalog page loads (or use a real-time listener). This returns all enrollment documents at once. Use a Custom Function to find the enrollment matching each course ID and return its completionPercentage. One enrollments query covers all courses rather than one read per course card.

### Can multiple instructors manage courses in my platform?

Yes. Add an 'instructorId' field to each course document. Create an admin section visible only to users with role == 'instructor'. In Firestore security rules, allow write access to courses only if request.auth.uid == resource.data.instructorId. This lets each instructor manage their own courses without seeing others'.

### How do I implement a discussion forum for each course?

Add a 'discussions' subcollection to each course document with fields: userId, displayName, message, createdAt, and a 'replies' subcollection for nested comments. Create a Discussion page that queries this subcollection ordered by createdAt. Use Firestore real-time listeners (enabled by default in FlutterFlow Firestore queries) for live updates when new messages are posted.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-an-interactive-learning-platform-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-an-interactive-learning-platform-in-flutterflow
