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
@JsonValuefor 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
HttpVersionedEntityApifor 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
- Enum Integration: Use enums with
@JsonValuefor type-safe categories - Computed Properties: Add getters for derived values (
isCurrentlyActive) - Rich Forms: Combine multiple field types (text, number, date, reference, tags)
- Custom Layouts: Build domain-specific views (overview, analytics)
- Relationship Types: Handle both one-to-many (lessons) and many-to-many (enrollments)
- Conditional Logic: Show/hide actions based on entity state
- Duplicate Pattern: Clone entities for template reuse
- Analytics Integration: Display metrics and insights
This pattern demonstrates how to build feature-rich educational platforms with complex data relationships and business logic.