Course Management

Learning management system with courses, lessons, and enrollments

A complete course management system demonstrating complex forms, multiple relationships, and analytics integration.

Overview

This example showcases:

  • Course entity with enums (category, difficulty)
  • One-to-many relationship (course → lessons)
  • Many-to-many relationship (course ↔ users via enrollments)
  • Rich detail layouts with analytics
  • Complex form with reference fields and conditional logic
  • Custom actions (duplicate course, bulk publish)

Entity Design

// packages/lms_entities/lib/course.dart
import 'package:json_annotation/json_annotation.dart';
import 'package:vyuh_entity_system/vyuh_entity_system.dart';

part 'course.g.dart';

@JsonSerializable(createToJson: true, fieldRename: FieldRename.snake)
class Course extends VersionedEntity {
  static const schemaName = 'lms.courses';

  static final typeDescriptor = TypeDescriptor(
    schemaType: schemaName,
    title: 'Course',
    fromJson: Course.fromJson,
  );

  // Core fields
  final String code;
  final String? description;
  final CourseCategory category;
  final CourseDifficulty difficulty;
  final DateTime startDate;

  Course({
    required super.id,
    required super.name,
    required this.code,
    this.description,
    required this.category,
    required this.difficulty,
    required this.startDate,
    super.versionNumber = 1,
    super.isActive = true,
    super.createdAt,
    super.updatedAt,
    super.createdBy,
    super.updatedBy,
    super.layout,
    super.modifiers,
  }) : super(schemaType: schemaName);

  factory Course.fromJson(Map<String, dynamic> json) => _$CourseFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$CourseToJson(this);

  // Helper: Days until start
  int get daysUntilStart {
    return startDate.difference(DateTime.now()).inDays;
  }
}

enum CourseCategory {
  @JsonValue('programming')
  programming,
  @JsonValue('design')
  design,
  @JsonValue('business')
  business,
  @JsonValue('science')
  science,
}

enum CourseDifficulty {
  @JsonValue('beginner')
  beginner,
  @JsonValue('intermediate')
  intermediate,
  @JsonValue('advanced')
  advanced,
}

Key Features:

  • Enums with @JsonValue for type-safe categories
  • Computed properties (daysUntilStart)
  • Optional description field
  • Required category and difficulty enums

Layouts

List Layouts

// features/feature_lms/lib/course/course_config.dart (excerpt)
layouts: EntityLayoutDescriptor<Course>(
  list: [
    // Card grid layout
    EntityLayout<Course>.grid(
      key: 'grid',
      title: 'Courses',
      columns: 3,
      aspectRatio: 1.2,
      builder: (context, course) => CourseCard(course: course),
    ),

    // Table layout
    EntityLayout<Course>.table(
      key: 'table',
      title: 'Courses Table',
      columns: 5,
      columnConfigs: [
        TableColumnConfig(key: 'code', label: 'Code', sortable: true),
        TableColumnConfig(key: 'name', label: 'Course', sortable: true, flex: 2),
        TableColumnConfig(key: 'category', label: 'Category', sortable: true),
        TableColumnConfig(key: 'difficulty', label: 'Level', sortable: true),
        TableColumnConfig(key: 'enrollments', label: 'Enrolled', width: 100),
      ],
    ),
  ],
  // ... detail layouts below
)

Detail Layouts

