2326 lines
106 KiB
Dart
2326 lines
106 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:io' show Platform;
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'dart:ui';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../core/constants/app_colors.dart';
|
|
import '../../../core/constants/app_sizes.dart';
|
|
import '../../shared/inputs/custom_text_field.dart';
|
|
import '../../shared/dialogs/success_dialog.dart';
|
|
import '../../shared/dialogs/error_dialog.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/navigation_provider.dart';
|
|
import '../../../core/utils/validators.dart';
|
|
import 'requirements_settings_screen.dart';
|
|
import '../../shared/widgets/app_error_state.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:calendar_date_picker2/calendar_date_picker2.dart';
|
|
import '../../providers/setup_provider.dart';
|
|
import '../../shared/dialogs/info_dialog.dart';
|
|
import '../../shared/modals/custom_modal.dart';
|
|
import 'teacher_management_screen.dart';
|
|
import '../../../domain/entities/setup_models.dart';
|
|
import '../../providers/system_settings_provider.dart';
|
|
import '../../providers/theme_provider.dart';
|
|
import '../../providers/preferences_provider.dart';
|
|
import 'widgets/change_password_modal.dart';
|
|
import '../../../core/services/haptic_service.dart';
|
|
class TitleCaseTextInputFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
|
if (newValue.text.isEmpty) {
|
|
return newValue.copyWith(text: '');
|
|
}
|
|
|
|
String newText = '';
|
|
bool capitalizeNext = true;
|
|
|
|
for (int i = 0; i < newValue.text.length; i++) {
|
|
String char = newValue.text[i];
|
|
if (capitalizeNext && char.trim().isNotEmpty) {
|
|
newText += char.toUpperCase();
|
|
capitalizeNext = false;
|
|
} else {
|
|
newText += char;
|
|
}
|
|
|
|
if (char == ' ' || char == '-') {
|
|
capitalizeNext = true;
|
|
}
|
|
}
|
|
|
|
return TextEditingValue(
|
|
text: newText,
|
|
selection: newValue.selection,
|
|
);
|
|
}
|
|
}
|
|
|
|
class UpperCaseTextFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
|
return TextEditingValue(
|
|
text: newValue.text.toUpperCase(),
|
|
selection: newValue.selection,
|
|
);
|
|
}
|
|
}
|
|
|
|
class SettingsScreen extends ConsumerStatefulWidget {
|
|
final String? userRole;
|
|
const SettingsScreen({super.key, this.userRole});
|
|
|
|
@override
|
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|
// Profile controllers
|
|
final _firstNameCtrl = TextEditingController();
|
|
final _middleNameCtrl = TextEditingController();
|
|
final _lastNameCtrl = TextEditingController();
|
|
final _extCtrl = TextEditingController();
|
|
final _phoneCtrl = TextEditingController();
|
|
final _emailCtrl = TextEditingController();
|
|
|
|
final _profileFormKey = GlobalKey<FormState>();
|
|
|
|
int? _lastUserId;
|
|
bool _isProfileLoading = false;
|
|
ProviderSubscription<String>? _tabListener;
|
|
|
|
// Collapsible sections state
|
|
bool _isProfileExpanded = false;
|
|
bool _isAcademicYearExpanded = false;
|
|
bool _isAutoEnrollExpanded = false;
|
|
bool _isAppearanceExpanded = false;
|
|
|
|
void _collapseAllSections() {
|
|
_isProfileExpanded = false;
|
|
_isAcademicYearExpanded = false;
|
|
_isAutoEnrollExpanded = false;
|
|
_isAppearanceExpanded = false;
|
|
}
|
|
|
|
void _refreshAllSettingsData() {
|
|
ref.invalidate(academicYearsListProvider);
|
|
ref.invalidate(sectionsListProvider);
|
|
ref.invalidate(gradeLevelsListProvider);
|
|
ref.invalidate(systemSettingsProvider);
|
|
ref.invalidate(profileProvider);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_refreshAllSettingsData();
|
|
_tabListener = ref.listenManual<String>(activeTabProvider, (
|
|
previous,
|
|
next,
|
|
) {
|
|
if (!mounted) return;
|
|
if (previous == 'Settings' && next != 'Settings') {
|
|
setState(() {
|
|
_collapseAllSections();
|
|
});
|
|
}
|
|
if (next == 'Settings') {
|
|
_refreshAllSettingsData();
|
|
if (previous != 'Settings') {
|
|
setState(() {
|
|
_collapseAllSections();
|
|
});
|
|
_firstNameCtrl.clear();
|
|
_middleNameCtrl.clear();
|
|
_lastNameCtrl.clear();
|
|
_extCtrl.clear();
|
|
_phoneCtrl.clear();
|
|
_emailCtrl.clear();
|
|
_profileFormKey.currentState?.reset();
|
|
_lastUserId = null;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabListener?.close();
|
|
_firstNameCtrl.dispose();
|
|
_middleNameCtrl.dispose();
|
|
_lastNameCtrl.dispose();
|
|
_extCtrl.dispose();
|
|
_phoneCtrl.dispose();
|
|
_emailCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _hasProfileChanges() {
|
|
final user = ref.read(profileProvider).asData?.value;
|
|
if (user == null) return false;
|
|
|
|
return _firstNameCtrl.text.trim() != user.firstName ||
|
|
_middleNameCtrl.text.trim() != (user.middleName ?? '') ||
|
|
_lastNameCtrl.text.trim() != user.lastName ||
|
|
_extCtrl.text.trim() != (user.extension ?? '') ||
|
|
_phoneCtrl.text.trim() != (user.phone ?? '') ||
|
|
_emailCtrl.text.trim() != (user.email ?? '');
|
|
}
|
|
|
|
void _revertProfileChanges() {
|
|
final user = ref.read(profileProvider).asData?.value;
|
|
if (user != null) {
|
|
_firstNameCtrl.text = user.firstName;
|
|
_middleNameCtrl.text = user.middleName ?? '';
|
|
_lastNameCtrl.text = user.lastName;
|
|
_extCtrl.text = user.extension ?? '';
|
|
_phoneCtrl.text = user.phone ?? '';
|
|
_emailCtrl.text = user.email ?? '';
|
|
}
|
|
}
|
|
|
|
Future<bool> _promptToConfirmSave() async {
|
|
final ctrl = TextEditingController();
|
|
final dialogFormKey = GlobalKey<FormState>();
|
|
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) {
|
|
return AlertDialog(
|
|
title: const Text('Confirm Changes'),
|
|
content: Form(
|
|
key: dialogFormKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Type "CONFIRM" to save your profile changes.'),
|
|
const SizedBox(height: 16),
|
|
CustomTextField(
|
|
hintText: 'CONFIRM',
|
|
controller: ctrl,
|
|
validator: (v) {
|
|
if (v == null || v.trim().toUpperCase() != 'CONFIRM') {
|
|
return 'Please type CONFIRM';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('CANCEL', style: TextStyle(color: Colors.grey)),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (dialogFormKey.currentState!.validate()) {
|
|
Navigator.pop(ctx, true);
|
|
}
|
|
},
|
|
child: const Text(
|
|
'SAVE',
|
|
style: TextStyle(
|
|
color: AppColors.primaryGreen,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
return result ?? false;
|
|
}
|
|
|
|
Future<void> _handleUpdateProfile() async {
|
|
if (!_profileFormKey.currentState!.validate()) {
|
|
_revertProfileChanges();
|
|
return;
|
|
}
|
|
|
|
final isConfirmed = await _promptToConfirmSave();
|
|
if (!isConfirmed) {
|
|
_revertProfileChanges();
|
|
return;
|
|
}
|
|
|
|
setState(() => _isProfileLoading = true);
|
|
try {
|
|
final repo = ref.read(authRepositoryProvider);
|
|
await repo.updateProfile(
|
|
firstName: _firstNameCtrl.text.trim(),
|
|
middleName: _middleNameCtrl.text.trim(),
|
|
lastName: _lastNameCtrl.text.trim(),
|
|
extension: _extCtrl.text.trim(),
|
|
phone: _phoneCtrl.text.trim(),
|
|
email: _emailCtrl.text.trim(),
|
|
);
|
|
ref.invalidate(profileProvider);
|
|
if (!mounted) return;
|
|
showSuccessDialog(context, message: 'Profile updated successfully!');
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Update Failed',
|
|
e.toString().replaceAll('Exception: ', ''),
|
|
);
|
|
_revertProfileChanges();
|
|
} finally {
|
|
if (mounted) setState(() => _isProfileLoading = false);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
Future<void> _handleActivateAcademicYear(AcademicYearModel selectedYear) async {
|
|
try {
|
|
await ref.read(setupMutationProvider.notifier).updateAcademicYear(
|
|
id: selectedYear.id,
|
|
yearRange: selectedYear.yearRange,
|
|
status: 'active',
|
|
startDate: selectedYear.startDate,
|
|
endDate: selectedYear.endDate,
|
|
);
|
|
ref.invalidate(academicYearsListProvider);
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Academic Year ${selectedYear.yearRange} is now Active!',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Activation Failed',
|
|
e.toString().replaceAll('Exception: ', ''),
|
|
);
|
|
ref.invalidate(academicYearsListProvider);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleRunAutoGraduation(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
final isNarrow =
|
|
MediaQuery.of(ctx).size.width < 600 ||
|
|
Theme.of(ctx).platform == TargetPlatform.android;
|
|
return AlertDialog(
|
|
title: const Text('Confirm Auto-Graduation'),
|
|
content: const Text(
|
|
'This will check the active academic year schedule and automatically graduate eligible Grade 10 and Grade 12 students if the schedule end date has been reached.\n\nDo you want to proceed?',
|
|
),
|
|
actions: [
|
|
if (isNarrow)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('PROCEED'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('CANCEL'),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text(
|
|
'CANCEL',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('PROCEED'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (confirmed != true || !context.mounted) return;
|
|
|
|
try {
|
|
final res =
|
|
await ref.read(setupMutationProvider.notifier).checkAutoGraduation();
|
|
if (!context.mounted) return;
|
|
final executed = res['executed'] == true;
|
|
showInfoDialog(
|
|
context,
|
|
title:
|
|
executed ? 'Auto-Graduation Completed' : 'Auto-Graduation Skipped',
|
|
message:
|
|
res['message']?.toString() ??
|
|
res['reason']?.toString() ??
|
|
'Check completed.',
|
|
);
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Check Failed',
|
|
'Failed to run auto-graduation check: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.listen<String>(activeTabProvider, (previous, next) {
|
|
if (next == 'Settings') {
|
|
_refreshAllSettingsData();
|
|
}
|
|
});
|
|
final profileAsync = ref.watch(profileProvider);
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
if (_hasProfileChanges()) {
|
|
_handleUpdateProfile();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: SafeArea(
|
|
child: profileAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (err, _) => AppErrorState.fromError(
|
|
error: err,
|
|
onRetry: () => ref.invalidate(profileProvider),
|
|
),
|
|
data: (user) {
|
|
if (_lastUserId != user.id) {
|
|
Future.microtask(() {
|
|
if (mounted) {
|
|
_firstNameCtrl.text = user.firstName;
|
|
_middleNameCtrl.text = user.middleName ?? '';
|
|
_lastNameCtrl.text = user.lastName;
|
|
_extCtrl.text = user.extension ?? '';
|
|
_phoneCtrl.text = user.phone ?? '';
|
|
_emailCtrl.text = user.email ?? '';
|
|
setState(() => _lastUserId = user.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(AppSizes.p24, AppSizes.p32, AppSizes.p24, AppSizes.p32),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 700),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Text(
|
|
'Account Settings',
|
|
style: TextStyle(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p8),
|
|
Text(
|
|
'Manage your profile information and security settings.',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p32),
|
|
|
|
// ── Profile Card (Collapsible) ──────────────────────────
|
|
_buildCollapsibleCard(
|
|
title: 'Profile Details',
|
|
isExpanded: _isProfileExpanded,
|
|
onToggle: () => setState(() => _isProfileExpanded = !_isProfileExpanded),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const CircleAvatar(
|
|
radius: 24,
|
|
backgroundColor: AppColors.primaryGreen,
|
|
child: Icon(
|
|
Icons.person,
|
|
size: 24,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(width: AppSizes.p16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${user.firstName} ${user.lastName}',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Role: ${user.role.toUpperCase().replaceAll('_', ' ')}',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Theme.of(context).brightness == Brightness.dark
|
|
? AppColors.darkTextSecondary
|
|
: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_isProfileLoading)
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p20),
|
|
Form(
|
|
key: _profileFormKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
CustomTextField(
|
|
hintText: 'First Name',
|
|
prefixIcon: Icons.badge_outlined,
|
|
controller: _firstNameCtrl,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [TitleCaseTextInputFormatter()],
|
|
validator: (v) =>
|
|
AppValidators.validateRequired(v, 'First Name'),
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
CustomTextField(
|
|
hintText: 'Middle Name (Optional)',
|
|
prefixIcon: Icons.badge_outlined,
|
|
controller: _middleNameCtrl,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [TitleCaseTextInputFormatter()],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
CustomTextField(
|
|
hintText: 'Last Name',
|
|
prefixIcon: Icons.badge_outlined,
|
|
controller: _lastNameCtrl,
|
|
textCapitalization: TextCapitalization.words,
|
|
inputFormatters: [TitleCaseTextInputFormatter()],
|
|
validator: (v) =>
|
|
AppValidators.validateRequired(v, 'Last Name'),
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
Autocomplete<String>(
|
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
|
const commonExts = ['Jr.', 'Sr.', 'II', 'III', 'IV'];
|
|
if (textEditingValue.text.isEmpty) {
|
|
return commonExts;
|
|
}
|
|
return commonExts.where(
|
|
(ext) => ext
|
|
.toLowerCase()
|
|
.contains(textEditingValue.text.toLowerCase()),
|
|
);
|
|
},
|
|
onSelected: (String selection) {
|
|
_extCtrl.text = selection;
|
|
},
|
|
fieldViewBuilder: (
|
|
context,
|
|
controller,
|
|
focusNode,
|
|
onEditingComplete,
|
|
) {
|
|
controller.addListener(() {
|
|
if (_extCtrl.text != controller.text) {
|
|
_extCtrl.text = controller.text;
|
|
}
|
|
});
|
|
if (controller.text.isEmpty &&
|
|
_extCtrl.text.isNotEmpty) {
|
|
controller.text = _extCtrl.text;
|
|
}
|
|
return CustomTextField(
|
|
hintText: 'Suffix (Optional)',
|
|
prefixIcon: Icons.text_format,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
textCapitalization:
|
|
TextCapitalization.characters,
|
|
inputFormatters: [UpperCaseTextFormatter()],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
CustomTextField(
|
|
hintText: 'Phone Number (Starts with 09)',
|
|
prefixIcon: Icons.phone_outlined,
|
|
controller: _phoneCtrl,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
LengthLimitingTextInputFormatter(11),
|
|
],
|
|
keyboardType: TextInputType.phone,
|
|
validator: AppValidators.validatePhone,
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
CustomTextField(
|
|
hintText: 'Email Address',
|
|
prefixIcon: Icons.email_outlined,
|
|
controller: _emailCtrl,
|
|
keyboardType: TextInputType.emailAddress,
|
|
validator: AppValidators.validateEmail,
|
|
),
|
|
ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: _emailCtrl,
|
|
builder: (context, value, child) {
|
|
final text = value.text;
|
|
if (!text.contains('@')) return const SizedBox.shrink();
|
|
|
|
final parts = text.split('@');
|
|
final domainPart = parts.length > 1 ? parts[1].toLowerCase() : '';
|
|
|
|
final commonDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com'];
|
|
final suggestions = commonDomains.where((d) => d.startsWith(domainPart) && d != domainPart).toList();
|
|
|
|
if (suggestions.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Wrap(
|
|
spacing: 8.0,
|
|
children: suggestions.map((domain) => ActionChip(
|
|
visualDensity: VisualDensity.compact,
|
|
label: Text(domain, style: const TextStyle(fontSize: 12)),
|
|
onPressed: () {
|
|
final newText = '${parts[0]}@$domain';
|
|
_emailCtrl.text = newText;
|
|
_emailCtrl.selection = TextSelection.collapsed(offset: newText.length);
|
|
},
|
|
)).toList(),
|
|
),
|
|
);
|
|
}
|
|
),
|
|
if (_hasProfileChanges()) ...[
|
|
const SizedBox(height: AppSizes.p16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_revertProfileChanges();
|
|
});
|
|
},
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: _isProfileLoading ? null : _handleUpdateProfile,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 10,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: const Text('Save Profile'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: AppSizes.p24),
|
|
|
|
// ── Document Requirements Settings (Super Admin Only) ──
|
|
if (widget.userRole == 'admin') ...[
|
|
_buildCard(
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(
|
|
AppSizes.radiusLarge,
|
|
),
|
|
onTap: () => RequirementsModal.open(context),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Row(
|
|
children: [
|
|
Icon(
|
|
Icons.folder_copy,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
SizedBox(width: AppSizes.p8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Document Requirements',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
'Configure required documents for JHS and SHS',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: Colors.grey,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 10,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryGreen.withValues(
|
|
alpha: 0.06,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: AppColors.primaryGreen.withValues(
|
|
alpha: 0.2,
|
|
),
|
|
),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 16,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Define which documents are required for enrollment per grade level '
|
|
'(e.g., Form 137, Birth Certificate, Good Moral). Students with missing '
|
|
'required documents will appear in the Missing Docs dashboard tile.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p24),
|
|
_buildCard(
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(
|
|
AppSizes.radiusLarge,
|
|
),
|
|
onTap: () => TeacherManagementModal.open(context),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Row(
|
|
children: [
|
|
Icon(
|
|
Icons.people_alt,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
SizedBox(width: AppSizes.p8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Teachers & Academic Setup',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
'Manage teachers, academic years, grade levels, and sections',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: Colors.grey,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 10,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withValues(alpha: 0.06),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: Colors.blue.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 16,
|
|
color: Colors.blue,
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Set up academic years, grade levels, and sections. Assign teachers to '
|
|
'their sections so they can view and manage their students\' records. '
|
|
'Changes here affect enrollment options across the system.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.blue,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p24),
|
|
|
|
// ── Active Academic Year Schedule & Auto-Graduation (Super Admin Only) ──
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final academicYearsAsync = ref.watch(academicYearsListProvider);
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return _buildCollapsibleCard(
|
|
title: 'Academic Year & Auto-Graduation',
|
|
isExpanded: _isAcademicYearExpanded,
|
|
onToggle: () => setState(() => _isAcademicYearExpanded = !_isAcademicYearExpanded),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.school_rounded,
|
|
color: AppColors.primaryGreen,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: Text(
|
|
'Manage active academic year and auto-graduation schedule',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
tooltip: 'Refresh Academic Year',
|
|
onPressed: () => ref.invalidate(academicYearsListProvider),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
academicYearsAsync.when(
|
|
data: (years) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final activeYear = years.cast<AcademicYearModel?>().firstWhere(
|
|
(y) => y?.status == 'active',
|
|
orElse: () => null,
|
|
);
|
|
if (years.isEmpty) {
|
|
return const Text(
|
|
'No academic years found. Configure an academic year in Teachers & Academic Setup first.',
|
|
style: TextStyle(color: AppColors.warning, fontSize: 13),
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<int>(
|
|
key: ValueKey('ay_dropdown_${activeYear?.id}'),
|
|
initialValue: activeYear?.id,
|
|
decoration: InputDecoration(
|
|
labelText: 'Academic Year',
|
|
prefixIcon: const Icon(Icons.calendar_month, color: AppColors.primaryGreen),
|
|
helperText: 'Select an academic year to activate it system-wide.',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
),
|
|
items: years.map((y) {
|
|
final isActive = y.status == 'active';
|
|
return DropdownMenuItem<int>(
|
|
value: y.id,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
y.yearRange,
|
|
style: TextStyle(
|
|
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? AppColors.primaryGreen.withValues(alpha: 0.15)
|
|
: Colors.grey.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
isActive ? 'Active' : 'Inactive',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isActive ? AppColors.primaryGreen : Colors.grey,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (selectedId) async {
|
|
if (selectedId == null || selectedId == activeYear?.id) return;
|
|
final selectedYear = years.firstWhere((y) => y.id == selectedId);
|
|
await _handleActivateAcademicYear(selectedYear);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (activeYear == null) ...[
|
|
const Text(
|
|
'No active academic year found. Select an academic year from the dropdown above to activate it.',
|
|
style: TextStyle(color: AppColors.warning, fontSize: 13),
|
|
),
|
|
] else ...[
|
|
Wrap(
|
|
spacing: 20,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.calendar_today_outlined, size: 15, color: AppColors.primaryGreen),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Start: ${activeYear.startDate ?? "Not set"}',
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.event_available_outlined, size: 15, color: AppColors.primaryGreen),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'End: ${activeYear.endDate ?? "Not set"}',
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: isDark
|
|
? Colors.blue.withValues(alpha: 0.08)
|
|
: Colors.blue.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isDark
|
|
? Colors.blue.withValues(alpha: 0.2)
|
|
: Colors.blue.shade200,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Auto-graduation runs sequentially (Grade 10 then Grade 12) once end date is reached. Requires both dates set.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: isDark ? Colors.blue.shade300 : Colors.blue,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Builder(
|
|
builder: (ctx) {
|
|
final isNarrow =
|
|
MediaQuery.of(ctx).size.width < 650 ||
|
|
Theme.of(ctx).platform ==
|
|
TargetPlatform.android;
|
|
if (isNarrow) {
|
|
return Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.stretch,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () => showDialog(
|
|
context: context,
|
|
builder: (_) =>
|
|
_EditScheduleDatesDialog(
|
|
year: activeYear,
|
|
),
|
|
),
|
|
icon: const Icon(
|
|
Icons.date_range,
|
|
),
|
|
label: const Text(
|
|
'Edit Schedule Dates',
|
|
),
|
|
style:
|
|
OutlinedButton.styleFrom(
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
vertical: 14,
|
|
),
|
|
side: const BorderSide(
|
|
color: AppColors
|
|
.primaryGreen,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton.icon(
|
|
onPressed: () =>
|
|
_handleRunAutoGraduation(
|
|
context,
|
|
ref,
|
|
),
|
|
icon: const Icon(
|
|
Icons.school,
|
|
),
|
|
label: const Text(
|
|
'Check / Run Auto-Graduation Now',
|
|
),
|
|
style:
|
|
FilledButton.styleFrom(
|
|
backgroundColor:
|
|
AppColors
|
|
.primaryGreen,
|
|
foregroundColor:
|
|
isDark ? Colors.white : Colors.black,
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
vertical: 14,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => showDialog(
|
|
context: context,
|
|
builder: (_) =>
|
|
_EditScheduleDatesDialog(
|
|
year: activeYear,
|
|
),
|
|
),
|
|
icon: const Icon(
|
|
Icons.date_range,
|
|
),
|
|
label: const Text(
|
|
'Edit Schedule Dates',
|
|
),
|
|
style:
|
|
OutlinedButton.styleFrom(
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
vertical: 14,
|
|
),
|
|
side: const BorderSide(
|
|
color: AppColors
|
|
.primaryGreen,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: () =>
|
|
_handleRunAutoGraduation(
|
|
context,
|
|
ref,
|
|
),
|
|
icon: const Icon(
|
|
Icons.school,
|
|
),
|
|
label: const Text(
|
|
'Check / Run Auto-Graduation Now',
|
|
),
|
|
style:
|
|
FilledButton.styleFrom(
|
|
backgroundColor:
|
|
AppColors
|
|
.primaryGreen,
|
|
foregroundColor:
|
|
isDark ? Colors.white : Colors.black,
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
vertical: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (err, _) => Text('Error loading academic years: $err', style: const TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: AppSizes.p24),
|
|
|
|
// ── Student Enrollment Auto-Update (Admin Only) ──
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final sysSettingsAsync = ref.watch(systemSettingsProvider);
|
|
final settingsMap = sysSettingsAsync.asData?.value ?? {};
|
|
final isAutoEnrollEnabled =
|
|
(settingsMap['auto_update_enrollment_from_sf'] ?? 'true') == 'true';
|
|
final frequency = settingsMap['auto_update_enrollment_from_sf_frequency'] ?? 'immediate';
|
|
final timeVal = settingsMap['auto_update_enrollment_from_sf_time'] ?? '00:00';
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return _buildCollapsibleCard(
|
|
title: 'Student Enrollment Auto-Update',
|
|
isExpanded: _isAutoEnrollExpanded,
|
|
onToggle: () => setState(() => _isAutoEnrollExpanded = !_isAutoEnrollExpanded),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.auto_awesome_outlined,
|
|
color: AppColors.primaryGreen,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Auto-Update Enrollment',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Automatically extract Academic Year, Grade Level, and Section from SF10/SF9 documents',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: isAutoEnrollEnabled,
|
|
activeThumbColor: AppColors.primaryGreen,
|
|
onChanged: (val) {
|
|
ref
|
|
.read(systemSettingsProvider.notifier)
|
|
.updateSetting(
|
|
'auto_update_enrollment_from_sf',
|
|
val ? 'true' : 'false',
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
if (!isAutoEnrollEnabled)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Text(
|
|
'Auto-update from SF10/SF9 is currently disabled. Toggle the switch above to enable automatic extraction and updates.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
|
),
|
|
),
|
|
),
|
|
if (isAutoEnrollEnabled) ...[
|
|
Text(
|
|
'Update Frequency',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_buildFrequencyChip(
|
|
ref: ref,
|
|
label: 'Immediate',
|
|
value: 'immediate',
|
|
currentValue: frequency,
|
|
),
|
|
_buildFrequencyChip(
|
|
ref: ref,
|
|
label: 'Daily',
|
|
value: 'daily',
|
|
currentValue: frequency,
|
|
),
|
|
_buildFrequencyChip(
|
|
ref: ref,
|
|
label: 'Weekly',
|
|
value: 'weekly',
|
|
currentValue: frequency,
|
|
),
|
|
_buildFrequencyChip(
|
|
ref: ref,
|
|
label: 'Monthly',
|
|
value: 'monthly',
|
|
currentValue: frequency,
|
|
),
|
|
],
|
|
),
|
|
if (frequency != 'immediate') ...[
|
|
const SizedBox(height: AppSizes.p16),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.access_time, size: 18, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'Execution Time:',
|
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(width: 12),
|
|
DropdownButton<String>(
|
|
value: _validTimeValue(timeVal),
|
|
underline: const SizedBox(),
|
|
items: _buildTimeDropdownItems(),
|
|
onChanged: (newTime) {
|
|
if (newTime != null) {
|
|
ref.read(systemSettingsProvider.notifier).updateSetting(
|
|
'auto_update_enrollment_from_sf_time',
|
|
newTime,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: AppSizes.p24),
|
|
],
|
|
|
|
// ── Appearance & Feedback Card (Collapsible) ───────────
|
|
_buildCollapsibleCard(
|
|
title: 'Appearance & Feedback',
|
|
isExpanded: _isAppearanceExpanded,
|
|
onToggle: () => setState(() => _isAppearanceExpanded = !_isAppearanceExpanded),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.palette_outlined,
|
|
color: AppColors.primaryGreen,
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Theme Mode',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Choose your preferred theme mode.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSizes.p16),
|
|
Consumer(
|
|
builder: (context, ref, child) {
|
|
final themeNotifier =
|
|
ref.watch(themeModeProvider.notifier);
|
|
final currentKey = themeNotifier.currentKey;
|
|
|
|
return Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
_buildThemeOptionChip(
|
|
label: 'System',
|
|
icon: Icons.brightness_auto_rounded,
|
|
value: 'system',
|
|
currentValue: currentKey,
|
|
onSelect: () => themeNotifier
|
|
.setThemeMode('system'),
|
|
),
|
|
_buildThemeOptionChip(
|
|
label: 'Light',
|
|
icon: Icons.light_mode_rounded,
|
|
value: 'light',
|
|
currentValue: currentKey,
|
|
onSelect: () => themeNotifier
|
|
.setThemeMode('light'),
|
|
),
|
|
_buildThemeOptionChip(
|
|
label: 'Dark',
|
|
icon: Icons.dark_mode_rounded,
|
|
value: 'dark',
|
|
currentValue: currentKey,
|
|
onSelect: () => themeNotifier
|
|
.setThemeMode('dark'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 32),
|
|
// Sound Effects Toggle
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final isSoundEnabled = ref.watch(soundEnabledProvider);
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.volume_up_outlined,
|
|
color: AppColors.primaryGreen,
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Sound Effects',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Play audio feedback on user actions and alerts',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: isSoundEnabled,
|
|
activeThumbColor: AppColors.primaryGreen,
|
|
onChanged: (val) {
|
|
ref
|
|
.read(soundEnabledProvider.notifier)
|
|
.setSoundEnabled(val);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
if (kIsWeb || !Platform.isWindows) ...[
|
|
const SizedBox(height: AppSizes.p16),
|
|
// Vibration & Haptics Toggle
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final isVibrationEnabled = ref.watch(vibrationEnabledProvider);
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.vibration_rounded,
|
|
color: AppColors.primaryGreen,
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: AppSizes.p12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Vibration & Haptics',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Enable haptic feedback on touch interactions (mobile)',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: isVibrationEnabled,
|
|
activeThumbColor: AppColors.primaryGreen,
|
|
onChanged: (val) {
|
|
ref
|
|
.read(vibrationEnabledProvider.notifier)
|
|
.setVibrationEnabled(val);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: AppSizes.p24),
|
|
|
|
// ── Change Password Card ──────────────────────────────
|
|
_buildCard(
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(
|
|
AppSizes.radiusLarge,
|
|
),
|
|
onTap: () => ChangePasswordModal.show(context),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.lock_outline,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
const SizedBox(width: AppSizes.p8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Change Password',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
'Set a new password for your account',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurface
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right,
|
|
color: Colors.grey,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: AppSizes.p48),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Top Blur Overlay
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 60,
|
|
child: IgnorePointer(
|
|
child: ShaderMask(
|
|
shaderCallback: (rect) {
|
|
return const LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.black, Colors.transparent],
|
|
stops: [0.6, 1.0],
|
|
).createShader(rect);
|
|
},
|
|
blendMode: BlendMode.dstIn,
|
|
child: ClipRect(
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: isDark
|
|
? [
|
|
AppColors.darkPageBackground.withValues(alpha: 0.85),
|
|
AppColors.darkPageBackground.withValues(alpha: 0.15),
|
|
]
|
|
: [
|
|
Colors.white.withValues(alpha: 0.85),
|
|
Colors.white.withValues(alpha: 0.15),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Bottom Blur Overlay
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 60,
|
|
child: IgnorePointer(
|
|
child: ShaderMask(
|
|
shaderCallback: (rect) {
|
|
return const LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.transparent, Colors.black],
|
|
stops: [0.0, 0.4],
|
|
).createShader(rect);
|
|
},
|
|
blendMode: BlendMode.dstIn,
|
|
child: ClipRect(
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: isDark
|
|
? [
|
|
AppColors.darkPageBackground.withValues(alpha: 0.0),
|
|
AppColors.darkPageBackground.withValues(alpha: 0.85),
|
|
]
|
|
: [
|
|
Colors.white.withValues(alpha: 0.0),
|
|
Colors.white.withValues(alpha: 0.85),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCard({required Widget child}) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: isDark
|
|
? Colors.black.withValues(alpha: 0.25)
|
|
: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget _buildCollapsibleCard({
|
|
required String title,
|
|
required bool isExpanded,
|
|
required VoidCallback onToggle,
|
|
required Widget child,
|
|
}) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: isDark
|
|
? Colors.black.withValues(alpha: 0.25)
|
|
: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: () {
|
|
HapticService.light();
|
|
onToggle();
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSizes.p24),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
AnimatedRotation(
|
|
turns: isExpanded ? 0.5 : 0.0,
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeInOut,
|
|
child: Icon(
|
|
Icons.keyboard_arrow_down_rounded,
|
|
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
|
size: 24,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
AnimatedCrossFade(
|
|
firstChild: const SizedBox(width: double.infinity, height: 0),
|
|
secondChild: Padding(
|
|
padding: const EdgeInsets.fromLTRB(AppSizes.p24, 0, AppSizes.p24, AppSizes.p24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Divider(),
|
|
const SizedBox(height: AppSizes.p16),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
crossFadeState: isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
|
duration: const Duration(milliseconds: 250),
|
|
sizeCurve: Curves.easeInOut,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFrequencyChip({
|
|
required WidgetRef ref,
|
|
required String label,
|
|
required String value,
|
|
required String currentValue,
|
|
}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isSelected = currentValue == value;
|
|
return ChoiceChip(
|
|
label: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isSelected
|
|
? Colors.white
|
|
: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
selected: isSelected,
|
|
selectedColor: AppColors.primaryGreen,
|
|
backgroundColor: isDark ? AppColors.darkSurface2 : AppColors.pageBackground,
|
|
onSelected: (_) {
|
|
ref.read(systemSettingsProvider.notifier).updateSetting(
|
|
'auto_update_enrollment_from_sf_frequency',
|
|
value,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildThemeOptionChip({
|
|
required String label,
|
|
required IconData icon,
|
|
required String value,
|
|
required String currentValue,
|
|
required VoidCallback onSelect,
|
|
}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isSelected = value == currentValue;
|
|
return ChoiceChip(
|
|
showCheckmark: false,
|
|
avatar: Icon(
|
|
icon,
|
|
size: 18,
|
|
color: isSelected ? Colors.white : AppColors.primaryGreen,
|
|
),
|
|
label: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isSelected
|
|
? Colors.white
|
|
: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
selected: isSelected,
|
|
selectedColor: AppColors.primaryGreen,
|
|
backgroundColor: isDark ? AppColors.darkSurface2 : AppColors.pageBackground,
|
|
onSelected: (_) => onSelect(),
|
|
);
|
|
}
|
|
|
|
String _validTimeValue(String? val) {
|
|
const validTimes = [
|
|
'00:00', '01:00', '02:00', '03:00', '04:00', '05:00',
|
|
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00',
|
|
'12:00', '13:00', '14:00', '15:00', '16:00', '17:00',
|
|
'18:00', '19:00', '20:00', '21:00', '22:00', '23:00',
|
|
];
|
|
if (val != null && validTimes.contains(val)) return val;
|
|
return '00:00';
|
|
}
|
|
|
|
List<DropdownMenuItem<String>> _buildTimeDropdownItems() {
|
|
const times = [
|
|
'00:00', '01:00', '02:00', '03:00', '04:00', '05:00',
|
|
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00',
|
|
'12:00', '13:00', '14:00', '15:00', '16:00', '17:00',
|
|
'18:00', '19:00', '20:00', '21:00', '22:00', '23:00',
|
|
];
|
|
return times.map((t) {
|
|
final parts = t.split(':');
|
|
final hour = int.parse(parts[0]);
|
|
final suffix = hour >= 12 ? 'PM' : 'AM';
|
|
final displayHour = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour);
|
|
final displayTime = '${displayHour.toString().padLeft(2, '0')}:00 $suffix';
|
|
return DropdownMenuItem<String>(
|
|
value: t,
|
|
child: Text(displayTime, style: const TextStyle(fontSize: 14)),
|
|
);
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
class _TransferProgressDialog extends StatefulWidget {
|
|
final String title;
|
|
final String initialStatus;
|
|
final Future<void> Function(
|
|
void Function(int count, int total) onProgress,
|
|
CancelToken cancelToken,
|
|
) action;
|
|
|
|
const _TransferProgressDialog({
|
|
required this.title,
|
|
required this.initialStatus,
|
|
required this.action,
|
|
});
|
|
|
|
@override
|
|
State<_TransferProgressDialog> createState() => _TransferProgressDialogState();
|
|
}
|
|
|
|
class _TransferProgressDialogState extends State<_TransferProgressDialog> {
|
|
final CancelToken _cancelToken = CancelToken();
|
|
double? _progress;
|
|
String _statusText = '';
|
|
String _etaText = 'Calculating ETA...';
|
|
String _rateText = '';
|
|
DateTime? _startTime;
|
|
DateTime? _lastTime;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_statusText = widget.initialStatus;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_startAction();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onProgress(int count, int total) {
|
|
if (!mounted) return;
|
|
|
|
final now = DateTime.now();
|
|
_startTime ??= now;
|
|
_lastTime ??= now;
|
|
|
|
double? p;
|
|
String status;
|
|
String eta = 'Calculating...';
|
|
String rateStr = '';
|
|
|
|
if (total > 0) {
|
|
p = count / total;
|
|
final mbCount = (count / (1024 * 1024)).toStringAsFixed(1);
|
|
final mbTotal = (total / (1024 * 1024)).toStringAsFixed(1);
|
|
final pct = (p * 100).toStringAsFixed(0);
|
|
status = '$pct% ($mbCount MB / $mbTotal MB)';
|
|
|
|
final elapsedSec = now.difference(_startTime!).inMilliseconds / 1000.0;
|
|
if (elapsedSec > 0.5 && count > 0) {
|
|
final rateBytesPerSec = count / elapsedSec;
|
|
final rateMbPerSec = (rateBytesPerSec / (1024 * 1024)).toStringAsFixed(2);
|
|
rateStr = '$rateMbPerSec MB/s';
|
|
|
|
final remainingBytes = total - count;
|
|
if (rateBytesPerSec > 0) {
|
|
final remainingSec = remainingBytes / rateBytesPerSec;
|
|
final mins = (remainingSec / 60).floor();
|
|
final secs = (remainingSec % 60).round();
|
|
if (mins > 0) {
|
|
eta = '${mins}m ${secs}s remaining';
|
|
} else {
|
|
eta = '${secs}s remaining';
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
final mbCount = (count / (1024 * 1024)).toStringAsFixed(1);
|
|
status = '$mbCount MB transferred';
|
|
}
|
|
|
|
setState(() {
|
|
_progress = p;
|
|
_statusText = status;
|
|
_etaText = eta;
|
|
_rateText = rateStr;
|
|
});
|
|
}
|
|
|
|
Future<void> _startAction() async {
|
|
try {
|
|
await widget.action(_onProgress, _cancelToken);
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(true);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
if (_cancelToken.isCancelled) {
|
|
Navigator.of(context).pop('cancelled');
|
|
} else {
|
|
Navigator.of(context).pop(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
child: AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
const Icon(Icons.sync_rounded, color: AppColors.primaryGreen),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
widget.title,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
content: SizedBox(
|
|
width: 400,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_statusText,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: _progress,
|
|
minHeight: 10,
|
|
backgroundColor: AppColors.inputBackground,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
_rateText.isNotEmpty ? _rateText : 'Starting transfer...',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
Text(
|
|
_etaText,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Please do not close the window while the transfer is in progress.',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
_cancelToken.cancel('User cancelled transfer');
|
|
},
|
|
icon: const Icon(Icons.cancel_outlined, color: AppColors.error),
|
|
label: const Text(
|
|
'CANCEL',
|
|
style: TextStyle(color: AppColors.error),
|
|
),
|
|
style: OutlinedButton.styleFrom(
|
|
side: const BorderSide(color: AppColors.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditScheduleDatesDialog extends ConsumerStatefulWidget {
|
|
final AcademicYearModel year;
|
|
const _EditScheduleDatesDialog({required this.year});
|
|
|
|
@override
|
|
ConsumerState<_EditScheduleDatesDialog> createState() =>
|
|
_EditScheduleDatesDialogState();
|
|
}
|
|
|
|
class _EditScheduleDatesDialogState
|
|
extends ConsumerState<_EditScheduleDatesDialog> {
|
|
DateTime? _startDate;
|
|
DateTime? _endDate;
|
|
bool _isLoading = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_startDate = widget.year.startDate != null
|
|
? DateTime.tryParse(widget.year.startDate!)
|
|
: null;
|
|
_endDate = widget.year.endDate != null
|
|
? DateTime.tryParse(widget.year.endDate!)
|
|
: null;
|
|
}
|
|
|
|
String? _formatYmd(DateTime? dt) {
|
|
if (dt == null) return null;
|
|
return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
Future<void> _handleSave() async {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
await ref.read(setupMutationProvider.notifier).updateAcademicYear(
|
|
id: widget.year.id,
|
|
yearRange: widget.year.yearRange,
|
|
status: widget.year.status,
|
|
startDate: _formatYmd(_startDate),
|
|
endDate: _formatYmd(_endDate),
|
|
);
|
|
ref.invalidate(academicYearsListProvider);
|
|
if (!mounted) return;
|
|
Navigator.pop(context);
|
|
showSuccessDialog(
|
|
context,
|
|
message:
|
|
'Schedule dates updated for Academic Year ${widget.year.yearRange}!',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Save Failed',
|
|
e.toString().replaceAll('Exception: ', ''),
|
|
);
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isNarrow = MediaQuery.of(context).size.width < 500 ||
|
|
Theme.of(context).platform == TargetPlatform.android;
|
|
|
|
return CustomModal(
|
|
title: 'Edit Schedule Dates',
|
|
icon: Icons.calendar_today,
|
|
maxWidth: 460,
|
|
content: Padding(
|
|
padding: EdgeInsets.all(isNarrow ? 14 : AppSizes.p20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.school, size: 20, color: AppColors.primaryGreen),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Academic Year: ${widget.year.yearRange} (${widget.year.status.toUpperCase()})',
|
|
style:
|
|
const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
|
),
|
|
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () async {
|
|
final initialDates = <DateTime>[];
|
|
if (_startDate != null) initialDates.add(_startDate!);
|
|
if (_endDate != null) initialDates.add(_endDate!);
|
|
|
|
final results = await showCalendarDatePicker2Dialog(
|
|
context: context,
|
|
config: CalendarDatePicker2WithActionButtonsConfig(
|
|
calendarType: CalendarDatePicker2Type.range,
|
|
firstDate: DateTime(1990),
|
|
lastDate: DateTime(2100),
|
|
selectedDayHighlightColor: AppColors.primaryGreen,
|
|
okButton: const Text(
|
|
'APPLY',
|
|
style: TextStyle(
|
|
color: AppColors.primaryGreen,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
cancelButton: const Text(
|
|
'CANCEL',
|
|
style: TextStyle(color: AppColors.textSecondary),
|
|
),
|
|
),
|
|
dialogSize: const Size(325, 400),
|
|
value: initialDates,
|
|
borderRadius: BorderRadius.circular(16),
|
|
);
|
|
|
|
if (results != null && results.isNotEmpty) {
|
|
setState(() {
|
|
_startDate = results.first;
|
|
_endDate =
|
|
results.length > 1 ? results.last : results.first;
|
|
});
|
|
}
|
|
},
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.date_range_outlined,
|
|
size: 20,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
(_startDate != null && _endDate != null)
|
|
? '${_formatYmd(_startDate)} to ${_formatYmd(_endDate)}'
|
|
: _startDate != null
|
|
? 'Start: ${_formatYmd(_startDate)} (Select End Date)'
|
|
: 'Select Schedule Dates (Optional)',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: (_startDate != null || _endDate != null)
|
|
? (isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary)
|
|
: (isDark
|
|
? AppColors.darkTextMuted
|
|
: AppColors.textMuted),
|
|
fontWeight: (_startDate != null || _endDate != null)
|
|
? FontWeight.w600
|
|
: FontWeight.normal,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (_startDate != null || _endDate != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.clear, size: 18),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
tooltip: 'Clear dates',
|
|
onPressed: () {
|
|
setState(() {
|
|
_startDate = null;
|
|
_endDate = null;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: OutlinedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: ElevatedButton(
|
|
onPressed: _isLoading ? null : _handleSave,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Text('Save Dates'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|