Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d38af631 | ||
|
|
fb1762caf7 |
No files matched your search
@@ -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
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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`).*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user