detail: [
  // Course overview with syllabus
  EntityLayout<Course>.detail(
    key: 'overview',
    title: 'Overview',
    builder: (context, course) => CourseOverviewLayout(course: course),
  ),

  // Lessons (one-to-many)
  EntityLayout<Course>.detail(
    key: 'lessons',
    title: 'Lessons',
    builder: (context, course) => RelatedEntitiesLayout<Course, Lesson>(
      parent: course,
      title: 'Course Lessons',
      emptyMessage: 'No lessons added yet',
      fetchRelated: (course) async {
        final api = context.read<LessonApi>();
        return api.list(filters: {'course_id': course.id});
      },
      actions: RelatedEntitiesActionsConfig(
        addConfig: EntityPickerConfig<Lesson>(
          title: 'Add Lesson',
          multiSelect: true,
        ),
      ),
    ),
  ),

  // Enrollments (many-to-many)
  EntityLayout<Course>.detail(
    key: 'enrollments',
    title: 'Enrollments',
    builder: (context, course) => CourseEnrollmentsLayout(course: course),
  ),

  // Analytics dashboard
  EntityLayout<Course>.detail(
    key: 'analytics',
    title: 'Analytics',
    builder: (context, course) => CourseAnalyticsLayout(course: course),
  ),
]

Layout Features:

  • Grid layout with custom card builder for visual appeal
  • Related lessons managed through RelatedEntitiesLayout
  • Custom enrollment layout with progress tracking
  • Analytics tab for course metrics

Form Configuration

// features/feature_lms/lib/course/course_config.dart (excerpt)
form: EntityFormDescriptor<Course>(
  builder: (context, entity) => vf.Form(
    title: entity == null ? 'Create Course' : 'Edit Course',
    autovalidateMode: AutovalidateMode.onUserInteraction,
    layout: vf.ColumnFormLayout(columns: 2, spacing: 24.0, runSpacing: 24.0),
    items: [
      // Course code (immutable after creation)
      vf.TextField(
        name: 'code',
        label: 'Course Code',
        initialValue: entity?.code,
        validators: [
          vf.requiredValidator,
          vf.patternValidator(r'^[A-Z0-9-]+$', 'Use only uppercase and numbers'),
        ],
        enabled: entity == null,
      ),

      // Course name
      vf.TextField(
        name: 'name',
        label: 'Course Name',
        initialValue: entity?.name,
        validators: [vf.requiredValidator, vf.maxLengthValidator(255)],
      ),

      // Description (multiline)
      vf.TextField(
        name: 'description',
        label: 'Description',
        initialValue: entity?.description,
        maxLines: 5,
      ),

      // Category dropdown
      vf.SelectField<CourseCategory>(
        name: 'category',
        label: 'Category',
        initialValue: entity?.category ?? CourseCategory.programming,
        options: CourseCategory.values.map((cat) =>
          vf.SelectOption(
            value: cat,
            label: cat.name.toUpperCase(),
          )
        ).toList(),
        validators: [vf.requiredValidator],
      ),

      // Difficulty dropdown
      vf.SelectField<CourseDifficulty>(
        name: 'difficulty',
        label: 'Difficulty Level',
        initialValue: entity?.difficulty ?? CourseDifficulty.beginner,
        options: CourseDifficulty.values.map((diff) =>
          vf.SelectOption(
            value: diff,
            label: diff.name.toUpperCase(),
          )
        ).toList(),
        validators: [vf.requiredValidator],
      ),

      // Start date
      vf.DateTimeField(
        name: 'start_date',
        label: 'Start Date',
        initialValue: entity?.startDate ?? DateTime.now(),
        validators: [vf.requiredValidator],
      ),
    ],
  ),
)

Form Features:

  • Pattern validation for course code (uppercase alphanumeric)
  • Enum dropdowns with proper serialization
  • Multiline description field
  • Date/time picker for start date
  • Two-column layout for compact presentation

Actions

