This commit is contained in:
ssbg04 committed 2026-06-20 15:27:27 +08:00
1 parent f8b5a54e3b
commit e39fc5593d
5 files changed
+203 -134

No files matched your search

@@ -67,93 +67,9 @@ class _AndroidBottomNavLayoutState extends ConsumerState<AndroidBottomNavLayout>
}
}
void _onNavTapped(int index, List<Map<String, dynamic>> allowedPrimary, List<Map<String, dynamic>> allowedSecondary) {
if (index < allowedPrimary.length) {
final label = allowedPrimary[index]['label'] as String;
// ✅ NEW: Update global state instead of local setState
void _onNavTapped(String label) {
ref.read(activeTabProvider.notifier).setTab(label);
_reloadTabContent(label);
} else {
_showMoreBottomSheet(allowedPrimary, allowedSecondary);
}
}
void _showMoreBottomSheet(List<Map<String, dynamic>> allowedPrimary, List<Map<String, dynamic>> allowedSecondary) {
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surfaceWhite,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppSizes.radiusLarge)),
),
builder: (sheetContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSizes.p12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag Handle
Container(
width: 40,
height: 5,
margin: const EdgeInsets.only(bottom: AppSizes.p16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(AppSizes.radiusSmall),
),
),
// Build Secondary Tabs
...allowedSecondary.asMap().entries.map((entry) {
int secondaryIndex = entry.key;
var tab = entry.value;
return ListTile(
leading: Container(
padding: const EdgeInsets.all(AppSizes.p8),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
),
child: Icon(tab['icon'] as IconData, color: AppColors.primaryGreen),
),
title: Text(
tab['label'] as String,
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
trailing: const Icon(Icons.chevron_right, color: AppColors.textMuted),
onTap: () {
Navigator.pop(sheetContext);
final label = tab['label'] as String;
// ✅ NEW: Update global state when a "More" option is clicked
ref.read(activeTabProvider.notifier).setTab(label);
_reloadTabContent(label);
},
);
}),
const Divider(),
// Logout Option in the More Menu
ListTile(
leading: const Padding(
padding: EdgeInsets.all(AppSizes.p8),
child: Icon(Icons.exit_to_app, color: AppColors.error),
),
title: const Text(
'Logout',
style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.error),
),
onTap: () {
// DO NOT pop the bottom sheet here.
// Just show the dialog directly!
showLogoutConfirmationDialog(context);
},
),
],
),
),
);
},
);
}
@override
@@ -180,28 +96,6 @@ class _AndroidBottomNavLayoutState extends ConsumerState<AndroidBottomNavLayout>
_visitedIndices.add(currentIndex);
int selectedBottomNavIndex = allowedPrimary.indexWhere((t) => t['label'] == activeTab);
if (selectedBottomNavIndex == -1) {
// If the active tab is NOT in the primary list (e.g., Settings), highlight the "More" tab
selectedBottomNavIndex = allowedPrimary.length;
}
List<BottomNavigationBarItem> bottomNavItems = allowedPrimary.map((tab) {
return BottomNavigationBarItem(
icon: Icon(tab['icon'] as IconData),
label: tab['label'] as String,
);
}).toList();
if (allowedSecondary.isNotEmpty) {
bottomNavItems.add(
const BottomNavigationBarItem(
icon: Icon(Icons.more_horiz),
label: 'More',
),
);
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
@@ -238,6 +132,69 @@ class _AndroidBottomNavLayoutState extends ConsumerState<AndroidBottomNavLayout>
},
child: Scaffold(
backgroundColor: AppColors.pageBackground,
appBar: AppBar(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
iconTheme: const IconThemeData(color: Colors.white),
title: Text(
tabs[currentIndex]['label'] as String,
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
),
elevation: 0,
),
drawer: Drawer(
child: Column(
children: [
UserAccountsDrawerHeader(
decoration: const BoxDecoration(color: AppColors.primaryGreen),
accountName: Text(
'TIS RMS',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.white),
),
accountEmail: Text(
'Role: ${widget.userRole.toUpperCase()}',
style: const TextStyle(color: Colors.white70),
),
currentAccountPicture: const CircleAvatar(
backgroundColor: Colors.transparent,
backgroundImage: AssetImage('assets/images/logo.png'),
),
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
if (allowedPrimary.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('MAIN', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.bold, fontSize: 12)),
),
...allowedPrimary.map((tab) => _buildDrawerItem(tab, currentIndex, tabs)),
],
if (allowedSecondary.isNotEmpty) ...[
const Divider(),
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('OTHER', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.bold, fontSize: 12)),
),
...allowedSecondary.map((tab) => _buildDrawerItem(tab, currentIndex, tabs)),
],
],
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.exit_to_app, color: AppColors.error),
title: const Text('Logout', style: TextStyle(fontWeight: FontWeight.bold, color: AppColors.error)),
onTap: () {
Navigator.pop(context); // Close drawer first
showLogoutConfirmationDialog(context);
},
),
const SizedBox(height: 16),
],
),
),
body: IndexedStack(
index: currentIndex,
children: tabs.asMap().entries.map((entry) {
@@ -246,30 +203,31 @@ class _AndroidBottomNavLayoutState extends ConsumerState<AndroidBottomNavLayout>
: const SizedBox.shrink();
}).toList(),
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: BottomNavigationBar(
currentIndex: selectedBottomNavIndex,
onTap: (i) => _onNavTapped(i, allowedPrimary, allowedSecondary),
backgroundColor: AppColors.primaryGreen,
selectedItemColor: Colors.white,
unselectedItemColor: Colors.white70,
showUnselectedLabels: true,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500, fontSize: 11),
items: bottomNavItems,
),
),
),
);
}
Widget _buildDrawerItem(Map<String, dynamic> tab, int currentIndex, List<Map<String, dynamic>> allTabs) {
final label = tab['label'] as String;
final icon = tab['icon'] as IconData;
final index = allTabs.indexWhere((t) => t['label'] == label);
final isSelected = index == currentIndex;
return ListTile(
leading: Icon(icon, color: isSelected ? AppColors.primaryGreen : AppColors.textSecondary),
title: Text(
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primaryGreen : AppColors.textSecondary,
),
),
selected: isSelected,
selectedTileColor: AppColors.primaryGreen.withOpacity(0.1),
onTap: () {
Navigator.pop(context); // Close drawer
_onNavTapped(label);
},
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_sizes.dart';
@@ -876,6 +877,7 @@ class _GradeLevelFormModalState extends ConsumerState<GradeLevelFormModal> {
controller: _levelController,
prefixIcon: Icons.numbers,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Level is required';
if (int.tryParse(v) == null) return 'Must be a valid integer';
@@ -17,6 +17,29 @@ import '../../../shared/dialogs/success_dialog.dart';
import '../../../shared/dialogs/info_dialog.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
// ---------------------------------------------------------------
// Auto-capitalises the first letter of every word (works on paste)
// ---------------------------------------------------------------
class _UpperCaseWordsFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text;
if (text.isEmpty) return newValue;
final capitalized = text.replaceAllMapped(
RegExp(r'(^|\s)(\S)'),
(m) => '${m[1]}${m[2]!.toUpperCase()}',
);
return newValue.copyWith(
text: capitalized,
selection: newValue.selection,
composing: TextRange.empty,
);
}
}
class AddEditStudentModal extends ConsumerStatefulWidget {
final StudentModel? student;
@@ -89,6 +112,63 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
super.dispose();
}
// ----------------------------------------------------------------
// EXIT CONFIRMATION
// ----------------------------------------------------------------
bool get _hasAnyData =>
_lrnController.text.isNotEmpty ||
_firstNameController.text.isNotEmpty ||
_middleNameController.text.isNotEmpty ||
_lastNameController.text.isNotEmpty ||
_selectedDob != null;
Future<void> _confirmClose() async {
// If editing, or nothing entered yet — close immediately
if (widget.student != null || !_hasAnyData) {
if (mounted) Navigator.of(context).pop();
return;
}
final shouldClose = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Discard Changes?'),
],
),
content: const Text(
'You have unsaved data. Are you sure you want to close without saving?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text(
'KEEP EDITING',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text(
'DISCARD',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
if (shouldClose == true && mounted) Navigator.of(context).pop();
}
// ----------------------------------------------------------------
// OCR PROCESSING
// ----------------------------------------------------------------
@@ -379,6 +459,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
gradeLevelsAsync,
sectionsAsync,
isMobile,
viewInsets.bottom,
),
),
const VerticalDivider(width: 32),
@@ -394,6 +475,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
gradeLevelsAsync,
sectionsAsync,
isMobile,
viewInsets.bottom,
)),
),
),
@@ -606,6 +688,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
AsyncValue<List<GradeLevelModel>> gradeLevelsAsync,
AsyncValue<List<SectionModel>> sectionsAsync,
bool isMobile,
double keyboardInset,
) {
final compactTheme = Theme.of(context).copyWith(
inputDecorationTheme: Theme.of(context).inputDecorationTheme.copyWith(
@@ -646,7 +729,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
),
IconButton(
icon: const Icon(Icons.close, color: AppColors.textSecondary),
onPressed: () => Navigator.of(context).pop(),
onPressed: _confirmClose,
),
],
),
@@ -717,6 +800,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
Expanded(
child: TextFormField(
controller: _firstNameController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
validator: (v) => _validateRequired(
v,
'First name',
@@ -733,6 +818,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
Expanded(
child: TextFormField(
controller: _middleNameController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
decoration: const InputDecoration(
labelText: 'Middle Name (optional)',
prefixIcon: Icon(
@@ -750,6 +837,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
flex: 3,
child: TextFormField(
controller: _lastNameController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
validator: (v) =>
_validateRequired(v, 'Last name'),
decoration: const InputDecoration(
@@ -764,6 +853,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
Expanded(
child: TextFormField(
controller: _extController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
decoration: const InputDecoration(
labelText: 'Ext.',
hintText: 'Jr / III',
@@ -780,6 +871,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
children: [
TextFormField(
controller: _firstNameController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
validator: (v) =>
_validateRequired(v, 'First name'),
decoration: const InputDecoration(
@@ -789,6 +882,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
const SizedBox(height: AppSizes.p12),
TextFormField(
controller: _middleNameController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
decoration: const InputDecoration(
labelText: 'Middle Name',
),
@@ -796,6 +891,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
const SizedBox(height: AppSizes.p12),
TextFormField(
controller: _lastNameController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
validator: (v) =>
_validateRequired(v, 'Last name'),
decoration: const InputDecoration(
@@ -805,6 +902,8 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
const SizedBox(height: AppSizes.p12),
TextFormField(
controller: _extController,
textCapitalization: TextCapitalization.words,
inputFormatters: [_UpperCaseWordsFormatter()],
decoration: const InputDecoration(
labelText: 'Extension (Jr / III)',
),
@@ -1046,6 +1145,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
// ---- Actions ----
if (isMobile)
if (keyboardInset == 0) // Hide when keyboard is open
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
@@ -1092,7 +1192,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
TextButton(
onPressed: _isLoading
? null
: () => Navigator.of(context).pop(),
: _confirmClose,
child: const Text(
'CANCEL',
style: TextStyle(
@@ -1131,7 +1231,7 @@ class _AddEditStudentModalState extends ConsumerState<AddEditStudentModal>
TextButton(
onPressed: _isLoading
? null
: () => Navigator.of(context).pop(),
: _confirmClose,
child: const Text(
'CANCEL',
style: TextStyle(
@@ -33,8 +33,10 @@ Future<void> showLogoutConfirmationDialog(BuildContext context) async {
// 1. Close the dialog immediately for responsiveness
navigator.pop();
// 2. Remove the saved password for security (keep username intact)
// 2. Remove all saved credentials for security
final prefs = await SharedPreferences.getInstance();
await prefs.remove('rememberMe');
await prefs.remove('saved_username');
await prefs.remove('saved_password');
// 3. Navigate FIRST to LoginScreen and destroy routing history.
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
final String hintText;
@@ -14,6 +15,8 @@ class CustomTextField extends StatelessWidget {
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final void Function(String)? onSubmitted;
final int? maxLength;
final List<TextInputFormatter>? inputFormatters;
const CustomTextField({
super.key,
@@ -30,6 +33,8 @@ class CustomTextField extends StatelessWidget {
this.keyboardType,
this.textInputAction,
this.onSubmitted,
this.maxLength,
this.inputFormatters,
});
@override
@@ -41,7 +46,9 @@ class CustomTextField extends StatelessWidget {
onChanged: onChanged,
validator: validator,
maxLines: maxLines,
maxLength: maxLength,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
textInputAction: textInputAction,
onFieldSubmitted: onSubmitted,
decoration: InputDecoration(