feat: Add student profile enrollment modification and name formatting

This commit is contained in:
ssbg04 committed 2026-07-06 19:55:27 +08:00
1 parent 7e528441ef
commit b15e52d39c
9 files changed
+798 -52

No files matched your search

@@ -36,13 +36,33 @@ class StudentModel {
}) : _latestGradeLevel = latestGradeLevel,
_latestSection = latestSection;
/// Display name: "De La Cruz, Juan Jr. M."
String get fullName {
final ext = extension != null && extension!.isNotEmpty ? ' ${extension!}' : '';
final mi = middleName != null && middleName!.isNotEmpty ? ' ${middleName![0]}.' : '';
return '$lastName, $firstName$ext$mi'.trim();
String _getValidExtension() {
if (extension == null || extension!.trim().isEmpty) return '';
// do not display the suffix with n/a regex
final RegExp naRegex = RegExp(r'^n\/?a$', caseSensitive: false);
if (naRegex.hasMatch(extension!.trim())) return '';
return extension!.trim();
}
/// Format: [Lastname, Firstname, suffix, middle name]
String get listDisplayName {
final extStr = _getValidExtension();
final ext = extStr.isNotEmpty ? ' $extStr' : '';
final mName = middleName != null && middleName!.isNotEmpty ? ' $middleName' : '';
return '$lastName, $firstName$ext$mName'.trim();
}
/// Format: [First Name, Middle name, last name, suffix]
String get profileDisplayName {
final extStr = _getValidExtension();
final ext = extStr.isNotEmpty ? ' $extStr' : '';
final mName = middleName != null && middleName!.isNotEmpty ? ' $middleName' : '';
return '$firstName$mName $lastName$ext'.trim();
}
/// Legacy fullName alias (maps to listDisplayName to avoid breaking existing code where not updated yet)
String get fullName => listDisplayName;
/// Traverses the enrollments list and dynamically resolves parameters matching
/// the highest value of academicYearId or the highest lexicographical string sequence in yearRange.
EnrollmentModel? get latestEnrollment {
@@ -281,4 +281,71 @@ class StudentRepository {
throw Exception(msg);
}
}
// ----------------------------------------------------------------
// Update Enrollment
// ----------------------------------------------------------------
Future<void> updateEnrollment({
required int enrollmentId,
required int academicYearId,
required int gradeLevel,
required int sectionId,
String? trackStrand,
}) async {
try {
final options = await _getAuthOptions();
await _dio.put(
'/students/enrollments/$enrollmentId',
data: {
'academicYearId': academicYearId,
'gradeLevel': gradeLevel,
'sectionId': sectionId,
'trackStrand': trackStrand,
},
options: options,
);
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to update enrollment.';
throw Exception(msg);
}
}
// ----------------------------------------------------------------
// Delete Enrollment
// ----------------------------------------------------------------
Future<void> deleteEnrollment(int enrollmentId) async {
try {
final options = await _getAuthOptions();
await _dio.delete('/students/enrollments/$enrollmentId', options: options);
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to delete enrollment.';
throw Exception(msg);
}
}
// ----------------------------------------------------------------
// Add Enrollment
// ----------------------------------------------------------------
Future<void> addEnrollment({
required int studentId,
required int academicYearId,
required int gradeLevel,
required int sectionId,
String? trackStrand,
}) async {
try {
final options = await _getAuthOptions();
await _dio.post(
'/students/$studentId/enrollments',
data: {
'academicYearId': academicYearId,
'gradeLevel': gradeLevel,
'sectionId': sectionId,
'trackStrand': trackStrand,
},
options: options,
);
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to add enrollment.';
throw Exception(msg);
}
}
}
@@ -325,4 +325,69 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
rethrow;
}
}
Future<void> updateEnrollment({
required int studentId,
required int enrollmentId,
required int academicYearId,
required int gradeLevel,
required int sectionId,
String? trackStrand,
}) async {
state = const AsyncLoading();
try {
final repo = ref.read(studentRepositoryProvider);
await repo.updateEnrollment(
enrollmentId: enrollmentId,
academicYearId: academicYearId,
gradeLevel: gradeLevel,
sectionId: sectionId,
trackStrand: trackStrand,
);
state = const AsyncData(null);
ref.invalidate(studentDetailProvider(studentId));
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
Future<void> deleteEnrollment({
required int studentId,
required int enrollmentId,
}) async {
state = const AsyncLoading();
try {
final repo = ref.read(studentRepositoryProvider);
await repo.deleteEnrollment(enrollmentId);
state = const AsyncData(null);
ref.invalidate(studentDetailProvider(studentId));
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
Future<void> addEnrollment({
required int studentId,
required int academicYearId,
required int gradeLevel,
required int sectionId,
String? trackStrand,
}) async {
state = const AsyncLoading();
try {
final repo = ref.read(studentRepositoryProvider);
await repo.addEnrollment(
studentId: studentId,
academicYearId: academicYearId,
gradeLevel: gradeLevel,
sectionId: sectionId,
trackStrand: trackStrand,
);
state = const AsyncData(null);
ref.invalidate(studentDetailProvider(studentId));
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
}
@@ -5,6 +5,7 @@ import '../../../../domain/repositories/document_repository.dart'
show MissingRequirements;
import '../../../providers/student_provider.dart';
import '../../../providers/document_provider.dart';
import '../../students/widgets/edit_enrollment_modal.dart';
// ─────────────────────────────────────────────────────────────
// Public helper – call this anywhere to show the modal
@@ -148,11 +149,38 @@ class StudentProfileModalBody extends ConsumerWidget {
_buildInfoCard(student),
const SizedBox(height: 20),
if (student.enrollments != null &&
student.enrollments!.isNotEmpty) ...[
const Text('Enrollments',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 10),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Enrollments',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
if (userRole == 'admin' || userRole == 'super_admin')
TextButton.icon(
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (ctx) => EditEnrollmentModal(
studentId: studentId,
enrollment: null,
),
);
},
icon: const Icon(Icons.add, size: 16),
label: const Text('Add Enrollment'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 10),
if (student.enrollments != null && student.enrollments!.isNotEmpty) ...[
...(() {
final sorted = List.from(student.enrollments!);
sorted.sort((a, b) =>
@@ -169,7 +197,7 @@ class StudentProfileModalBody extends ConsumerWidget {
}
return uniqueEnrollments
.map<Widget>((e) => _buildEnrollmentCard(e))
.map<Widget>((e) => _buildEnrollmentCard(context, ref, e))
.toList();
})(),
const SizedBox(height: 20),
@@ -233,7 +261,7 @@ class StudentProfileModalBody extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${student.firstName} ${student.middleName != null ? student.middleName! + ' ' : ''}${student.lastName}${student.extension?.isNotEmpty == true ? ', ${student.extension}' : ''}',
student.profileDisplayName,
style: const TextStyle(
fontSize: 17, fontWeight: FontWeight.bold),
),
@@ -353,7 +381,7 @@ class StudentProfileModalBody extends ConsumerWidget {
}
// ── Enrollment card ──────────────────────────────────────
Widget _buildEnrollmentCard(dynamic enrollment) {
Widget _buildEnrollmentCard(BuildContext context, WidgetRef ref, dynamic enrollment) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
@@ -392,6 +420,53 @@ class StudentProfileModalBody extends ConsumerWidget {
],
),
),
if (userRole == 'admin' || userRole == 'super_admin') ...[
IconButton(
icon: const Icon(Icons.edit, size: 18, color: Colors.blue),
tooltip: 'Edit Enrollment',
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (ctx) => EditEnrollmentModal(
studentId: studentId,
enrollment: enrollment,
),
);
},
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error),
tooltip: 'Delete Enrollment',
onPressed: () {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Enrollment'),
content: const Text(
'Are you sure you want to delete this enrollment? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('CANCEL'),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
ref.read(studentMutationProvider.notifier).deleteEnrollment(
studentId: studentId,
enrollmentId: enrollment.id,
);
},
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('DELETE'),
),
],
),
);
},
),
],
],
),
);
@@ -329,7 +329,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
children: [
Text(
'Are you sure you want to permanently delete '
'"${student.fullName}"? This cannot be undone.',
'"${student.listDisplayName}"? This cannot be undone.',
),
const SizedBox(height: 16),
const Text('Please enter your password to confirm:'),
@@ -430,7 +430,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
Future.microtask(() {
if (mounted) {
ref.read(openedFolderProvider.notifier).setFolder(
OpenedFolderData(id: student.id, name: student.fullName)
OpenedFolderData(id: student.id, name: student.listDisplayName)
);
}
});
@@ -1170,7 +1170,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
onTap: () => _viewProfile(student),
),
DataCell(
Text(student.fullName),
Text(student.listDisplayName),
onTap: () => _viewProfile(student),
),
DataCell(
@@ -1314,7 +1314,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
children: [
Expanded(
child: Text(
s.fullName,
s.listDisplayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -70,9 +70,11 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
int? _selectedSectionId;
String? _trackStrand;
bool _isEnrollmentInitialized = false;
// Cache of all enrollments loaded for the student being edited
List<dynamic>? _loadedEnrollments;
static const _statuses = ['Enrolled', 'Graduated', 'Transferred', 'Dropped'];
static const _extSuggestions = ['JR.', 'SR.', 'II', 'III', 'IV', 'V', 'VI'];
static const _extSuggestions = ['N/A', 'JR.', 'SR.', 'II', 'III', 'IV', 'V', 'VI'];
bool _showOcrStep = false;
String? _selectedOcrDocType;
@@ -105,10 +107,21 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
ref.invalidate(sectionsListProvider);
}
});
// Also refresh when the Enrollment tab is opened
_tabController.addListener(_onTabChanged);
}
void _onTabChanged() {
if (_tabController.index == 1 && mounted) {
ref.invalidate(academicYearsListProvider);
ref.invalidate(gradeLevelsListProvider);
ref.invalidate(sectionsListProvider);
}
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_lrnController.dispose();
_firstNameController.dispose();
@@ -492,6 +505,34 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
return;
}
// Validate enrollment academic year downgrade restriction (edit only)
// A student cannot be updated to a lower grade level within the same academic year.
if (widget.student != null) {
// Use _loadedEnrollments (fetched via studentDetailProvider) which is always
// populated in edit mode, unlike widget.student!.enrollments which may be null.
final enrollments = _loadedEnrollments;
if (enrollments != null && enrollments.isNotEmpty) {
final sameYearEnrollments = enrollments.where(
(e) => e.academicYearId == _selectedAcademicYearId,
).toList();
if (sameYearEnrollments.isNotEmpty) {
final existingMaxGrade = sameYearEnrollments
.map((e) => e.gradeLevel as int)
.reduce((a, b) => a > b ? a : b);
if (_selectedGradeLevel! < existingMaxGrade) {
_tabController.animateTo(1);
_showValidationDialog(
'Cannot downgrade enrollment. This student is already '
'in Grade $existingMaxGrade for this academic year. '
'You cannot change it to Grade $_selectedGradeLevel '
'in the same academic year.',
);
return;
}
}
}
}
setState(() => _isLoading = true);
try {
final notifier = ref.read(studentMutationProvider.notifier);
@@ -562,6 +603,12 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
if (widget.student != null && !_isEnrollmentInitialized) {
final detailsAsync = ref.watch(studentDetailProvider(widget.student!.id));
detailsAsync.whenData((fullStudent) {
// Cache the enrollment list for use in validation
if (_loadedEnrollments == null && fullStudent.enrollments != null) {
Future.microtask(() {
if (mounted) setState(() => _loadedEnrollments = fullStudent.enrollments);
});
}
if (fullStudent.enrollments != null &&
fullStudent.enrollments!.isNotEmpty) {
final latestEnrollment = fullStudent.enrollments!.reduce((a, b) {
@@ -809,7 +856,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
_SectionLabel(label: 'NAME'),
const SizedBox(height: AppSizes.p8),
// Names & Ext
// Names & Ext — order: First Name | Ext | Middle Name (row 1), Last Name (row 2)
LayoutBuilder(
builder: (ctx, c) {
final wide = c.maxWidth > 480;
@@ -817,8 +864,11 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// First Name
Expanded(
flex: 3,
child: TextFormField(
controller: _firstNameController,
textCapitalization: TextCapitalization.characters,
@@ -836,7 +886,18 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
),
),
const SizedBox(width: AppSizes.p12),
// Extension (right after first name)
Expanded(
flex: 2,
child: _ExtensionNameField(
controller: _extController,
suggestions: _extSuggestions,
),
),
const SizedBox(width: AppSizes.p12),
// Middle Name
Expanded(
flex: 3,
child: TextFormField(
controller: _middleNameController,
textCapitalization: TextCapitalization.characters,
@@ -852,32 +913,19 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
],
),
const SizedBox(height: AppSizes.p12),
Row(
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _lastNameController,
textCapitalization: TextCapitalization.characters,
inputFormatters: [_UpperCaseWordsFormatter()],
validator: (v) =>
_validateRequired(v, 'Last name'),
decoration: const InputDecoration(
labelText: 'LAST NAME',
prefixIcon: Icon(
Icons.badge_outlined,
),
),
),
// Last Name — full width
TextFormField(
controller: _lastNameController,
textCapitalization: TextCapitalization.characters,
inputFormatters: [_UpperCaseWordsFormatter()],
validator: (v) =>
_validateRequired(v, 'Last name'),
decoration: const InputDecoration(
labelText: 'LAST NAME',
prefixIcon: Icon(
Icons.badge_outlined,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: _ExtensionNameField(
controller: _extController,
suggestions: _extSuggestions,
),
),
],
),
),
],
);
@@ -895,6 +943,11 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
),
),
const SizedBox(height: AppSizes.p12),
_ExtensionNameField(
controller: _extController,
suggestions: _extSuggestions,
),
const SizedBox(height: AppSizes.p12),
TextFormField(
controller: _middleNameController,
textCapitalization: TextCapitalization.characters,
@@ -914,11 +967,6 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
labelText: 'LAST NAME',
),
),
const SizedBox(height: AppSizes.p12),
_ExtensionNameField(
controller: _extController,
suggestions: _extSuggestions,
),
],
);
}
@@ -1129,7 +1177,15 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
final matches = filtered.where((s) => s.id == _selectedSectionId);
final initialSectionName = matches.isNotEmpty ? matches.first.name : '';
// Key based on grade+year ensures Autocomplete fully rebuilds
// when grade level or academic year changes — fixes the bug
// where user had to type and erase before dropdown refreshed.
final autocompleteKey = ValueKey(
'section_${_selectedGradeLevel}_${_selectedAcademicYearId}',
);
return Autocomplete<SectionModel>(
key: autocompleteKey,
initialValue: TextEditingValue(text: initialSectionName),
displayStringForOption: (sec) => sec.name,
optionsBuilder: (textEditingValue) {
@@ -1572,6 +1628,7 @@ class _SectionLabel extends StatelessWidget {
// ================================================================
// EXTENSION NAME FIELD — text field with autocomplete suggestions
// Selecting 'N/A' clears the field so the stored value is null.
// ================================================================
class _ExtensionNameField extends StatelessWidget {
final TextEditingController controller;
@@ -1610,7 +1667,7 @@ class _ExtensionNameField extends StatelessWidget {
textCapitalization: TextCapitalization.characters,
inputFormatters: [_UpperCaseWordsFormatter()],
decoration: const InputDecoration(
labelText: 'EXTENSION NAME',
labelText: 'SUFFIX (Optional)',
hintText: 'Jr. / III',
prefixIcon: Icon(Icons.text_format),
),
@@ -1618,7 +1675,12 @@ class _ExtensionNameField extends StatelessWidget {
);
},
onSelected: (selection) {
controller.text = selection;
// 'N/A' means no extension — clear so stored value is null/empty
if (selection == 'N/A') {
controller.clear();
} else {
controller.text = selection;
}
},
optionsViewBuilder: (ctx, onSelected, options) {
return Align(
@@ -1635,6 +1697,7 @@ class _ExtensionNameField extends StatelessWidget {
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (ctx, index) {
final opt = options.elementAt(index);
final isNa = opt == 'N/A';
return InkWell(
onTap: () => onSelected(opt),
child: Padding(
@@ -1644,9 +1707,11 @@ class _ExtensionNameField extends StatelessWidget {
),
child: Text(
opt,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: isNa ? AppColors.textSecondary : null,
fontStyle: isNa ? FontStyle.italic : FontStyle.normal,
),
),
),
@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../shared/modals/custom_modal.dart';
import '../../../shared/buttons/primary_button.dart';
import '../../../providers/student_provider.dart';
import '../../../../domain/entities/student_model.dart';
import '../../../providers/setup_provider.dart';
import '../../../../domain/entities/setup_models.dart';
class EditEnrollmentModal extends ConsumerStatefulWidget {
final int studentId;
final dynamic enrollment; // If null, it's an Add operation. If set, it's an Edit operation.
const EditEnrollmentModal({
Key? key,
required this.studentId,
this.enrollment,
}) : super(key: key);
@override
ConsumerState<EditEnrollmentModal> createState() => _EditEnrollmentModalState();
}
class _EditEnrollmentModalState extends ConsumerState<EditEnrollmentModal> {
final _formKey = GlobalKey<FormState>();
int? _selectedAcademicYearId;
int? _selectedGradeLevel;
int? _selectedSectionId;
String? _trackStrand;
bool _isLoading = false;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.enrollment != null) {
_selectedAcademicYearId = widget.enrollment.academicYearId;
_selectedGradeLevel = widget.enrollment.gradeLevel;
_selectedSectionId = widget.enrollment.sectionId;
_trackStrand = widget.enrollment.trackStrand;
}
}
void _showValidationDialog(String message) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Invalid Update'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
)
],
),
);
}
Future<void> _handleSave() async {
if (!_formKey.currentState!.validate()) return;
if (_selectedSectionId == null) {
_showValidationDialog('Please select a valid section.');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final notifier = ref.read(studentMutationProvider.notifier);
if (widget.enrollment == null) {
// Add
await notifier.addEnrollment(
studentId: widget.studentId,
academicYearId: _selectedAcademicYearId!,
gradeLevel: _selectedGradeLevel!,
sectionId: _selectedSectionId!,
trackStrand: _trackStrand,
);
} else {
// Update
await notifier.updateEnrollment(
studentId: widget.studentId,
enrollmentId: widget.enrollment.id,
academicYearId: _selectedAcademicYearId!,
gradeLevel: _selectedGradeLevel!,
sectionId: _selectedSectionId!,
trackStrand: _trackStrand,
);
}
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
final raw = e.toString();
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
setState(() {
_isLoading = false;
_errorMessage = msg;
});
}
}
@override
Widget build(BuildContext context) {
final yearsAsync = ref.watch(academicYearsListProvider);
final gradeLevelsAsync = ref.watch(gradeLevelsListProvider);
final sectionsAsync = ref.watch(sectionsListProvider);
final viewInsets = MediaQuery.viewInsetsOf(context);
final screenHeight = MediaQuery.sizeOf(context).height;
final screenWidth = MediaQuery.sizeOf(context).width;
final isMobile = screenWidth < 600;
double maxDialogHeight = isMobile ? (screenHeight * 0.8) : 480;
double dialogHeight = maxDialogHeight.clamp(
200.0,
screenHeight - viewInsets.bottom - 24.0,
);
return CustomModal(
title: widget.enrollment == null ? 'Add Enrollment' : 'Edit Enrollment',
icon: widget.enrollment == null ? Icons.add_box_outlined : Icons.edit_document,
maxWidth: 450,
onClose: () => Navigator.of(context).pop(),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: dialogHeight,
),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Form(
key: _formKey,
child: Column(
children: [
// Academic Year
yearsAsync.when(
data: (years) {
return DropdownButtonFormField<int>(
key: ValueKey('academic_$_selectedAcademicYearId'),
initialValue: _selectedAcademicYearId,
decoration: const InputDecoration(
labelText: 'Academic Year',
prefixIcon: Icon(Icons.calendar_today),
),
items: years
.map((y) => DropdownMenuItem<int>(
value: y.id,
child: Text(y.yearRange),
))
.toList(),
onChanged: (val) {
setState(() {
_selectedAcademicYearId = val;
_selectedSectionId = null;
});
},
validator: (v) => v == null ? 'Academic year is required.' : null,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error: $err', style: const TextStyle(color: Colors.red)),
),
const SizedBox(height: AppSizes.p16),
// Grade Level
gradeLevelsAsync.when(
data: (grades) {
return DropdownButtonFormField<int>(
key: ValueKey('grade_$_selectedGradeLevel'),
initialValue: _selectedGradeLevel,
decoration: const InputDecoration(
labelText: 'Grade Level',
prefixIcon: Icon(Icons.grade),
),
items: grades
.map((g) => DropdownMenuItem<int>(
value: g.level,
child: Text(g.name),
))
.toList(),
onChanged: (val) {
setState(() {
_selectedGradeLevel = val;
_selectedSectionId = null;
});
},
validator: (v) => v == null ? 'Grade level is required.' : null,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error: $err', style: const TextStyle(color: Colors.red)),
),
const SizedBox(height: AppSizes.p16),
// Section
sectionsAsync.when(
data: (sections) {
final filtered = sections
.where(
(sec) =>
sec.academicYearId == _selectedAcademicYearId &&
sec.gradeLevel == _selectedGradeLevel,
)
.toList();
final matches = filtered.where((s) => s.id == _selectedSectionId);
final initialSectionName = matches.isNotEmpty ? matches.first.name : '';
final autocompleteKey = ValueKey(
'section_${_selectedGradeLevel}_${_selectedAcademicYearId}',
);
return Autocomplete<SectionModel>(
key: autocompleteKey,
initialValue: TextEditingValue(text: initialSectionName),
displayStringForOption: (sec) => sec.name,
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) {
return filtered;
}
return filtered.where((sec) =>
sec.name.toLowerCase().contains(textEditingValue.text.toLowerCase()));
},
onSelected: (sec) {
setState(() => _selectedSectionId = sec.id);
},
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
return TextFormField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Section (Type or Select)',
prefixIcon: Icon(Icons.segment),
suffixIcon: Icon(Icons.arrow_drop_down),
),
onChanged: (val) {
if (val.isEmpty) {
setState(() => _selectedSectionId = null);
} else {
final exactMatches =
filtered.where((s) => s.name.toLowerCase() == val.toLowerCase());
setState(() => _selectedSectionId =
exactMatches.isNotEmpty ? exactMatches.first.id : null);
}
},
validator: (v) {
if (v == null || v.isEmpty || _selectedSectionId == null) {
return 'Please select a valid section.';
}
return null;
},
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error: $err', style: const TextStyle(color: Colors.red)),
),
if (_selectedGradeLevel != null && _selectedGradeLevel! >= 11) ...[
const SizedBox(height: AppSizes.p16),
TextFormField(
initialValue: _trackStrand,
decoration: const InputDecoration(
labelText: 'Track & Strand (for SHS)',
prefixIcon: Icon(Icons.school_outlined),
),
onChanged: (val) => _trackStrand = val.trim().isEmpty ? null : val.trim(),
),
],
],
),
),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.08),
border: Border.all(color: AppColors.error.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: AppColors.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: AppColors.error, fontSize: 14),
),
),
],
),
),
],
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: AppSizes.p12,
runSpacing: AppSizes.p8,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text(
'CANCEL',
style: TextStyle(
color: AppColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
width: 150,
child: PrimaryButton(
label: 'SAVE',
isLoading: _isLoading,
onPressed: _handleSave,
),
),
],
),
],
),
),
);
}
}