// features/feature_lms/lib/course/course_config.dart (excerpt)
actions: EntityActionsDescriptor<Course>(
  inline: [
    EntityAction<Course>(
      key: 'edit',
      label: 'Edit',
      icon: Icons.edit,
      onExecute: (context, course) async {
        context.go('/courses/${course.id}/edit');
      },
    ),

    EntityAction<Course>(
      key: 'duplicate',
      label: 'Duplicate',
      icon: Icons.copy,
      onExecute: (context, course) async {
        final api = context.read<CourseApi>();
        final duplicated = await api.duplicate(course.id);

        if (context.mounted) {
          context.go('/courses/${duplicated.id}/edit');
        }
      },
    ),

    EntityAction<Course>(
      key: 'archive',
      label: 'Archive',
      icon: Icons.archive,
      showIf: (course) => course.isActive,
      onExecute: (context, course) async {
        final confirmed = await showConfirmDialog(
          context,
          title: 'Archive Course',
          message: 'Students will no longer be able to enroll.',
        );

        if (confirmed) {
          final api = context.read<CourseApi>();
          await api.archive(course.id);
        }
      },
    ),
  ],

  collection: [
    EntityAction<Course>(
      key: 'bulk_publish',
      label: 'Publish Selected',
      icon: Icons.publish,
      onExecute: (context, courses) async {
        final api = context.read<CourseApi>();

        for (final course in courses) {
          await api.publish(course.id);
        }

        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Published ${courses.length} courses')),
        );
      },
    ),

    EntityAction<Course>(
      key: 'export_catalog',
      label: 'Export Catalog',
      icon: Icons.download,
      onExecute: (context, courses) async {
        final pdf = await _generateCourseCatalog(courses);
        await FileSaver.instance.saveFile('catalog.pdf', pdf, 'pdf');
      },
    ),
  ],
)

Action Features:

  • Duplicate: Clone course for new session
  • Archive: Conditional visibility (only active courses)
  • Bulk Publish: Collection action with progress feedback
  • Export Catalog: Generate PDF from selected courses

API Implementation

// features/feature_lms/lib/course/course_api.dart
class CourseApi extends HttpVersionedEntityApi<Course> {
  CourseApi()
      : super(
          entityType: 'courses',
          schemaType: Course.schemaName,
          pathBuilder: VersionedEndpointBuilder(prefix: 'lms/courses'),
          fromJson: Course.fromJson,
        );

  // Duplicate course
  Future<Course> duplicate(String courseId) async {
    final uri = pathBuilder.buildUri('/$courseId/duplicate');
    final response = await vyuh.network.post(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to duplicate course: ${response.statusCode}');
    }

    final data = jsonDecode(response.body);
    return Course.fromJson(data);
  }

  // Publish course
  Future<void> publish(String courseId) async {
    final uri = pathBuilder.buildUri('/$courseId/publish');
    final response = await vyuh.network.post(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to publish course: ${response.statusCode}');
    }
  }

  // Get course enrollments with stats
  Future<List<EnrollmentWithStats>> getEnrollments(String courseId) async {
    final uri = pathBuilder.buildUri('/$courseId/enrollments');
    final response = await vyuh.network.get(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to load enrollments: ${response.statusCode}');
    }

    final data = jsonDecode(response.body);
    return (data as List)
        .map((json) => EnrollmentWithStats.fromJson(json))
        .toList();
  }

  // Get course analytics
  Future<CourseAnalytics> getAnalytics(String courseId) async {
    final uri = pathBuilder.buildUri('/$courseId/analytics');
    final response = await vyuh.network.get(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to load analytics: ${response.statusCode}');
    }

    final data = jsonDecode(response.body);
    return CourseAnalytics.fromJson(data);
  }
}

API Features:

  • Extends HttpVersionedEntityApi for standard CRUD operations
  • Custom endpoints for domain-specific operations (duplicate, publish, analytics)
  • Uses pathBuilder.buildUri() to construct endpoint paths
  • Access network via vyuh.network.get/post
  • Includes proper error handling with status code checks

