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:
1 parent
71526fe61e
commit
99be1e8d79
10 files changed
+315
-234
No files matched your search
@@ -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
@@ -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
@@ -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` → **`TIS_RMS_64_v<version>.apk`**
|
||||
- `app-armeabi-v7a-release.apk` → **`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` → **`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,
|
||||
|
||||
Reference in new issue
Block a user