Compare commits

...
2 Commits
11 changed files with 869 additions and 21 deletions

No files matched your search

+27
View File
@@ -341,6 +341,33 @@ jobs:
$releaseDir = "build\windows\x64\runner\Release"
Get-ChildItem -Path "$releaseDir\*.dll" | Select-Object Name, Length
# ------------------------------------------------------------
# Visual C++ Redistributable Dependency
# ------------------------------------------------------------
- name: Download Microsoft Visual C++ Redistributable
working-directory: frontend
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$vcRedistUrl = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
$vcRedistFile = "vc_redist.x64.exe"
Write-Host "Downloading Microsoft Visual C++ 2015-2022 Redistributable (x64) from $vcRedistUrl..."
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $vcRedistUrl -OutFile $vcRedistFile
if (-not (Test-Path $vcRedistFile)) {
throw "Downloaded vc_redist.x64.exe was not found."
}
$fileSize = (Get-Item $vcRedistFile).Length
Write-Host "vc_redist.x64.exe downloaded successfully. Size: $fileSize bytes."
if ($fileSize -lt 10000000) {
throw "Downloaded vc_redist.x64.exe appears incomplete or corrupted (size: $fileSize bytes)."
}
# ------------------------------------------------------------
# Windows Installer
# ------------------------------------------------------------
+1
View File
@@ -54,6 +54,7 @@ frontend/build/
frontend/android/build/
frontend/.flutter-plugins
frontend/.flutter-plugins-dependencies
frontend/vc_redist.x64.exe
# Backup logs
backend/data/backup_info.json
backend/data/backup_logs.txt
+3
View File
@@ -50,3 +50,6 @@ app.*.map.json
/android/key.properties
.kotlin/
/android/.kotlin/
# Visual C++ Redistributable binary (downloaded dynamically during CI / local build)
vc_redist.x64.exe
+2 -2
View File
@@ -1054,7 +1054,7 @@ Files backup/restore - mirror mode or zip -->
- [x] in-app documentation viewing (bundled offline HTML user guide, local loopback server, embedded WebView for Windows & Android without redirecting to browser)
-->
# UI/UX RECOMMENDATIONS ROADMAP (TIS RMS UI-UX Recommendations.md)
<!-- # UI/UX RECOMMENDATIONS ROADMAP (TIS RMS UI-UX Recommendations.md)
## Priority 1: Student Profile as Central Workspace
- [x] Refactor student profile into primary operational hub: Overview, Documents checklist, and Activity History tabs.
@@ -1092,7 +1092,7 @@ Files backup/restore - mirror mode or zip -->
- [x] Add lightweight persistent server connection status pill (Connected / Offline).
## Priority 10: Adaptive Tablet / Desktop Navigation
- [x] Implement adaptive Navigation Rail for tablet viewports and maintain persistent sidebar on desktop.
- [x] Implement adaptive Navigation Rail for tablet viewports and maintain persistent sidebar on desktop. -->
---
+6 -3
View File
@@ -76,8 +76,8 @@ flutter build windows --release --obfuscate --split-debug-info=build/windows/sym
The installer script [`frontend/TIS_RMS_Client.iss`](file:///f:/SumbrerongBato/tis_rms_server/frontend/TIS_RMS_Client.iss) packages the app into an ultra-compact standalone setup file (`TIS_RMS_Client_Setup_v<version>.exe` ~**16.7 MB**).
#### Features:
- **Small File Size (~17 MB)**: Uses `lzma2/ultra64` solid 64MB dictionary compression.
- **Automated System-Wide VC++ & .NET Runtime Setup**: Automatically detects whether a compatible Visual Studio 2022 (v14.30+) x64 runtime is installed in Windows (`System32`) and silently installs Microsoft's official `vc_redist.x64.exe` if missing or outdated. Also purges legacy app-local CRT DLLs to prevent ABI mismatch crashes.
- **Small File Size**: Uses `lzma2/ultra64` solid compression.
- **Bundled Offline VC++ Runtime Setup**: Packages Microsoft's official `vc_redist.x64.exe` inside the installer. During installation, it detects whether a compatible Visual Studio 2022 (v14.30+) x64 runtime is installed in Windows (`System32`) and silently runs the bundled installer if missing or outdated, reporting errors if installation fails. Also purges legacy app-local CRT DLLs to prevent ABI mismatch crashes.
- **Selectable Destination Path**: Lets the user choose where to install (defaults to `C:\Program Files\TIS RMS Client`).
- **Desktop Shortcut Checkbox**: Optional checkbox on the tasks page to create a desktop shortcut with the official school icon logo.
- **Start Menu & Uninstaller**: Registers a Start Menu program group and includes a clean uninstaller in Windows *Apps & Features* / *Settings*.
@@ -85,7 +85,10 @@ The installer script [`frontend/TIS_RMS_Client.iss`](file:///f:/SumbrerongBato/t
#### Compiling with Inno Setup CLI:
```powershell
# Using Inno Setup Command Line Compiler (ISCC)
# 1. Download official vc_redist.x64.exe into frontend/ (if not already downloaded)
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile "frontend\vc_redist.x64.exe"
# 2. Compile using Inno Setup Command Line Compiler (ISCC)
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" frontend\TIS_RMS_Client.iss
```
*The compiled installer will be saved to `installers/TIS_RMS_Client_Setup_v<version>.exe` (e.g., `installers/TIS_RMS_Client_Setup_v1.0.0.exe`).*
+64 -5
View File
@@ -63,6 +63,8 @@ Source: "build\windows\x64\runner\Release\*.exe"; DestDir: "{app}"; Flags: ignor
Source: "build\windows\x64\runner\Release\*.dll"; DestDir: "{app}"; Flags: ignoreversion; Excludes: "msvcp140*.dll,vcruntime140*.dll,concrt140.dll,vccorlib140.dll"
Source: "build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppIcon}"; DestDir: "{app}"; DestName: "app_icon.ico"; Flags: ignoreversion
; Bundled official Microsoft Visual C++ 2015-2022 Redistributable (x64)
Source: "vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: dontcopy
[InstallDelete]
; Clean up any legacy app-local MSVC CRT DLLs from older installations so app always uses C:\Windows\System32
@@ -118,17 +120,74 @@ begin
RegKeyExists(HKLM, 'SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App');
end;
// Installs bundled Microsoft Visual C++ Redistributable silently and verifies installation
procedure InstallVCRedist;
var
ResultCode: Integer;
VCRedistPath: String;
PrevStatus: String;
begin
if not IsVCRedistCompatible then
begin
PrevStatus := WizardForm.StatusLabel.Caption;
WizardForm.StatusLabel.Caption := 'Installing Microsoft Visual C++ 2015-2022 Redistributable...';
try
try
ExtractTemporaryFile('vc_redist.x64.exe');
VCRedistPath := ExpandConstant('{tmp}\vc_redist.x64.exe');
// Run bundled installer silently (/install /quiet /norestart)
if not Exec(VCRedistPath, '/install /quiet /norestart', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
SuppressibleMsgBox(
'Failed to execute bundled Microsoft Visual C++ Redistributable installer.' + #13#10 +
'System Error Code: ' + IntToStr(ResultCode) + #13#10#13#10 +
'The application requires this runtime component to function properly. Please install vc_redist.x64.exe manually.',
mbError, MB_OK, MB_OK
);
end
else
begin
// Exit codes: 0 = SUCCESS, 3010 = SUCCESS_REBOOT_REQUIRED, 1641 = SUCCESS_REBOOT_INITIATED, 1638 = ANOTHER_VERSION_ALREADY_INSTALLED
if (ResultCode <> 0) and (ResultCode <> 3010) and (ResultCode <> 1641) and (ResultCode <> 1638) then
begin
SuppressibleMsgBox(
'Microsoft Visual C++ 2015-2022 Redistributable installation failed.' + #13#10 +
'Installer Exit Code: ' + IntToStr(ResultCode) + #13#10#13#10 +
'The application requires this runtime component to function properly. Please install vc_redist.x64.exe manually.',
mbError, MB_OK, MB_OK
);
end
else if (not IsVCRedistCompatible) and (ResultCode <> 3010) then
begin
SuppressibleMsgBox(
'Microsoft Visual C++ 2015-2022 Redistributable completed, but runtime verification in the system registry failed.' + #13#10#13#10 +
'The application requires this runtime component to function properly. Please install vc_redist.x64.exe manually.',
mbError, MB_OK, MB_OK
);
end;
end;
except
SuppressibleMsgBox(
'An error occurred while preparing the Visual C++ Redistributable: ' + GetExceptionMessage + #13#10#13#10 +
'Please install Microsoft Visual C++ 2015-2022 Redistributable (x64) manually.',
mbError, MB_OK, MB_OK
);
end;
finally
WizardForm.StatusLabel.Caption := PrevStatus;
end;
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin
if CurStep = ssPostInstall then
begin
// If Microsoft Visual C++ 2015-2022 Redistributable (x64) is not installed or outdated (< VS 2022), automatically install it silently
if not IsVCRedistCompatible then
begin
Exec('powershell.exe', '-NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = ''SilentlyContinue''; try { if (Get-Command winget -ErrorAction SilentlyContinue) { winget install --id Microsoft.VCRedist.2015+.x64 --silent --accept-package-agreements --accept-source-agreements } else { $url = ''https://aka.ms/vs/17/release/vc_redist.x64.exe''; $out = ''$env:TEMP\vc_redist.x64.exe''; Invoke-WebRequest -Uri $url -OutFile $out; Start-Process -FilePath $out -ArgumentList ''/install /quiet /norestart'' -Wait } } catch {}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
// Check and install Microsoft Visual C++ 2015-2022 Redistributable
InstallVCRedist;
// If .NET Desktop Runtime is not installed, automatically install it silently
if not IsDotNetRuntimeInstalled then
+11 -2
View File
@@ -51,11 +51,13 @@ Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
class FcmService {
static bool get _isMobile => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
static Future<void> initialize() async {
static Future<void> initialize({bool requestPermission = false}) async {
try {
if (!_isMobile || Firebase.apps.isEmpty) return;
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(alert: true, badge: true, sound: true);
if (requestPermission) {
await messaging.requestPermission(alert: true, badge: true, sound: true);
}
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
@@ -104,6 +106,13 @@ class FcmService {
}
}
static Future<void> requestPermission() async {
try {
if (!_isMobile || Firebase.apps.isEmpty) return;
await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true);
} catch (_) {}
}
static Future<void> registerToken() async {
try {
if (!_isMobile || Firebase.apps.isEmpty) return;
@@ -12,7 +12,7 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _initialized = false;
Future<void> initialize() async {
Future<void> initialize({bool requestPermission = false}) async {
if (_initialized) return;
// Skip on Web and Windows (flutter_local_notifications does not support Windows platform interface)
@@ -26,9 +26,9 @@ class NotificationService {
const DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(defaultActionName: 'Open notification');
@@ -104,15 +104,30 @@ class NotificationService {
await androidImplementation?.createNotificationChannel(ch);
}
try {
await androidImplementation?.requestNotificationsPermission();
} catch (_) {}
if (requestPermission) {
try {
await androidImplementation?.requestNotificationsPermission();
} catch (_) {}
}
}
} catch (_) {}
_initialized = true;
}
Future<bool> requestPermission() async {
if (kIsWeb || !Platform.isAndroid) return true;
try {
final androidImplementation = _flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
return await androidImplementation?.requestNotificationsPermission() ?? false;
} catch (_) {
return false;
}
}
final Set<String> _recentlyShown = <String>{};
Future<void> showNotification({
@@ -31,6 +31,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../core/services/app_update_service.dart';
import '../capstone_members/capstone_members_screen.dart';
import '../documentation/documentation_screen.dart';
import '../setup/setup_wizard_screen.dart';
class TitleCaseTextInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
@@ -2459,6 +2460,25 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
if (!kIsWeb && Platform.isAndroid)
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SetupWizardScreen(isFirstTime: false),
),
);
},
icon: const Icon(Icons.security_rounded, size: 15),
label: const Text('Device Permissions'),
style: OutlinedButton.styleFrom(
foregroundColor: isDark ? AppColors.darkTextPrimary : Colors.black87,
side: BorderSide(color: isDark ? AppColors.darkBorder : Colors.grey.shade300),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
ElevatedButton.icon(
onPressed: () => DocumentationScreen.open(context),
icon: const Icon(Icons.menu_book_rounded, size: 15),
@@ -0,0 +1,682 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/constants/app_colors.dart';
import '../../shared/widgets/abstract_background.dart';
import '../../shared/widgets/app_button_loader.dart';
import '../login/login_screen.dart';
import '../../../core/services/fcm_service.dart';
import '../../../core/services/notification_service.dart';
class SetupWizardScreen extends StatefulWidget {
final bool isFirstTime;
final Widget? nextScreen;
final VoidCallback? onComplete;
const SetupWizardScreen({
super.key,
this.isFirstTime = true,
this.nextScreen,
this.onComplete,
});
@override
State<SetupWizardScreen> createState() => _SetupWizardScreenState();
}
class _SetupWizardScreenState extends State<SetupWizardScreen>
with WidgetsBindingObserver {
bool _loading = true;
bool _isRequestingAll = false;
bool _storageGranted = false;
bool _notificationGranted = false;
bool _installPackagesGranted = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkAllPermissions();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkAllPermissions();
}
}
Future<void> _checkAllPermissions() async {
if (kIsWeb || !Platform.isAndroid) {
if (mounted) {
setState(() {
_storageGranted = true;
_notificationGranted = true;
_installPackagesGranted = true;
_loading = false;
});
}
return;
}
bool storage = false;
bool notification = false;
bool installPackages = false;
try {
// 1. Storage
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt >= 30) {
storage = await Permission.manageExternalStorage.isGranted;
} else {
storage = await Permission.storage.isGranted;
}
// 2. Notification
notification = await Permission.notification.isGranted;
// 3. Install Unknown Apps
installPackages = await Permission.requestInstallPackages.isGranted;
} catch (_) {}
if (mounted) {
setState(() {
_storageGranted = storage;
_notificationGranted = notification;
_installPackagesGranted = installPackages;
_loading = false;
});
}
}
Future<void> _requestStorage() async {
if (kIsWeb || !Platform.isAndroid) return;
try {
final androidInfo = await DeviceInfoPlugin().androidInfo;
PermissionStatus status;
if (androidInfo.version.sdkInt >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
status = await Permission.storage.request();
}
if (status.isPermanentlyDenied) {
_promptOpenSettings('Storage Access');
}
} catch (_) {}
await _checkAllPermissions();
}
Future<void> _requestNotification() async {
if (kIsWeb || !Platform.isAndroid) return;
try {
final status = await Permission.notification.request();
await NotificationService().requestPermission();
await FcmService.requestPermission();
if (status.isPermanentlyDenied) {
_promptOpenSettings('Notifications');
}
} catch (_) {}
await _checkAllPermissions();
}
Future<void> _requestInstallPackages() async {
if (kIsWeb || !Platform.isAndroid) return;
try {
final status = await Permission.requestInstallPackages.request();
if (status.isPermanentlyDenied) {
_promptOpenSettings('Install Unknown Apps');
}
} catch (_) {}
await _checkAllPermissions();
}
Future<void> _requestAll() async {
if (_isRequestingAll) return;
setState(() => _isRequestingAll = true);
if (!_storageGranted) {
await _requestStorage();
}
if (!_notificationGranted) {
await _requestNotification();
}
if (!_installPackagesGranted) {
await _requestInstallPackages();
}
if (mounted) {
setState(() => _isRequestingAll = false);
}
}
void _promptOpenSettings(String permissionName) {
if (!mounted) return;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text('$permissionName Required'),
content: Text(
'$permissionName was permanently denied or requires manual approval in device settings. Please enable it in Application Settings.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
openAppSettings();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
),
child: const Text('Open Settings'),
),
],
),
);
}
Future<void> _finishSetup({bool isSkipping = false}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('setup_wizard_completed', true);
if (!mounted) return;
if (isSkipping && (!_storageGranted || !_notificationGranted || !_installPackagesGranted)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Some features (auto-updates or downloads) may require manual permission later.',
),
duration: Duration(seconds: 3),
),
);
}
if (widget.nextScreen != null) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => widget.nextScreen!),
);
return;
}
if (widget.onComplete != null) {
widget.onComplete!();
return;
}
if (widget.isFirstTime) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
return;
}
Navigator.of(context).pop();
}
int get _grantedCount {
int count = 0;
if (_storageGranted) count++;
if (_notificationGranted) count++;
if (_installPackagesGranted) count++;
return count;
}
bool get _allGranted => _storageGranted && _notificationGranted && _installPackagesGranted;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite;
final borderColor = isDark ? AppColors.darkBorder : AppColors.borderLight;
final primaryTextColor = isDark ? AppColors.darkTextPrimary : AppColors.textPrimary;
final secondaryTextColor = isDark ? AppColors.darkTextSecondary : AppColors.textSecondary;
return Scaffold(
body: AbstractBackground(
child: SafeArea(
child: _loading
? const Center(
child: CircularProgressIndicator(color: AppColors.primaryGreen),
)
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Column(
children: [
if (!widget.isFirstTime)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
child: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.of(context).pop(),
),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Header Crest & Title ───────────────
Center(
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
color: cardColor,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primaryGreen.withValues(alpha: 0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryGreen.withValues(alpha: 0.12),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
padding: const EdgeInsets.all(12),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => const Icon(
Icons.school_rounded,
size: 40,
color: AppColors.primaryGreen,
),
),
),
),
const SizedBox(height: 14),
Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'SETUP WIZARD',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
color: AppColors.primaryGreen,
),
),
),
),
const SizedBox(height: 10),
Text(
widget.isFirstTime
? 'Welcome to TIS RMS'
: 'Device Permissions',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: primaryTextColor,
),
),
const SizedBox(height: 6),
Text(
'Grant the following permissions to enable downloads, real-time sync notifications, and automatic updates on your device.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
height: 1.4,
color: secondaryTextColor,
),
),
const SizedBox(height: 20),
// ── Progress Card ───────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Permissions Progress',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: primaryTextColor,
),
),
Text(
'$_grantedCount of 3 Granted',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: _allGranted
? AppColors.primaryGreen
: AppColors.warning,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _grantedCount / 3,
minHeight: 6,
backgroundColor: isDark
? AppColors.darkSurface2
: AppColors.borderLight,
valueColor: AlwaysStoppedAnimation<Color>(
_allGranted
? AppColors.primaryGreen
: AppColors.warning,
),
),
),
],
),
),
const SizedBox(height: 16),
// ── Permission Cards ────────────────────
_buildPermissionTile(
context: context,
icon: Icons.folder_open_rounded,
iconBgColor: const Color(0xFF2E7D32).withValues(alpha: 0.12),
iconColor: const Color(0xFF2E7D32),
title: 'Storage Access',
subtitle:
'Allows downloading student records, Form 137/SF10 documents, report cards, and archives to your device storage.',
isGranted: _storageGranted,
onRequest: _requestStorage,
cardColor: cardColor,
borderColor: borderColor,
primaryTextColor: primaryTextColor,
secondaryTextColor: secondaryTextColor,
),
const SizedBox(height: 12),
_buildPermissionTile(
context: context,
icon: Icons.notifications_active_rounded,
iconBgColor: const Color(0xFF1976D2).withValues(alpha: 0.12),
iconColor: const Color(0xFF1976D2),
title: 'Push Notifications',
subtitle:
'Notifies you in real-time about newly uploaded documents, verification requests, and system alerts even when the app is in the background.',
isGranted: _notificationGranted,
onRequest: _requestNotification,
cardColor: cardColor,
borderColor: borderColor,
primaryTextColor: primaryTextColor,
secondaryTextColor: secondaryTextColor,
),
const SizedBox(height: 12),
_buildPermissionTile(
context: context,
icon: Icons.system_update_alt_rounded,
iconBgColor: const Color(0xFFE65100).withValues(alpha: 0.12),
iconColor: const Color(0xFFE65100),
title: 'Install Unknown Apps',
subtitle:
'Allows in-app auto-updates when newer TIS RMS APK versions are released so you never have to manually reinstall.',
isGranted: _installPackagesGranted,
onRequest: _requestInstallPackages,
cardColor: cardColor,
borderColor: borderColor,
primaryTextColor: primaryTextColor,
secondaryTextColor: secondaryTextColor,
),
const SizedBox(height: 24),
// ── Master Action Button ─────────────────
if (!_allGranted) ...[
ElevatedButton(
onPressed: _isRequestingAll ? null : _requestAll,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: _isRequestingAll
? const AppButtonLoader()
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield_rounded, size: 18),
SizedBox(width: 8),
Text(
'Grant All Permissions',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 10),
TextButton(
onPressed: () => _finishSetup(isSkipping: true),
style: TextButton.styleFrom(
foregroundColor: secondaryTextColor,
),
child: Text(
widget.isFirstTime
? 'Skip for now & Continue'
: 'Done',
style: const TextStyle(fontSize: 13),
),
),
] else ...[
ElevatedButton(
onPressed: () => _finishSetup(isSkipping: false),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_rounded, size: 20),
const SizedBox(width: 8),
Text(
widget.isFirstTime
? 'All Set — Continue to TIS RMS'
: 'Save & Return',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward_rounded, size: 18),
],
),
),
],
const SizedBox(height: 16),
Center(
child: Text(
'You can review or change permissions anytime in Android Settings > Apps > TIS RMS.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: secondaryTextColor.withValues(alpha: 0.8),
),
),
),
],
),
),
),
],
),
),
),
),
),
);
}
Widget _buildPermissionTile({
required BuildContext context,
required IconData icon,
required Color iconBgColor,
required Color iconColor,
required String title,
required String subtitle,
required bool isGranted,
required VoidCallback onRequest,
required Color cardColor,
required Color borderColor,
required Color primaryTextColor,
required Color secondaryTextColor,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isGranted
? AppColors.primaryGreen.withValues(alpha: 0.35)
: borderColor,
width: isGranted ? 1.5 : 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconBgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: primaryTextColor,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isGranted
? AppColors.primaryGreen.withValues(alpha: 0.12)
: Colors.orange.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isGranted ? Icons.check_circle_rounded : Icons.pending_outlined,
size: 13,
color: isGranted ? AppColors.primaryGreen : Colors.orange,
),
const SizedBox(width: 4),
Text(
isGranted ? 'Granted' : 'Required',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: isGranted ? AppColors.primaryGreen : Colors.orange,
),
),
],
),
),
],
),
const SizedBox(height: 5),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
height: 1.35,
color: secondaryTextColor,
),
),
if (!isGranted) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: onRequest,
icon: const Icon(Icons.lock_open_rounded, size: 14),
label: const Text('Grant Permission'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryGreen,
side: const BorderSide(color: AppColors.primaryGreen),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
),
],
),
);
}
}
@@ -12,9 +12,10 @@ import '../login/login_screen.dart';
import '../../layouts/windows_sidebar_layout.dart';
import '../../layouts/android_bottom_nav_layout.dart';
import '../../providers/auth_provider.dart';
import '../../../core/services/foreground_sync_service.dart';
import '../../shared/widgets/abstract_background.dart';
import '../../shared/widgets/app_button_loader.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../setup/setup_wizard_screen.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@@ -74,7 +75,6 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
}
Future<void> _initializeApp({bool isRetry = false}) async {
await ForegroundSyncService.requestPermissions();
await Future.delayed(const Duration(milliseconds: 600));
if (!mounted) return;
@@ -86,6 +86,35 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
final user = await ref.read(authProvider.notifier).tryAutoLogin();
if (!mounted) return;
// ── Step 3: Check Setup Wizard (Permissions on Android) ────────────────
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
final wizardCompleted = prefs.getBool('setup_wizard_completed') ?? false;
final isDesktop = MediaQuery.of(context).size.width >= 800;
final targetScreen = user != null
? (isDesktop
? WindowsSidebarLayout(userRole: user.role)
: AndroidBottomNavLayout(userRole: user.role))
: const LoginScreen();
if (!kIsWeb && Platform.isAndroid && !wizardCompleted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => SetupWizardScreen(
isFirstTime: true,
nextScreen: targetScreen,
),
),
);
return;
}
_navigateToNext(user);
}
void _navigateToNext(dynamic user) {
if (!mounted) return;
if (user != null) {
final isDesktop = MediaQuery.of(context).size.width >= 800;
Navigator.of(context).pushReplacement(