Note: This assumes API endpoints are available at a base URL (e.g., http://localhost:3000 or your cloud endpoint) that will service these requests.


Custom Layouts

Course Overview

// features/feature_lms/lib/course/layouts/course_overview_layout.dart
class CourseOverviewLayout extends StatelessWidget {
  final Course course;

  const CourseOverviewLayout({required this.course});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(24),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Hero section with thumbnail
          if (course.thumbnailUrl != null)
            ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Image.network(
                course.thumbnailUrl!,
                height: 300,
                width: double.infinity,
                fit: BoxFit.cover,
              ),
            ),

          const SizedBox(height: 24),

          // Course metadata
          Wrap(
            spacing: 16,
            runSpacing: 8,
            children: [
              _MetadataChip(
                icon: Icons.category,
                label: course.category.name.toUpperCase(),
                color: _getCategoryColor(course.category),
              ),
              _MetadataChip(
                icon: Icons.signal_cellular_alt,
                label: course.difficulty.name.toUpperCase(),
                color: _getDifficultyColor(course.difficulty),
              ),
              _MetadataChip(
                icon: Icons.schedule,
                label: '${course.durationHours}h',
              ),
              _MetadataChip(
                icon: Icons.attach_money,
                label: course.price > 0
                    ? '\$${course.price.toStringAsFixed(2)}'
                    : 'FREE',
              ),
            ],
          ),

          const SizedBox(height: 24),

          // Description
          Text(
            'Description',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const SizedBox(height: 8),
          Text(
            course.description ?? 'No description provided',
            style: Theme.of(context).textTheme.bodyLarge,
          ),

          const SizedBox(height: 24),

          // Tags
          if (course.tags.isNotEmpty) ...[
            Text(
              'Tags',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: course.tags
                  .map((tag) => Chip(label: Text(tag)))
                  .toList(),
            ),
          ],
        ],
      ),
    );
  }
}

Course Analytics

// features/feature_lms/lib/course/layouts/course_analytics_layout.dart
class CourseAnalyticsLayout extends StatelessWidget {
  final Course course;

  const CourseAnalyticsLayout({required this.course});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<CourseAnalytics>(
      future: context.read<CourseApi>().getAnalytics(course.id),
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const Center(child: CircularProgressIndicator());
        }

        final analytics = snapshot.data!;

        return GridView.count(
          crossAxisCount: 3,
          padding: const EdgeInsets.all(24),
          crossAxisSpacing: 16,
          mainAxisSpacing: 16,
          children: [
            _StatCard(
              title: 'Total Enrollments',
              value: analytics.totalEnrollments.toString(),
              icon: Icons.people,
              color: Colors.blue,
            ),
            _StatCard(
              title: 'Completion Rate',
              value: '${analytics.completionRate.toStringAsFixed(1)}%',
              icon: Icons.check_circle,
              color: Colors.green,
            ),
            _StatCard(
              title: 'Avg. Progress',
              value: '${analytics.averageProgress.toStringAsFixed(0)}%',
              icon: Icons.trending_up,
              color: Colors.orange,
            ),
            _StatCard(
              title: 'Active Students',
              value: analytics.activeStudents.toString(),
              icon: Icons.school,
              color: Colors.purple,
            ),
            _StatCard(
              title: 'Revenue',
              value: '\$${analytics.revenue.toStringAsFixed(2)}',
              icon: Icons.attach_money,
              color: Colors.teal,
            ),
            _StatCard(
              title: 'Avg. Rating',
              value: analytics.averageRating.toStringAsFixed(1),
              icon: Icons.star,
              color: Colors.amber,
            ),
          ],
        );
      },
    );
  }
}

Key Takeaways

  1. Enum Integration: Use enums with @JsonValue for type-safe categories
  2. Computed Properties: Add getters for derived values (isCurrentlyActive)
  3. Rich Forms: Combine multiple field types (text, number, date, reference, tags)
  4. Custom Layouts: Build domain-specific views (overview, analytics)
  5. Relationship Types: Handle both one-to-many (lessons) and many-to-many (enrollments)
  6. Conditional Logic: Show/hide actions based on entity state
  7. Duplicate Pattern: Clone entities for template reuse
  8. Analytics Integration: Display metrics and insights

This pattern demonstrates how to build feature-rich educational platforms with complex data relationships and business logic.

On this page