From 99be1e8d799de5e1b9bbb9144e6d1decc0e695c7 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Fri, 25 Sep 2026 20:07:14 +0800 Subject: [PATCH] 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 --- .github/workflows/release.yml | 78 +++- frontend/AGENTS.md | 16 +- frontend/INSTALL.md | 28 +- .../lib/core/services/app_update_service.dart | 11 +- .../utils/transparency_board_pdf_service.dart | 50 ++- .../ui/screens/settings/settings_screen.dart | 420 +++++++++--------- .../ui/screens/students/students_screen.dart | 1 + .../students/widgets/add_student_modal.dart | 1 + .../students/widgets/edit_student_modal.dart | 2 + .../dialogs/update_available_dialog.dart | 2 +- 10 files changed, 345 insertions(+), 264 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78bf8fa..e72cc16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3d41210..422315e 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 + + + +# 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 --- diff --git a/frontend/INSTALL.md b/frontend/INSTALL.md index 81f2437..b5c2717 100644 --- a/frontend/INSTALL.md +++ b/frontend/INSTALL.md @@ -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.apk`** | **Universal (32-bit & 64-bit)** | Single standalone installer compatible with all modern and legacy Android devices (Android 7.0+). | +| **`TIS_RMS_64_v.apk`** | **64-bit ARM (`arm64-v8a`)** | **Recommended.** Best performance and smallest size (~40% smaller) for modern Android phones and tablets. | +| **`TIS_RMS_32bit_v.apk`** | **32-bit ARM (`armeabi-v7a`)** | Optimized for older or entry-level 32-bit Android devices. | +| **`TIS_RMS_Universal_v.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.apk`** +- `app-armeabi-v7a-release.apk` → **`TIS_RMS_32bit_v.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.apk`** ### Installation Steps: -1. Transfer `TIS_RMS_Android_Universal_v.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.apk` for modern devices, `TIS_RMS_32bit_v.apk` for older devices, or `TIS_RMS_Universal_v.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. diff --git a/frontend/lib/core/services/app_update_service.dart b/frontend/lib/core/services/app_update_service.dart index 70873a2..c73e061 100644 --- a/frontend/lib/core/services/app_update_service.dart +++ b/frontend/lib/core/services/app_update_service.dart @@ -103,9 +103,14 @@ class AppUpdateService { assetName = asset['name'] as String?; break; } else if (isAndroid && name.endsWith('.apk')) { - downloadUrl = browserUrl; - assetName = asset['name'] as String?; - break; + if (name.contains('universal')) { + downloadUrl = browserUrl; + assetName = asset['name'] as String?; + break; + } else if (downloadUrl == null) { + downloadUrl = browserUrl; + assetName = asset['name'] as String?; + } } } } diff --git a/frontend/lib/core/utils/transparency_board_pdf_service.dart b/frontend/lib/core/utils/transparency_board_pdf_service.dart index 2955221..5000901 100644 --- a/frontend/lib/core/utils/transparency_board_pdf_service.dart +++ b/frontend/lib/core/utils/transparency_board_pdf_service.dart @@ -212,41 +212,47 @@ 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.Text( - hasPrev - ? 'Reference Period: $prevSyLabel vs. $activeSyLabel' - : 'Reference Period: $activeSyLabel (Baseline)', - style: pw.TextStyle( - fontSize: 8, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey800, - ), - ), - pw.Text( - 'Date: $generatedDate', - style: const pw.TextStyle( - fontSize: 7.5, - color: PdfColors.grey700, - ), + pw.SizedBox(height: 4), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + hasPrev + ? 'Reference Period: $prevSyLabel vs. $activeSyLabel' + : 'Reference Period: $activeSyLabel (Baseline)', + style: pw.TextStyle( + fontSize: 8, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey800, + ), + ), + pw.Text( + 'Date: $generatedDate', + style: const pw.TextStyle( + fontSize: 7.5, + color: PdfColors.grey700, + ), + ), + ], ), ], ), diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index 38e6158..a2ba9e2 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -284,31 +284,6 @@ class _SettingsScreenState extends ConsumerState { - Future _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 _handleRunAutoGraduation( BuildContext context, @@ -977,9 +952,11 @@ class _SettingsScreenState extends ConsumerState { 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 { 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 { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DropdownButtonFormField( - 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), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), ), - items: years.map((y) { - final isActive = y.status == 'active'; - return DropdownMenuItem( - 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, - ), - ), - ), - ], + ] 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, ), - ); - }).toList(), - onChanged: (selectedId) async { - if (selectedId == null || selectedId == activeYear?.id) return; - final selectedYear = years.firstWhere((y) => y.id == selectedId); - await _handleActivateAcademicYear(selectedYear); - }, - ), + ), + child: Row( + children: [ + const Icon(Icons.calendar_month, color: AppColors.primaryGreen, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active Academic Year', + style: TextStyle( + fontSize: 11, + 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, + ), + ), + ), + ], + ), + ), + ], const SizedBox(height: 16), if (activeYear == null) ...[ const Text( @@ -1284,6 +1280,142 @@ class _SettingsScreenState extends ConsumerState { 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 { ), ], ], - 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)), - ), - ), - ], - ); - }, - ), ], ), ); diff --git a/frontend/lib/ui/screens/students/students_screen.dart b/frontend/lib/ui/screens/students/students_screen.dart index 870c256..7691878 100644 --- a/frontend/lib/ui/screens/students/students_screen.dart +++ b/frontend/lib/ui/screens/students/students_screen.dart @@ -948,6 +948,7 @@ class _StudentsScreenState extends ConsumerState { horizontal: AppSizes.p24, ), child: pageAsync.when( + skipLoadingOnReload: true, loading: () => const Center( child: CircularProgressIndicator( color: AppColors.primaryGreen, diff --git a/frontend/lib/ui/screens/students/widgets/add_student_modal.dart b/frontend/lib/ui/screens/students/widgets/add_student_modal.dart index a8bbf13..8dd76ca 100644 --- a/frontend/lib/ui/screens/students/widgets/add_student_modal.dart +++ b/frontend/lib/ui/screens/students/widgets/add_student_modal.dart @@ -465,6 +465,7 @@ class _AddStudentModalState extends ConsumerState { if (!mounted) return; ref.invalidate(studentPageProvider); + if (mounted) setState(() => _isLoading = false); if (uploadError != null) { await showDialog( context: context, diff --git a/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart b/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart index d5f9509..57ddc32 100644 --- a/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart +++ b/frontend/lib/ui/screens/students/widgets/edit_student_modal.dart @@ -335,6 +335,7 @@ class _EditStudentModalState extends ConsumerState { 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 { 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) { diff --git a/frontend/lib/ui/shared/dialogs/update_available_dialog.dart b/frontend/lib/ui/shared/dialogs/update_available_dialog.dart index c68fa42..e79a018 100644 --- a/frontend/lib/ui/shared/dialogs/update_available_dialog.dart +++ b/frontend/lib/ui/shared/dialogs/update_available_dialog.dart @@ -31,7 +31,7 @@ Future 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,