feat(build): split Android APKs, refine settings, fix student loading and PDF layout

- Split Android release into TIS_RMS_32bit, TIS_RMS_64, and TIS_RMS_Universal APKs in GitHub Actions and INSTALL.md
- Prioritize universal APK in AppUpdateService and update fallback labels
- Refactor settings academic collapse to 'Academic & Graduation' with automatic active school year card and relocated grace period
- Fix persistent loading indicators on student edit/add modals and list background
- Fix DepEd Transparency Board PDF report subheader layout to prevent title and period collision
- Update AGENTS.md backlog with document scanner and PDF processing items
This commit is contained in:
ssbg04 committed 2026-09-25 20:07:14 +08:00
1 parent 71526fe61e
commit 99be1e8d79
10 files changed
+315 -234

No files matched your search

+61 -17
View File
@@ -422,9 +422,9 @@ jobs:
}
# ------------------------------------------------------------
# Android Build
# Android Build (Split 32-bit, 64-bit & Universal APKs)
# ------------------------------------------------------------
- name: Build Android Universal APK
- name: Build Android APKs (Split 32-bit, 64-bit & Universal)
working-directory: frontend
shell: pwsh
run: |
@@ -435,33 +435,73 @@ jobs:
& "$env:JAVA_HOME\bin\java.exe" -version
$tag = "${{ steps.release_info.outputs.tag }}"
$verName = "${{ steps.release_info.outputs.version_name }}"
$buildNum = "${{ steps.release_info.outputs.build_number }}"
# 1. Build Split-per-ABI APKs (32-bit armeabi-v7a & 64-bit arm64-v8a)
Write-Host ""
Write-Host "Building Android APK..."
Write-Host "Building Android Split-per-ABI APKs (32-bit & 64-bit)..."
flutter build apk `
--release `
--split-per-abi `
--obfuscate `
--split-debug-info=build/app/outputs/symbols `
--build-name="$verName" `
--build-number="$buildNum"
if ($LASTEXITCODE -ne 0) {
throw "Flutter Android split-per-abi build failed."
}
$apk32 = "build\app\outputs\flutter-apk\app-armeabi-v7a-release.apk"
$apk64 = "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk"
$output32 = "..\installers\TIS_RMS_32bit_$tag.apk"
$output64 = "..\installers\TIS_RMS_64_$tag.apk"
if (-not (Test-Path $apk32)) {
throw "Split build completed without producing $apk32"
}
if (-not (Test-Path $apk64)) {
throw "Split build completed without producing $apk64"
}
Copy-Item $apk32 $output32 -Force
Copy-Item $apk64 $output64 -Force
Copy-Item $apk32 "..\installers\TIS_RMS_32bit.apk" -Force
Copy-Item $apk64 "..\installers\TIS_RMS_64.apk" -Force
Write-Host "32-bit APK copied to $output32 and ..\installers\TIS_RMS_32bit.apk"
Write-Host "64-bit APK copied to $output64 and ..\installers\TIS_RMS_64.apk"
# 2. Build Universal APK (All ABIs bundled)
Write-Host ""
Write-Host "Building Android Universal APK..."
flutter build apk `
--release `
--obfuscate `
--split-debug-info=build/app/outputs/symbols `
--build-name="${{ steps.release_info.outputs.version_name }}" `
--build-number="${{ steps.release_info.outputs.build_number }}"
--build-name="$verName" `
--build-number="$buildNum"
if ($LASTEXITCODE -ne 0) {
throw "Flutter Android APK build failed."
throw "Flutter Android universal APK build failed."
}
$apkPath = "build\app\outputs\flutter-apk\app-release.apk"
$outputPath = "..\installers\TIS_RMS_Android_Universal_${{ steps.release_info.outputs.tag }}.apk"
$apkUniversal = "build\app\outputs\flutter-apk\app-release.apk"
$outputUniversal = "..\installers\TIS_RMS_Universal_$tag.apk"
if (-not (Test-Path $apkPath)) {
throw "Android build completed without producing $apkPath"
if (-not (Test-Path $apkUniversal)) {
throw "Universal build completed without producing $apkUniversal"
}
Copy-Item `
$apkPath `
$outputPath `
-Force
Copy-Item $apkUniversal $outputUniversal -Force
Copy-Item $apkUniversal "..\installers\TIS_RMS_Universal.apk" -Force
Write-Host "Android APK copied to $outputPath"
Write-Host "Universal APK copied to $outputUniversal and ..\installers\TIS_RMS_Universal.apk"
# ------------------------------------------------------------
# Verify Artifacts
@@ -474,7 +514,9 @@ jobs:
$tag = "${{ steps.release_info.outputs.tag }}"
$files = @(
"installers\TIS_RMS_Client_Setup_$tag.exe",
"installers\TIS_RMS_Android_Universal_$tag.apk"
"installers\TIS_RMS_32bit_$tag.apk",
"installers\TIS_RMS_64_$tag.apk",
"installers\TIS_RMS_Universal_$tag.apk"
)
foreach ($file in $files) {
@@ -503,7 +545,9 @@ jobs:
name: Release ${{ steps.release_info.outputs.tag }}
files: |
installers/TIS_RMS_Client_Setup_${{ steps.release_info.outputs.tag }}.exe
installers/TIS_RMS_Android_Universal_${{ steps.release_info.outputs.tag }}.apk
installers/TIS_RMS_32bit_${{ steps.release_info.outputs.tag }}.apk
installers/TIS_RMS_64_${{ steps.release_info.outputs.tag }}.apk
installers/TIS_RMS_Universal_${{ steps.release_info.outputs.tag }}.apk
generate_release_notes: true
draft: false
prerelease: false
+14 -2
View File
@@ -798,8 +798,20 @@ Files backup/restore - mirror mode or zip -->
# DASHBOARD
- make the layout bento grids for windows app for mobile use normal responsive grid ( columns) -->
# REPORT SCREEN
- make the layout bento grids for windows app for mobile use normal responsive grid
<!-- # REPORT SCREEN
- make the layout bento grids for windows app for mobile use normal responsive grid -->
<!-- # STUDENT SCREEN
- edit/add student after success -> still has a loading at background under the tabs
# SETTINGS SCREEN
- move the automatic archiving grace period under the academic & auto graduation collapse -> rename the collapse use basic word -> remove picking academic year, make it automatic choose the active year, dont make it dropdown just choose or pick the active year -->
# ANDROID / DOCUMENT SCANNER
- reduce android app size: configure google mlkit document scanner to unbundled mode (Google Play Services) while keeping automatic edge detection and enhance
# PDF GENERATION & MANIPULATION
- remove PDF manipulation and generation from frontend, keep PDF viewing
- move PDF manipulation and generation to backend
---
+21 -7
View File
@@ -4,25 +4,39 @@ This guide provides instructions for installing, building, and running the **TIS
---
## 1. Android Installation (Universal APK)
## 1. Android Installation (Split & Universal APKs)
The Android application is distributed as a single universal APK supporting both **32-bit (armeabi-v7a)** and **64-bit (arm64-v8a, x86_64)** devices.
The Android application is distributed in architecture-specific split APKs (for smaller download size and faster performance) as well as a universal APK:
### Universal APK:
### Available APKs:
| APK File | Architecture | Description |
| :--- | :--- | :--- |
| **`TIS_RMS_Android_Universal_v<version>.apk`** | **Universal (32-bit & 64-bit)** | Single standalone installer compatible with all modern and legacy Android devices (Android 7.0+). |
| **`TIS_RMS_64_v<version>.apk`** | **64-bit ARM (`arm64-v8a`)** | **Recommended.** Best performance and smallest size (~40% smaller) for modern Android phones and tablets. |
| **`TIS_RMS_32bit_v<version>.apk`** | **32-bit ARM (`armeabi-v7a`)** | Optimized for older or entry-level 32-bit Android devices. |
| **`TIS_RMS_Universal_v<version>.apk`** | **Universal (32-bit & 64-bit)** | Single standalone installer containing all native architectures (runs on all Android 7.0+ devices). |
### Build Command (Flutter):
### Build Commands (Flutter):
#### A. Build Split APKs (Generates 32-bit & 64-bit standalone APKs):
```bash
cd frontend
flutter build apk --release --split-per-abi --obfuscate --split-debug-info=build/app/outputs/symbols
```
*Outputs located in `frontend/build/app/outputs/flutter-apk/`:*
- `app-arm64-v8a-release.apk` &rarr; **`TIS_RMS_64_v<version>.apk`**
- `app-armeabi-v7a-release.apk` &rarr; **`TIS_RMS_32bit_v<version>.apk`**
#### B. Build Universal APK (Contains all architectures):
```bash
cd frontend
flutter build apk --release --obfuscate --split-debug-info=build/app/outputs/symbols
```
*Output APK located in `frontend/build/app/outputs/flutter-apk/app-release.apk`.*
*Output located in `frontend/build/app/outputs/flutter-apk/`:*
- `app-release.apk` &rarr; **`TIS_RMS_Universal_v<version>.apk`**
### Installation Steps:
1. Transfer `TIS_RMS_Android_Universal_v<version>.apk` (e.g. `TIS_RMS_Android_Universal_v1.0.0.apk`) to your Android device (via USB, local network share, or download).
1. Transfer the matching APK (`TIS_RMS_64_v<version>.apk` for modern devices, `TIS_RMS_32bit_v<version>.apk` for older devices, or `TIS_RMS_Universal_v<version>.apk`) to your Android device (via USB, local network share, or download).
2. Tap the `.apk` file in your file manager.
3. If prompted, enable **"Install unknown apps"** or **"Allow from this source"** in Android Settings.
4. Tap **Install** and open the app.
@@ -103,9 +103,14 @@ class AppUpdateService {
assetName = asset['name'] as String?;
break;
} else if (isAndroid && name.endsWith('.apk')) {
if (name.contains('universal')) {
downloadUrl = browserUrl;
assetName = asset['name'] as String?;
break;
} else if (downloadUrl == null) {
downloadUrl = browserUrl;
assetName = asset['name'] as String?;
}
}
}
}
@@ -212,25 +212,29 @@ class TransparencyBoardPdfService {
pw.SizedBox(height: 5),
// Report Subheader
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 7, vertical: 2.5),
decoration: pw.BoxDecoration(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: const pw.BoxDecoration(
color: PdfColors.green800,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
borderRadius: pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'DEPED TRANSPARENCY & SCHOOL PERFORMANCE BOARD REPORT',
style: pw.TextStyle(
fontSize: 8,
fontSize: 8.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
letterSpacing: 0.3,
),
),
),
pw.SizedBox(height: 4),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
hasPrev
? 'Reference Period: $prevSyLabel vs. $activeSyLabel'
@@ -250,6 +254,8 @@ class TransparencyBoardPdfService {
),
],
),
],
),
pw.SizedBox(height: 3),
],
);
@@ -284,31 +284,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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,
@@ -977,9 +952,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Consumer(
builder: (context, ref, _) {
final academicYearsAsync = ref.watch(academicYearsListProvider);
final sysSettingsAsync = ref.watch(systemSettingsProvider);
final settingsMap = sysSettingsAsync.asData?.value ?? {};
final isDark = Theme.of(context).brightness == Brightness.dark;
return _buildCollapsibleCard(
title: 'Academic Year & Auto-Graduation',
title: 'Academic & Graduation',
isExpanded: _isAcademicYearExpanded,
onToggle: () => setState(() => _isAcademicYearExpanded = !_isAcademicYearExpanded),
child: Column(
@@ -996,7 +973,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(width: AppSizes.p12),
Expanded(
child: Text(
'Manage active academic year and auto-graduation schedule',
'Manage active academic year, auto-graduation, and archiving grace period',
style: TextStyle(
fontSize: 13,
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
@@ -1027,59 +1004,78 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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(
if (activeYear == null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.warning.withValues(alpha: 0.3)),
),
child: const Text(
'No active academic year found. An administrator must activate a school year in Teachers & Academic Setup.',
style: TextStyle(color: AppColors.warning, fontSize: 13),
),
),
] else ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
),
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: [
const Icon(Icons.calendar_month, color: AppColors.primaryGreen, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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',
'Active Academic Year',
style: TextStyle(
fontSize: 11,
color: isActive ? AppColors.primaryGreen : Colors.grey,
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
activeYear.yearRange,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Active',
style: TextStyle(
fontSize: 11,
color: AppColors.primaryGreen,
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(
@@ -1284,6 +1280,142 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error loading academic years: $err', style: const TextStyle(color: Colors.red)),
),
const Divider(height: 32),
// Auto-Archiving Grace Period
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.archive_outlined,
color: AppColors.primaryGreen,
size: 24,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Automatic Archiving Grace Period',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
'Enrolled students with no active enrollment past this number of days from the active school year start date are automatically marked Inactive and archived.',
style: TextStyle(
fontSize: 12,
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
),
),
],
),
),
],
),
const SizedBox(height: AppSizes.p12),
Wrap(
spacing: 8,
runSpacing: 8,
children: ['15', '30', '45', '60', '90'].map((days) {
final currentGrace = settingsMap['enrollment_grace_period_days'] ?? '30';
final isSelected = currentGrace == days;
return ChoiceChip(
label: Text('$days Days'),
selected: isSelected,
selectedColor: AppColors.primaryGreen.withValues(alpha: 0.18),
backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.grey.shade100,
side: BorderSide(
color: isSelected
? AppColors.primaryGreen
: Theme.of(context).dividerColor.withValues(alpha: 0.3),
width: isSelected ? 1.5 : 1,
),
labelStyle: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? AppColors.primaryGreen : Theme.of(context).colorScheme.onSurface,
),
onSelected: (_) {
_gracePeriodCtrl.text = days;
ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', days);
},
);
}).toList(),
),
const SizedBox(height: AppSizes.p12),
Builder(
builder: (context) {
final currentGrace = settingsMap['enrollment_grace_period_days'] ?? '30';
if (_gracePeriodCtrl.text.isEmpty) {
_gracePeriodCtrl.text = currentGrace;
}
return Row(
children: [
SizedBox(
width: 140,
height: 40,
child: TextFormField(
controller: _gracePeriodCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
labelText: 'Custom Days',
labelStyle: const TextStyle(fontSize: 12),
suffixText: 'days',
suffixStyle: const TextStyle(fontSize: 12),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
onFieldSubmitted: (val) {
final trimmed = val.trim();
if (trimmed.isNotEmpty && int.tryParse(trimmed) != null && int.parse(trimmed) > 0) {
ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', trimmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Grace period updated to $trimmed days'),
duration: const Duration(seconds: 2),
),
);
}
},
),
),
const SizedBox(width: 8),
SizedBox(
height: 40,
child: ElevatedButton(
onPressed: () {
final trimmed = _gracePeriodCtrl.text.trim();
if (trimmed.isNotEmpty && int.tryParse(trimmed) != null && int.parse(trimmed) > 0) {
ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', trimmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Grace period updated to $trimmed days'),
duration: const Duration(seconds: 2),
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: const Text('Save', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
),
),
],
);
},
),
],
),
);
@@ -1434,142 +1566,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
],
],
const Divider(height: 32),
// Auto-Archiving Grace Period
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.archive_outlined,
color: AppColors.primaryGreen,
size: 24,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Automatic Archiving Grace Period',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
'Enrolled students with no active enrollment past this number of days from the active school year start date are automatically marked Inactive and archived.',
style: TextStyle(
fontSize: 12,
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
),
),
],
),
),
],
),
const SizedBox(height: AppSizes.p12),
Wrap(
spacing: 8,
runSpacing: 8,
children: ['15', '30', '45', '60', '90'].map((days) {
final currentGrace = settingsMap['enrollment_grace_period_days'] ?? '30';
final isSelected = currentGrace == days;
return ChoiceChip(
label: Text('$days Days'),
selected: isSelected,
selectedColor: AppColors.primaryGreen.withValues(alpha: 0.18),
backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.grey.shade100,
side: BorderSide(
color: isSelected
? AppColors.primaryGreen
: Theme.of(context).dividerColor.withValues(alpha: 0.3),
width: isSelected ? 1.5 : 1,
),
labelStyle: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected ? AppColors.primaryGreen : Theme.of(context).colorScheme.onSurface,
),
onSelected: (_) {
_gracePeriodCtrl.text = days;
ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', days);
},
);
}).toList(),
),
const SizedBox(height: AppSizes.p12),
Builder(
builder: (context) {
final currentGrace = settingsMap['enrollment_grace_period_days'] ?? '30';
if (_gracePeriodCtrl.text.isEmpty) {
_gracePeriodCtrl.text = currentGrace;
}
return Row(
children: [
SizedBox(
width: 140,
height: 40,
child: TextFormField(
controller: _gracePeriodCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
labelText: 'Custom Days',
labelStyle: const TextStyle(fontSize: 12),
suffixText: 'days',
suffixStyle: const TextStyle(fontSize: 12),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
onFieldSubmitted: (val) {
final trimmed = val.trim();
if (trimmed.isNotEmpty && int.tryParse(trimmed) != null && int.parse(trimmed) > 0) {
ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', trimmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Grace period updated to $trimmed days'),
duration: const Duration(seconds: 2),
),
);
}
},
),
),
const SizedBox(width: 8),
SizedBox(
height: 40,
child: ElevatedButton(
onPressed: () {
final trimmed = _gracePeriodCtrl.text.trim();
if (trimmed.isNotEmpty && int.tryParse(trimmed) != null && int.parse(trimmed) > 0) {
ref.read(systemSettingsProvider.notifier).updateSetting('enrollment_grace_period_days', trimmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Grace period updated to $trimmed days'),
duration: const Duration(seconds: 2),
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
child: const Text('Save', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
),
),
],
);
},
),
],
),
);
@@ -948,6 +948,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
horizontal: AppSizes.p24,
),
child: pageAsync.when(
skipLoadingOnReload: true,
loading: () => const Center(
child: CircularProgressIndicator(
color: AppColors.primaryGreen,
@@ -465,6 +465,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
if (!mounted) return;
ref.invalidate(studentPageProvider);
if (mounted) setState(() => _isLoading = false);
if (uploadError != null) {
await showDialog(
context: context,
@@ -335,6 +335,7 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
if (!mounted) return;
ref.invalidate(studentPageProvider);
ref.invalidate(studentDetailProvider(widget.student.id));
setState(() => _isLoading = false);
await showSuccessDialog(
context,
message: 'Student updated successfully!',
@@ -752,6 +753,7 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
final isDark = Theme.of(context).brightness == Brightness.dark;
return detailAsync.when(
skipLoadingOnReload: true,
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading enrollments: $e')),
data: (detailStudent) {
@@ -31,7 +31,7 @@ Future<void> showUpdateAvailableDialog(
: (isAndroid ? 'Android Universal App' : 'TIS RMS Client');
final assetLabel = updateInfo.assetName ??
(isWindows ? 'TIS_RMS_Client_Setup.exe' : 'TIS_RMS_Android_Universal.apk');
(isWindows ? 'TIS_RMS_Client_Setup.exe' : 'TIS_RMS_Universal.apk');
return Dialog(
backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.white,