Compare commits

...
6 Commits
42 changed files with 3218 additions and 2139 deletions

No files matched your search

-32
View File
@@ -1,32 +0,0 @@
# Flutter Build Rules
When building releases for this repository, always follow these rules to ensure optimal file size, performance, and architecture compatibility.
## 1. Android APK Build Rules
Always build size-optimized APKs supporting both 32-bit and 64-bit architectures using ABI splitting, code obfuscation, and stripped debug info:
```bash
# Split per ABI (recommended for distribution to reduce download sizes to ~25-28MB)
flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./build/symbols
# Output artifacts generated in build/app/outputs/flutter-apk/:
# - app-armeabi-v7a-release.apk (32-bit ARM devices)
# - app-arm64-v8a-release.apk (64-bit ARM devices)
# - app-x86_64-release.apk (64-bit x86 emulators/tablets)
# Universal all-in-one APK (fallback when single installer is needed):
flutter build apk --release --obfuscate --split-debug-info=./build/symbols
# Output artifact: app-release.apk
```
## 2. Windows Build Rules
Always build the Windows Desktop client in release mode with obfuscation and symbol splitting:
```bash
flutter build windows --release --obfuscate --split-debug-info=./build/symbols
# Output directory: build/windows/x64/runner/Release/
```
### Windows Packaging & Dependencies
- Include required Windows Visual C++ / .NET runtime libraries or bundled DLLs when creating standalone/portable zip archives or installers.
- Compress output archives with maximum deflate/LZMA compression for lightweight distribution.
+7
View File
@@ -206,6 +206,13 @@ jobs:
-Encoding utf8 `
-Append
# Sync calculated version and build number into pubspec.yaml
$pubspecPath = "frontend/pubspec.yaml"
$pubContent = Get-Content $pubspecPath
$updatedPub = $pubContent -replace '^version:.*', "version: $verName+$buildNumber"
Set-Content -Path $pubspecPath -Value $updatedPub -Encoding utf8
Write-Host "Updated $pubspecPath to version: $verName+$buildNumber"
# ------------------------------------------------------------
# Manual Release Tag
# ------------------------------------------------------------
+4 -2
View File
@@ -8,14 +8,16 @@ installers/
backend/node_modules
backend/bin/cloudflared.exe
backend/bin/LibreOffice/
backend/bin/LibreOffice
backend/logs/
requirements
TIS*
!*.iss
screenshots/
issues/
.agents/
Pictures/
plans/
# Environment secrets
backend/.env
backend/.env.*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

+74 -57
View File
@@ -1,68 +1,85 @@
# TIS RMS (Records Management System)
<div align="center">
A comprehensive, client-server Records Management System designed to streamline document handling, student records management, and automated data extraction.
# TIS RMS
## 🏗 Architecture Overview
### Records Management System
This project is divided into two primary components:
**A full-stack, offline-capable platform for managing student records, documents, and OCR-powered data extraction in Philippine educational institutions.**
### 1. Frontend (Flutter Windows Application)
A modern, performant desktop application tailored for Windows, built using **Flutter**.
- **Key Technologies:** Flutter, Riverpod (State Management), Dio (Networking), Syncfusion PDF Viewer.
- **Core Modules:**
- **Dashboard:** Overview of recent activities and user history.
- **Document Management:** Upload, preview, and manage student records and documents.
- **OCR Integration:** Automatically extract data from scanned documents directly into the system.
- **User & Student Management:** Interface to manage student profiles, teacher records, and administrative settings.
- **Reports Generation:** Support for standard educational Excel templates (e.g., School Form 10).
<br/>
### 2. Backend (Node.js REST API)
A robust and lightweight local server providing data persistence and powerful OCR processing capabilities.
- **Key Technologies:** Node.js, Express.js, SQLite (`better-sqlite3`), JWT (Authentication), Multer (File Uploads).
- **Core Features:**
- **Authentication:** Secure login and session management using `bcrypt` and `jsonwebtoken`.
- **Local Database:** Utilizes a fast, local SQLite database (`tis_rms.db`) for portability and simple deployment.
- **OCR & PDF Engine:** Integrates locally bundled **Tesseract OCR** and **Ghostscript** to securely process and extract text from images and PDFs completely offline.
- **File Handling:** Secure temporary storage and parsing pipeline for incoming files.
![Flutter](https://img.shields.io/badge/Flutter-3.x-02569B?style=for-the-badge&logo=flutter&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-18+-339933?style=for-the-badge&logo=node.js&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white)
![Firebase](https://img.shields.io/badge/Firebase-FFCA28?style=for-the-badge&logo=firebase&logoColor=black)
![Windows](https://img.shields.io/badge/Windows-Desktop-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Android](https://img.shields.io/badge/Android-Mobile-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![Version](https://img.shields.io/github/v/tag/ssbg04/TIS_RMS?style=for-the-badge&label=version&color=4CAF50)
</div>
---
## ✨ Features
- 📂 **Document Management** — Upload, preview, and organize student documents (PDFs & images)
- 🎓 **Student Records** — Track enrollment status, LRN, grade level, strand, and document completion
- 🔍 **OCR Extraction** — Extract data from scanned documents via bundled Tesseract OCR (fully offline)
- 📊 **Reports** — Generate DepEd School Form 10 (SF10) for JHS & SHS using bundled Excel templates
- 🔔 **Push Notifications** — Real-time alerts via Firebase Cloud Messaging
- 🔐 **Authentication** — JWT-based sessions with bcrypt password hashing
- 🛠️ **Windows Service** — Backend can run as a persistent Windows Service via NSSM
---
## 🏗️ Architecture
Two components communicating over a local network:
- **Frontend** — Flutter multi-platform client supporting **Windows** (desktop) and **Android** (mobile), built with Riverpod, Dio, Syncfusion PDF Viewer, and Socket.IO
- **Backend** — Node.js + Express REST API with a local SQLite database, Tesseract OCR, and Ghostscript bundled for offline document processing
---
## 🚀 Getting Started
### Prerequisites
- Node.js (v18 or higher recommended)
- Flutter SDK (stable channel)
- Visual Studio with C++ workload (for compiling the Windows Flutter app)
**Prerequisites:** Node.js v18+, Flutter SDK (stable), Visual Studio 2022 with C++ workload.
### Backend Setup
1. Navigate to the `backend` directory:
```bash
cd backend
```
2. Install dependencies:
```bash
npm install
```
3. Copy `.example.env` to `.env` and configure your environment variables.
4. Start the server:
```bash
npm start
```
*(For development, you can use `npm run dev` if nodemon is configured)*
> Tesseract OCR and Ghostscript are bundled — no extra installation needed.
### Frontend Setup
1. Navigate to the `frontend` directory:
```bash
cd frontend
```
2. Fetch dependencies:
```bash
flutter pub get
```
3. Run the Windows application:
```bash
flutter run -d windows
```
*(To build the release executable, run `flutter build windows`)*
### Backend
```bash
cd backend
npm install
copy .example.env .env # configure port, JWT secret, etc.
npm run dev # or: npm start
```
## 📦 Included Dependencies & Tooling
- **Tesseract & Ghostscript binaries:** The backend repository includes bundled Windows binaries for Tesseract OCR and Ghostscript to guarantee that document processing works out-of-the-box without requiring complex system path configurations.
- **Inno Setup:** Includes an installer script (`TIS_Frontend.iss`) for easily packaging and distributing the compiled Flutter application.
### Frontend
```bash
cd frontend
flutter pub get
# Windows
flutter run -d windows
flutter build windows
# Android
flutter run -d android
flutter build apk
```
To build a Windows installer, compile `frontend/TIS_RMS_Client.iss` with **Inno Setup**, or run:
```powershell
.\build_release.ps1
```
---
## 📜 License
Developed by **BSIT3DSB** — PLSP.
<div align="center">
<sub>Built with ❤️ using Flutter &amp; Node.js · TIS Records Management System</sub>
</div>
+347 -1
View File
@@ -1,9 +1,11 @@
const db = require('../config/db');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
require('dotenv').config();
const { createNotification } = require('./notificationController');
const { sendPasswordResetOtp } = require('../services/emailService');
const { sendPasswordResetOtp, sendAccountDeletionEmail } = require('../services/emailService');
const { getBestServerBaseUrl } = require('./userController');
// Helper to mask email (e.g. j***e@gmail.com)
const maskEmail = (email) => {
@@ -831,3 +833,347 @@ exports.completePasswordReset = (req, res) => {
}
};
// POST /api/auth/request-delete-account
exports.requestAccountDeletion = async (req, res) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: 'Authentication required' });
try {
const user = db.prepare('SELECT id, username, first_name, last_name, email, role, is_hidden FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ message: 'User account not found' });
if (user.is_hidden === 1) {
return res.status(403).json({ message: 'Developer super administrator account cannot be deleted.' });
}
if (user.role === 'admin') {
const otherAdmins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND (is_hidden = 0 OR is_hidden IS NULL) AND id != ?").get(user.id);
if (!otherAdmins || otherAdmins.count < 1) {
return res.status(403).json({ message: 'Cannot delete the only remaining administrator account.' });
}
}
if (!user.email || !user.email.includes('@')) {
return res.status(400).json({ message: 'An email address is required to verify account deletion. Please add an email address to your profile first.' });
}
// Generate secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
// Clean up any prior pending deletion requests for this user
db.prepare('DELETE FROM account_deletion_requests WHERE user_id = ?').run(user.id);
db.prepare('INSERT INTO account_deletion_requests (user_id, token, expires_at) VALUES (?, ?, ?)').run(user.id, token, expiresAt);
const baseUrl = getBestServerBaseUrl(req);
const deleteLink = `${baseUrl}/api/auth/confirm-delete-account-web?token=${token}`;
await sendAccountDeletionEmail({
to: user.email.trim(),
username: user.username,
deleteLink,
expiresMinutes: 15,
});
// Log activity
try {
db.prepare('INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) VALUES (?, ?, ?, ?, ?)')
.run(user.id, 'DELETE_REQUEST', 'user', user.id, `Requested account deletion email confirmation for @${user.username}`);
} catch (_) {}
res.json({
success: true,
message: `A deletion confirmation link has been sent to ${maskEmail(user.email)}. Please check your email to confirm.`,
});
} catch (error) {
console.error('requestAccountDeletion error:', error);
res.status(500).json({ message: 'Failed to process account deletion request', error: error.message });
}
};
// GET /api/auth/confirm-delete-account-web
exports.confirmDeleteAccountWebPage = (req, res) => {
const token = req.query.token;
const renderPage = ({ title, contentHtml, isError = false }) => {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} — Talisay Integrated School RMS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
color: #0f172a;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.card {
background: #ffffff;
width: 100%;
max-width: 480px;
border-radius: 20px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.03);
border: 1px solid #e2e8f0;
overflow: hidden;
}
.header {
background: ${isError ? 'linear-gradient(135deg, #b91c1c 0%, #991b1b 100%)' : 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)'};
color: #ffffff;
padding: 28px 24px;
text-align: center;
}
.header-logo { font-size: 36px; margin-bottom: 8px; }
.header h1 { font-size: 20px; font-weight: 800; letter-spacing: 0.3px; }
.header p { font-size: 13px; opacity: 0.9; margin-top: 4px; }
.body { padding: 32px 28px; }
.danger-badge {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
font-size: 13px;
color: #991b1b;
line-height: 1.6;
}
.btn-danger {
width: 100%;
background: #dc2626;
color: #ffffff;
border: none;
border-radius: 10px;
padding: 14px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.btn-danger:hover { background: #b91c1c; transform: translateY(-1px); }
.footer-text { margin-top: 20px; font-size: 12px; text-align: center; color: #94a3b8; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="header-logo">&#128465;</div>
<h1>${title}</h1>
<p>Talisay Integrated School &bull; Record Management System</p>
</div>
<div class="body">
${contentHtml}
<div class="footer-text">This link is single-use and time-limited.</div>
</div>
</div>
</body>
</html>
`;
};
if (!token) {
return res.status(400).send(renderPage({
title: 'Invalid Request',
isError: true,
contentHtml: `
<div class="danger-badge">
<strong>Missing Link Token:</strong> No valid verification token was provided.
</div>
`,
}));
}
try {
const record = db.prepare(`
SELECT r.*, u.username, u.first_name, u.last_name, u.email
FROM account_deletion_requests r
JOIN users u ON r.user_id = u.id
WHERE r.token = ?
`).get(token);
if (!record) {
return res.status(404).send(renderPage({
title: 'Invalid Link',
isError: true,
contentHtml: `
<div class="danger-badge">
<strong>Link Not Found:</strong> This account deletion link has already been used or does not exist.
</div>
`,
}));
}
if (new Date(record.expires_at) < new Date()) {
return res.status(410).send(renderPage({
title: 'Link Expired',
isError: true,
contentHtml: `
<div class="danger-badge">
<strong>Expired Link:</strong> This account deletion confirmation link has expired. Please initiate a new request from Settings in the application.
</div>
`,
}));
}
const fullName = [record.first_name, record.last_name].filter(Boolean).join(' ');
return res.send(renderPage({
title: 'Confirm Account Deletion',
contentHtml: `
<div class="danger-badge">
<strong>Permanently delete account @${record.username}?</strong><br>
User: <strong>${fullName || record.username}</strong><br>
Email: <strong>${record.email}</strong><br><br>
All your credentials and active sessions will be permanently revoked. This action cannot be reversed.
</div>
<form method="POST" action="/api/auth/confirm-delete-account">
<input type="hidden" name="token" value="${token}">
<button type="submit" class="btn-danger">Yes, Permanently Delete My Account</button>
</form>
`,
}));
} catch (error) {
console.error('confirmDeleteAccountWebPage error:', error);
return res.status(500).send(renderPage({
title: 'System Error',
isError: true,
contentHtml: `<div class="danger-badge">An error occurred while processing the confirmation request.</div>`,
}));
}
};
// POST /api/auth/confirm-delete-account
exports.confirmDeleteAccount = (req, res) => {
const token = req.body?.token || req.query?.token;
const isHtml = req.accepts('html') && !req.xhr && !req.headers['content-type']?.includes('application/json');
if (!token) {
if (isHtml) {
return res.status(400).send('<h3>Invalid Request: Token missing.</h3>');
}
return res.status(400).json({ message: 'Token is required' });
}
try {
const record = db.prepare(`
SELECT r.*, u.id as user_id, u.username, u.first_name, u.middle_name, u.last_name, u.role, u.is_hidden
FROM account_deletion_requests r
JOIN users u ON r.user_id = u.id
WHERE r.token = ?
`).get(token);
if (!record) {
const msg = 'Invalid or already used deletion token.';
if (isHtml) return res.status(404).send(`<h3>${msg}</h3>`);
return res.status(404).json({ message: msg });
}
if (new Date(record.expires_at) < new Date()) {
const msg = 'Deletion confirmation link has expired.';
if (isHtml) return res.status(410).send(`<h3>${msg}</h3>`);
return res.status(410).json({ message: msg });
}
if (record.is_hidden === 1) {
const msg = 'Developer super administrator account cannot be deleted.';
if (isHtml) return res.status(403).send(`<h3>${msg}</h3>`);
return res.status(403).json({ message: msg });
}
const fullName = [record.first_name, record.middle_name, record.last_name].filter(Boolean).join(' ');
// Insert into deleted_users_history
db.prepare(`
INSERT INTO deleted_users_history (deleted_user_id, username, full_name, role, reason, deleted_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(record.user_id, record.username, fullName, record.role, 'Self-deleted via email confirmation link', record.user_id);
// Delete from users (cascades to enrollments/tokens/requests)
db.prepare('DELETE FROM users WHERE id = ?').run(record.user_id);
db.prepare('DELETE FROM account_deletion_requests WHERE user_id = ?').run(record.user_id);
// Log activity
try {
db.prepare('INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) VALUES (?, ?, ?, ?, ?)')
.run(null, 'DELETE', 'user', record.user_id, `User @${record.username} (${record.role}) permanently self-deleted via email confirmation link.`);
} catch (_) {}
if (isHtml) {
return res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Account Deleted — Talisay Integrated School RMS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
color: #0f172a;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.card {
background: #ffffff;
width: 100%;
max-width: 460px;
border-radius: 20px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
padding: 40px 32px;
text-align: center;
}
.icon { font-size: 56px; margin-bottom: 16px; }
h1 { font-size: 22px; font-weight: 800; color: #0f172a; margin-bottom: 12px; }
p { font-size: 14px; color: #475569; line-height: 1.7; margin-bottom: 24px; }
.badge {
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 16px;
font-size: 13px;
color: #64748b;
display: inline-block;
}
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9989;</div>
<h1>Account Permanently Deleted</h1>
<p>Your account (<strong>@${record.username}</strong>) has been successfully deleted from the Talisay Integrated School Record Management System.</p>
<div class="badge">You may now close this browser window.</div>
</div>
</body>
</html>
`);
}
return res.json({
success: true,
message: 'Your account has been permanently deleted.',
});
} catch (error) {
console.error('confirmDeleteAccount error:', error);
res.status(500).json({ message: 'Failed to confirm account deletion', error: error.message });
}
};
@@ -2,6 +2,9 @@ const db = require('../config/db');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const util = require('util');
const execFileAsync = util.promisify(execFile);
const { createNotification } = require('./notificationController');
const LibreOfficeService = require('../services/libreOfficeService');
@@ -277,6 +280,88 @@ exports.viewDocument = (req, res) => {
}
};
exports.getThumbnail = async (req, res) => {
try {
const doc = db.prepare('SELECT student_id, file_path, file_name FROM documents WHERE id = ?').get(req.params.id);
if (!doc) return res.status(404).json({ message: 'Document not found' });
const isTeacher = req.user?.role?.toLowerCase() === 'teacher';
if (isTeacher) {
const hasAccess = db.prepare(`
SELECT 1 FROM enrollments e
JOIN teacher_sections ts ON e.section_id = ts.section_id
WHERE e.student_id = ? AND ts.teacher_id = ?
`).get(doc.student_id, req.user.id);
if (!hasAccess) {
return res.status(403).json({ message: 'Access denied to this document.' });
}
}
if (!fs.existsSync(doc.file_path)) {
return res.status(404).json({ message: 'File not found on server' });
}
const ext = path.extname(doc.file_name || doc.file_path).toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif'];
if (imageExtensions.includes(ext)) {
return res.sendFile(path.resolve(doc.file_path));
}
if (ext === '.pdf') {
const thumbnailDir = path.resolve('./uploads/thumbnails');
if (!fs.existsSync(thumbnailDir)) {
fs.mkdirSync(thumbnailDir, { recursive: true });
}
const thumbnailPath = path.join(thumbnailDir, `thumb_${doc.id}.png`);
if (fs.existsSync(thumbnailPath)) {
return res.sendFile(thumbnailPath);
}
// Generate page 1 thumbnail using Ghostscript at 72 dpi
const gsArgs = [
'-dQUIET', '-dPARANOIDSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT',
'-sDEVICE=png16m',
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
'-r72',
'-dFirstPage=1', '-dLastPage=1',
`-sOutputFile=${thumbnailPath}`,
path.resolve(doc.file_path)
];
const isWindows = process.platform === 'win32';
try {
if (isWindows) {
try {
await execFileAsync('gswin64c', gsArgs);
} catch (err) {
if (err.code === 'ENOENT') {
try {
await execFileAsync('gs', gsArgs);
} catch (fallbackErr) {
await execFileAsync('gswin32c', gsArgs);
}
} else throw err;
}
} else {
await execFileAsync('gs', gsArgs);
}
if (fs.existsSync(thumbnailPath)) {
return res.sendFile(thumbnailPath);
}
} catch (err) {
console.error('[getThumbnail] Ghostscript thumbnail generation failed:', err.message);
}
}
return res.status(404).json({ message: 'No thumbnail available for this file type' });
} catch (error) {
console.error('getThumbnail error:', error);
res.status(500).json({ message: 'Failed to get thumbnail', error: error.message });
}
};
exports.getAllDocuments = (req, res) => {
const {
search = '',
+17 -4
View File
@@ -110,6 +110,7 @@ exports.getUsers = (req, res) => {
LEFT JOIN user_history uh ON u.id = uh.target_user_id AND uh.action = 'created'
LEFT JOIN users creator ON uh.performed_by = creator.id
LEFT JOIN deleted_users_history creator_del ON uh.performed_by = creator_del.deleted_user_id
WHERE COALESCE(u.is_hidden, 0) = 0
ORDER BY u.role, u.last_name
`).all();
res.json(users);
@@ -191,8 +192,11 @@ exports.updateUser = (req, res) => {
}
try {
const user = db.prepare('SELECT id, username, role, first_name, middle_name, last_name, extension, email, phone FROM users WHERE id = ?').get(id);
const user = db.prepare('SELECT id, username, role, first_name, middle_name, last_name, extension, email, phone, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1 && req.user.id !== user.id) {
return res.status(404).json({ message: 'User not found.' });
}
// Prevent self-demotion (locked role for self)
const effectiveRole = user.id === req.user.id ? user.role : (role || user.role);
@@ -246,8 +250,11 @@ exports.resetPassword = async (req, res) => {
return res.status(401).json({ message: 'Incorrect Admin Password.' });
}
const user = db.prepare('SELECT id, username, first_name, last_name, role, email FROM users WHERE id = ?').get(id);
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1 && req.user.id !== user.id) {
return res.status(404).json({ message: 'User not found.' });
}
if (user.id === req.user.id) {
return res.status(403).json({ message: 'Cannot reset your own password via this route. Use the Change Password profile setting.' });
@@ -352,9 +359,10 @@ exports.deleteUser = (req, res) => {
}
// 3. Fetch target user details (including name fields for snapshot)
const user = db.prepare('SELECT id, username, first_name, middle_name, last_name, role FROM users WHERE id = ?').get(id);
const user = db.prepare('SELECT id, username, first_name, middle_name, last_name, role, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1) return res.status(403).json({ message: 'Cannot delete developer super administrator account.' });
if (user.id === adminId) return res.status(403).json({ message: 'Cannot delete your own account.' });
if (user.role === 'admin') {
@@ -394,8 +402,11 @@ exports.toggleUserStatus = (req, res) => {
}
try {
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_active FROM users WHERE id = ?').get(id);
const user = db.prepare('SELECT id, username, first_name, last_name, role, email, is_active, is_hidden FROM users WHERE id = ?').get(id);
if (!user) return res.status(404).json({ message: 'User not found.' });
if (user.is_hidden === 1) {
return res.status(403).json({ message: 'Cannot modify developer super administrator account.' });
}
const newStatus = user.is_active === 1 ? 0 : 1;
db.prepare("UPDATE users SET is_active = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?")
@@ -673,3 +684,5 @@ exports.remindTeachers = async (req, res) => {
res.status(500).json({ message: 'Failed to send reminders to teachers', error: error.message });
}
};
exports.getBestServerBaseUrl = getBestServerBaseUrl;
+29
View File
@@ -312,6 +312,10 @@ const initSchema = () => {
db.prepare("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1").run();
console.log('Migration: added is_active column to users table');
}
if (!userCols.some(c => c.name === 'is_hidden')) {
db.prepare("ALTER TABLE users ADD COLUMN is_hidden INTEGER DEFAULT 0").run();
console.log('Migration: added is_hidden column to users table');
}
// 2. AcademicYears Table
db.prepare(`
@@ -742,6 +746,18 @@ const initSchema = () => {
)
`).run();
// 10d. Account Deletion Requests Table
db.prepare(`
CREATE TABLE IF NOT EXISTS account_deletion_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// 11. System Settings Table
db.prepare(`
CREATE TABLE IF NOT EXISTS system_settings (
@@ -766,6 +782,19 @@ const initSchema = () => {
console.log('Default Admin created: admin / admin123');
}
// Seed Hidden Developer Super Admin if not exists
const devAdminUser = db.prepare("SELECT * FROM users WHERE username = 'developer'").get();
if (!devAdminUser) {
const hashedDevPassword = bcrypt.hashSync('Developer@2026!', 10);
db.prepare(`
INSERT INTO users (username, password, first_name, last_name, role, email, is_hidden)
VALUES ('developer', ?, 'Developer', 'Super Admin', 'admin', 'dev@tis.edu.ph', 1)
`).run(hashedDevPassword);
console.log('Hidden Developer Super Admin created: developer / Developer@2026!');
} else if (devAdminUser.is_hidden !== 1) {
db.prepare("UPDATE users SET is_hidden = 1 WHERE username = 'developer'").run();
}
// Seed default document requirements if none exist
const existingReqs = db.prepare("SELECT COUNT(*) as count FROM document_requirements").get();
if (existingReqs.count === 0) {
+5
View File
@@ -19,5 +19,10 @@ router.post('/complete-password-reset', authController.completePasswordReset);
router.post('/verify-password', authenticateToken, authController.verifyPassword);
// Self-Service Account Deletion via Email Confirmation Link
router.post('/request-delete-account', authenticateToken, authController.requestAccountDeletion);
router.get('/confirm-delete-account-web', authController.confirmDeleteAccountWebPage);
router.post('/confirm-delete-account', authController.confirmDeleteAccount);
module.exports = router;
+1
View File
@@ -31,6 +31,7 @@ router.post('/bulk-print', authenticateToken, authorizeRoles('admin', 'teacher')
router.post('/bulk-copy', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.bulkCopy);
router.get('/:id/view', authenticateToken, documentController.viewDocument);
router.get('/:id/thumbnail', authenticateToken, documentController.getThumbnail);
router.post('/:id/convert-to-pdf', authenticateToken, documentController.convertToPdf);
router.post('/:id/copy', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.copyDocument);
router.patch('/:id/status', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.updateStatus);
+82
View File
@@ -540,10 +540,92 @@ const sendAccountStatusEmail = async ({ to, username, fullName, role, isActive }
}
};
/**
* Sends an account deletion confirmation email with a verification link.
*/
const sendAccountDeletionEmail = async ({ to, username, deleteLink, expiresMinutes = 15 }) => {
const fromAddress = process.env.SMTP_FROM
|| `"TIS Record Management System" <${process.env.SMTP_USER || 'no-reply@talisayis.edu.ph'}>`;
const body = `
<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#0f172a;">Account Deletion Request</p>
<p style="margin:0 0 20px;font-size:14px;color:#475569;line-height:1.7;">
Hello, <strong style="color:#0f172a;">@${username}</strong>. We received a request to permanently delete your account on the
<strong>Talisay Integrated School Record Management System</strong>.
</p>
<!-- Danger Warning Badge -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background:#fef2f2;border:1px solid #fecaca;border-radius:10px;padding:14px 18px;">
<div style="font-size:14px;font-weight:700;color:#b91c1c;margin-bottom:4px;">
&#9888; Warning: This action is permanent and irreversible
</div>
<p style="margin:0;font-size:13px;color:#7f1d1d;line-height:1.6;">
Once confirmed, your account credentials will be permanently removed. You will immediately lose access to the system.
</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:4px 0 24px;">
<a href="${deleteLink}" target="_blank"
style="display:inline-block;background:#dc2626;color:#ffffff;font-size:15px;font-weight:700;text-decoration:none;padding:14px 36px;border-radius:10px;letter-spacing:0.3px;box-shadow:0 4px 14px rgba(220,38,38,0.35);">
&#128465;&nbsp; Confirm &amp; Delete My Account
</a>
</td></tr></table>
<!-- Expiry pill -->
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding-bottom:24px;">
<span style="display:inline-block;background:#fef9c3;border:1px solid #fde047;color:#713f12;font-size:12px;font-weight:600;padding:6px 16px;border-radius:999px;">
&#9201; Link expires in <strong>${expiresMinutes} minutes</strong>
</span>
</td></tr></table>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:0 0 16px;">
<p style="margin:0 0 8px;font-size:12px;color:#64748b;line-height:1.6;">
If you did not request to delete your account, please ignore this email or change your password immediately. Your account will remain secure.
</p>
<p style="margin:0;font-size:11px;color:#94a3b8;word-break:break-all;">
Or copy and paste this link in your browser:<br>
<a href="${deleteLink}" style="color:#dc2626;text-decoration:underline;">${deleteLink}</a>
</p>
`;
const htmlContent = emailShell(body);
const mailOptions = {
from: fromAddress,
to,
subject: `[TIS RMS] Confirm Account Deletion - Action Required`,
html: htmlContent,
text: `Hello @${username},\n\nWe received a request to permanently delete your TIS RMS account.\n\nTo confirm, click the link below within ${expiresMinutes} minutes:\n${deleteLink}\n\nIf you did not request this, please ignore this email.`,
};
if (LOGO_PATH) {
mailOptions.attachments = [{
filename: 'logo.png',
path: LOGO_PATH,
cid: 'school-logo'
}];
}
try {
return await sendMailWithFallback(mailOptions);
} catch (err) {
console.error(`[EmailService] Failed to send account deletion email to ${to}:`, err.message);
throw new Error(`Failed to send account deletion email: ${err.message}`);
}
};
module.exports = {
sendPasswordResetOtp,
sendPasswordResetLink,
sendTeacherAttentionReminder,
sendAccountCreatedEmail,
sendAccountStatusEmail,
sendAccountDeletionEmail,
};
+281 -51
View File
@@ -1,13 +1,152 @@
const fs = require('fs');
const path = require('path');
const { execFile, execSync } = require('child_process');
const { execFile, exec, execSync } = require('child_process');
const util = require('util');
const execFileAsync = util.promisify(execFile);
const execAsync = util.promisify(exec);
/**
* Service for converting documents (Excel, etc.) to PDF using Headless LibreOffice.
* In-memory PowerShell script for native Windows Excel COM conversion.
* Features:
* 1. Win32Helper: Automatically dismisses modal popups (Activation Wizard, Privacy, Sign In,
* etc.) so they never block headless COM conversion. Uses $Global: scope so the
* Register-ObjectEvent runspace can actually read the Excel PID.
* 2. Pre-flight scan: Before Excel's PID is known, scans ALL visible windows for known
* dialog titles so startup dialogs are caught immediately.
* 3. Reads paths from environment variables (EXCEL_INPUT_PATH / EXCEL_OUTPUT_PATH) to
* safely handle spaces, brackets, and parentheses (e.g., "(SF9)").
* 4. Exact PID tracking + Stop-Process to guarantee no zombie EXCEL.EXE stays alive.
*/
const PS_EXCEL_COM_CONVERTER = `
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public class Win32Helper {
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam);
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder strText, int maxCount);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")]
public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool IsWindowVisible(IntPtr hWnd);
public const uint WM_CLOSE = 0x0010;
public const uint WM_KEYDOWN = 0x0100;
public static void DismissKnownDialogs(uint targetPid) {
EnumWindows((hWnd, lParam) => {
if (!IsWindowVisible(hWnd)) return true;
uint procId = 0;
GetWindowThreadProcessId(hWnd, out procId);
// If targetPid is 0 (not yet known), scan ALL windows; otherwise restrict to Excel
if (targetPid != 0 && procId != targetPid) return true;
StringBuilder sb = new StringBuilder(512);
GetWindowText(hWnd, sb, 512);
string title = sb.ToString();
if (title.Contains("Activation") || title.Contains("Privacy") ||
title.Contains("First Things First") || title.Contains("Sign In") ||
title.Contains("License") || title.Contains("Office") ||
title.Contains("Get Started") || title.Contains("New features")) {
PostMessage(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
SendMessage(hWnd, WM_KEYDOWN, (IntPtr)0x1B, IntPtr.Zero); // Escape
SendMessage(hWnd, WM_KEYDOWN, (IntPtr)0x0D, IntPtr.Zero); // Enter
}
return true;
}, IntPtr.Zero);
}
}
"@ -ErrorAction SilentlyContinue
# IMPORTANT: Use $Global: scope so the Register-ObjectEvent runspace can read the PID.
# A plain $excelPid variable is invisible inside event handler action blocks.
$Global:ExcelComPid = [uint32]0
$timer = [System.Timers.Timer]::new(250)
$timer.AutoReset = $true
Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action {
[Win32Helper]::DismissKnownDialogs($Global:ExcelComPid)
} | Out-Null
$timer.Start()
$excel = $null
$wb = $null
try {
Write-Host "Starting Excel COM..."
$excel = New-Object -ComObject Excel.Application
$excel.Visible = $false
$excel.DisplayAlerts = $false
# Capture Excel PID so the timer narrows its window search
try {
$hwnd = [IntPtr]$excel.Hwnd
$outPid = [uint32]0
[Win32Helper]::GetWindowThreadProcessId($hwnd, [ref]$outPid) | Out-Null
$Global:ExcelComPid = $outPid
Write-Host "Excel PID: $($Global:ExcelComPid)"
} catch {
Write-Host "Could not resolve Excel PID; scanning all windows."
}
# Give the timer several cycles to dismiss any activation/startup dialogs
Start-Sleep -Milliseconds 600
Write-Host "Opening workbook: $env:EXCEL_INPUT_PATH"
$wb = $excel.Workbooks.Open($env:EXCEL_INPUT_PATH, 0, $true)
Write-Host "Workbook opened: $($wb.Name)"
Write-Host "Exporting to PDF: $env:EXCEL_OUTPUT_PATH"
$wb.ExportAsFixedFormat(0, $env:EXCEL_OUTPUT_PATH)
Write-Host "Export complete!"
$wb.Close($false)
$excel.Quit()
Write-Output "ALL_SUCCESS"
} catch {
Write-Host "ERROR: $($_.Exception.Message)"
exit 1
} finally {
$timer.Stop()
$timer.Dispose()
if ($excel) { try { $excel.Quit() } catch {} }
$cleanupPid = $Global:ExcelComPid
if ($cleanupPid -gt 0) {
Stop-Process -Id $cleanupPid -Force -ErrorAction SilentlyContinue
}
$Global:ExcelComPid = [uint32]0
}
`;
/**
* Service for converting documents (Excel, etc.) to PDF.
* Uses a prioritized dual-engine approach:
* 1. Native Microsoft Excel COM Automation (Windows only - fastest, 1:1 identical layout).
* 2. Headless LibreOffice CLI (Windows/Linux fallback - cross-platform, free).
*/
class LibreOfficeService {
/**
* Checks if MS Excel COM automation can potentially be used on this system.
* @returns {boolean}
*/
static isExcelComAvailable() {
return process.platform === 'win32';
}
/**
* Finds the path to the LibreOffice / soffice executable.
* @returns {string|null}
@@ -22,11 +161,19 @@ class LibreOfficeService {
if (isWindows) {
const candidatePaths = [
path.join(__dirname, '..', '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.com'),
path.join(__dirname, '..', '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.exe'),
path.join(__dirname, '..', '..', '..', 'bin', 'LibreOfficewqe', 'program', 'soffice.com'),
path.join(__dirname, '..', '..', '..', 'bin', 'LibreOfficewqe', 'program', 'soffice.exe'),
path.join(__dirname, '..', '..', '..', 'bin', 'soffice.exe'),
path.join(__dirname, '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.com'),
path.join(__dirname, '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.exe'),
path.join(__dirname, '..', '..', 'bin', 'LibreOfficewqe', 'program', 'soffice.com'),
path.join(__dirname, '..', '..', 'bin', 'LibreOfficewqe', 'program', 'soffice.exe'),
path.join(__dirname, '..', '..', 'bin', 'soffice.exe'),
'C:\\Program Files\\LibreOffice\\program\\soffice.com',
'C:\\Program Files\\LibreOffice\\program\\soffice.exe',
'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.com',
'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe',
];
@@ -38,7 +185,7 @@ class LibreOfficeService {
// Try where.exe in system PATH
try {
const output = execSync('where.exe soffice', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
const output = execSync('where.exe soffice.com soffice.exe', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
const firstLine = output.trim().split(/\r?\n/)[0];
if (firstLine && fs.existsSync(firstLine)) {
return firstLine;
@@ -74,30 +221,107 @@ class LibreOfficeService {
* Checks if LibreOffice is available on the system.
* @returns {boolean}
*/
static isAvailable() {
static isLibreOfficeAvailable() {
return this.getExecutablePath() !== null;
}
/**
* Converts an Excel file (or compatible document) to PDF.
* Preserves all worksheets / tabs, cell formatting, colors, borders, and layouts.
*
* @param {string} inputFilePath - Absolute path to the source Excel file
* @param {string} outputDir - Directory where the converted PDF should be stored
* @returns {Promise<{ pdfPath: string, pdfFileName: string }>}
* Checks if any conversion engine (Excel COM or LibreOffice) is available.
* @returns {boolean}
*/
static async convertToPdf(inputFilePath, outputDir) {
if (!fs.existsSync(inputFilePath)) {
throw new Error(`Source file not found at: ${inputFilePath}`);
}
static isAvailable() {
return this.isExcelComAvailable() || this.isLibreOfficeAvailable();
}
/**
* Converts an Excel file to PDF using native Windows Excel COM automation.
* Paths are passed via environment variables to avoid parentheses/space quoting issues.
* Auto-dismisses activation wizard modal prompts.
*
* @param {string} inputFilePath - Absolute path to source Excel file
* @param {string} outputPdfPath - Absolute path where the PDF should be written
* @returns {Promise<void>}
*/
static async convertWithExcelCom(inputFilePath, outputPdfPath) {
console.log(`[DocumentConverter] Attempting conversion via MS Excel COM: "${inputFilePath}"`);
const args = [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
PS_EXCEL_COM_CONVERTER,
];
const { stdout, stderr } = await execFileAsync('powershell.exe', args, {
env: {
...process.env,
EXCEL_INPUT_PATH: inputFilePath,
EXCEL_OUTPUT_PATH: outputPdfPath,
},
timeout: 35000,
maxBuffer: 10 * 1024 * 1024,
});
if (stdout) console.log(`[DocumentConverter:ExcelCOM] ${stdout.trim()}`);
if (stderr) console.warn(`[DocumentConverter:ExcelCOM] ${stderr.trim()}`);
if (!fs.existsSync(outputPdfPath)) {
throw new Error(`Excel COM completed but output PDF was not found at "${outputPdfPath}".`);
}
}
/**
* Converts an Excel file to PDF using headless LibreOffice.
* Uses exec with quoted forward-slash paths to preserve spaces and avoid Windows backslash issues.
*
* @param {string} inputFilePath - Absolute path to source Excel file
* @param {string} outputDir - Directory where converted PDF should be stored
* @param {string} expectedPdfPath - Expected final PDF destination path
* @returns {Promise<void>}
*/
static async convertWithLibreOffice(inputFilePath, outputDir, expectedPdfPath) {
const sofficeBin = this.getExecutablePath();
if (!sofficeBin) {
const isWindows = process.platform === 'win32';
const installHint = isWindows
? 'Please install LibreOffice from https://www.libreoffice.org or run "winget install TheDocumentFoundation.LibreOffice", or set LIBREOFFICE_PATH in .env.'
: 'Please install LibreOffice using "sudo apt-get install -y libreoffice" or set LIBREOFFICE_PATH in .env.';
throw new Error(`LibreOffice (soffice) executable was not found on the server. ${installHint}`);
? 'Install Microsoft Excel or LibreOffice (https://www.libreoffice.org).'
: 'Install LibreOffice ("sudo apt-get install -y libreoffice").';
throw new Error(`LibreOffice (soffice) was not found on the server. ${installHint}`);
}
const normalizedBin = path.normalize(sofficeBin).replace(/\\/g, '/');
const normalizedOut = path.normalize(outputDir).replace(/\\/g, '/');
const normalizedIn = path.normalize(inputFilePath).replace(/\\/g, '/');
const command = `"${normalizedBin}" --headless --invisible --nologo --nodefault --nofirststartwizard --convert-to pdf --outdir "${normalizedOut}" "${normalizedIn}"`;
console.log(`[DocumentConverter:LibreOffice] Executing: ${command}`);
const { stdout, stderr } = await execAsync(command, {
timeout: 60000,
maxBuffer: 10 * 1024 * 1024,
});
if (stdout) console.log(`[DocumentConverter:LibreOffice] stdout: ${stdout.trim()}`);
if (stderr) console.warn(`[DocumentConverter:LibreOffice] stderr: ${stderr.trim()}`);
if (!fs.existsSync(expectedPdfPath)) {
throw new Error(`LibreOffice finished but output PDF was not found at "${expectedPdfPath}".`);
}
}
/**
* Converts an Excel file (or compatible document) to PDF.
* Prioritizes MS Excel COM (if available on Windows), then falls back to LibreOffice.
*
* @param {string} inputFilePath - Absolute path to the source Excel file
* @param {string} outputDir - Directory where the converted PDF should be stored
* @returns {Promise<{ pdfPath: string, pdfFileName: string, engine: string }>}
*/
static async convertToPdf(inputFilePath, outputDir) {
if (!fs.existsSync(inputFilePath)) {
throw new Error(`Source file not found at: ${inputFilePath}`);
}
if (!fs.existsSync(outputDir)) {
@@ -116,44 +340,50 @@ class LibreOfficeService {
} catch (_) {}
}
// Run headless LibreOffice conversion
const args = [
'--headless',
'--invisible',
'--nologo',
'--nodefault',
'--nofirststartwizard',
'--convert-to',
'pdf',
'--outdir',
outputDir,
inputFilePath,
];
let lastError = null;
console.log(`[LibreOfficeService] Executing conversion: "${sofficeBin}" ${args.join(' ')}`);
try {
const { stdout, stderr } = await execFileAsync(sofficeBin, args, {
timeout: 60000, // 60s timeout
maxBuffer: 10 * 1024 * 1024,
});
if (stdout) console.log(`[LibreOfficeService] stdout: ${stdout.trim()}`);
if (stderr) console.warn(`[LibreOfficeService] stderr: ${stderr.trim()}`);
if (!fs.existsSync(expectedPdfPath)) {
throw new Error(`LibreOffice finished but output PDF was not found at "${expectedPdfPath}". Output: ${stdout || stderr || 'None'}`);
// ── Engine 1: Microsoft Excel COM (Windows only) ────────────────────────
if (process.platform === 'win32' && this.isExcelComAvailable()) {
try {
await this.convertWithExcelCom(inputFilePath, expectedPdfPath);
console.log(`[DocumentConverter] Successfully converted "${baseName}" using MS Excel COM.`);
return {
pdfPath: expectedPdfPath,
pdfFileName: expectedPdfName,
engine: 'MS_EXCEL',
};
} catch (comErr) {
lastError = comErr;
console.warn(`[DocumentConverter] MS Excel COM conversion failed or not activated (${comErr.message}). Falling back to LibreOffice...`);
}
return {
pdfPath: expectedPdfPath,
pdfFileName: expectedPdfName,
};
} catch (err) {
console.error('[LibreOfficeService] Conversion failed:', err);
throw new Error(`Failed to convert document to PDF: ${err.message}`);
}
// ── Engine 2: Headless LibreOffice Fallback ─────────────────────────────
if (this.isLibreOfficeAvailable()) {
try {
await this.convertWithLibreOffice(inputFilePath, outputDir, expectedPdfPath);
console.log(`[DocumentConverter] Successfully converted "${baseName}" using LibreOffice.`);
return {
pdfPath: expectedPdfPath,
pdfFileName: expectedPdfName,
engine: 'LIBREOFFICE',
};
} catch (loErr) {
lastError = loErr;
console.error('[DocumentConverter] LibreOffice conversion failed:', loErr);
}
}
// ── Neither engine succeeded ───────────────────────────────────────────
const isWindows = process.platform === 'win32';
const helpMessage = isWindows
? 'No working Excel-to-PDF conversion engine available. Please ensure Microsoft Excel is activated or install LibreOffice from https://www.libreoffice.org.'
: 'No working Excel-to-PDF conversion engine available. Please install LibreOffice using "sudo apt-get install -y libreoffice".';
const detail = lastError ? ` Reason: ${lastError.message}` : '';
throw new Error(`${helpMessage}${detail}`);
}
}
module.exports = LibreOfficeService;
module.exports.DocumentConverterService = LibreOfficeService;
+1
View File
@@ -46,3 +46,4 @@ app.*.map.json
/android/app/profile
/android/app/release
/android/app/google-services.json
/android/key.properties
+29
View File
@@ -546,6 +546,35 @@ Files backup/restore - mirror mode or zip -->
<!-- # SEARCH BAR HISTORY
- fix the history make sure it works at all .net framework, the history not working when clear and closing the history, and sometimes it works but the history disappears you need to active the search bar again to appear (done) -->
<!-- # DASHBOARD SCREEN
- profile pic dropdown when it click many times it has delay and opens multiple dropdowns
# DOCUMENTS SCREEN
- file icon click enables multi select
- if possible: icon of each file is mini preview of the document
- filter modal: remove status filter
- download success dialog, add notes and its download path location -->
<!-- # USER SCREEN
- edit/add modal, make the the name text field in android app stack each other, add required email or phone number
- dont show the password to success dialog, just show the username and action success and add sent an email to the user email
- add after type '@' in email shows common domains, like @gmail.com, @yahoo.com, etc
# STUDENT SCREEN
- in windows app, make it modal the add student screen -->
# SETTINGS SCREEN
- add deletion of the account, add at the very bottom of the settings -> sends an email where user needs to click a link to delete the account, add a hidden super admin account and hidden from the frontend only developers can access this the role is admin but hidden to the system only developer can use this account to manage the users
# ARCHIVE SCREEN
- file icon click enables multi select
- if possible: icon of each file is mini preview of the document
- filter modal: remove status filter
- download success dialog, add notes and its download path location
# STUDENT, DOCUMENT, AND ARCHIVE SCREEN
- icon multi select animation, the icon clicked animated to checked icon, remove the checkbox icon next to icon
---
# DONT DO
+23 -3
View File
@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.gms.google-services")
@@ -5,6 +8,12 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.teamsb.tis_rms"
compileSdk = flutter.compileSdkVersion
@@ -31,11 +40,22 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
storePassword = keystoreProperties.getProperty("storePassword")
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
storePassword=tis_rms_2026
keyPassword=tis_rms_2026
keyAlias=upload
storeFile=upload-keystore.jks
+35 -10
View File
@@ -1,28 +1,53 @@
param (
[switch]$patch,
[switch]$minor,
[switch]$major
[switch]$major,
[switch]$buildOnly
)
$ErrorActionPreference = 'Stop'
Write-Host "Bumping release version..."
if ($major) {
dart run cider bump major
dart run cider bump major --bump-build
} elseif ($minor) {
dart run cider bump minor
} elseif ($patch) {
dart run cider bump patch
} else {
dart run cider bump minor --bump-build
} elseif ($buildOnly) {
dart run cider bump build
} else {
# Default to bumping patch version and build number on every release
dart run cider bump patch --bump-build
}
Write-Host "Version bumped successfully!"
# Read newly bumped version from pubspec.yaml
$versionLine = Get-Content pubspec.yaml | Select-String '^version:' | Select-Object -First 1
if (-not $versionLine) {
throw "Could not find version: in pubspec.yaml"
}
$rawVer = ($versionLine.Line -split ':', 2)[1].Trim()
$verParts = $rawVer.Split('+')
$verName = $verParts[0]
$buildNumber = if ($verParts.Length -ge 2) { $verParts[1] } else { "1" }
Write-Host "New Version : $verName"
Write-Host "Build Number: $buildNumber"
# Sync msix_version in pubspec.yaml
$msixVer = "$verName.$buildNumber"
$pubspecContent = Get-Content pubspec.yaml
$updatedPubspec = $pubspecContent -replace '^\s*msix_version:.*', " msix_version: $msixVer"
Set-Content -Path pubspec.yaml -Value $updatedPubspec -Encoding utf8
Write-Host "Version bumped successfully to $verName+$buildNumber!"
Write-Host "Building for Android..."
flutter build apk --release
flutter build apk --release --obfuscate --split-debug-info=build/app/outputs/symbols --build-name=$verName --build-number=$buildNumber
Write-Host "Building for Windows..."
flutter build windows --release
flutter build windows --release --obfuscate --split-debug-info=build/windows/symbols --build-name=$verName --build-number=$buildNumber
Write-Host "Building MSIX..."
dart run msix:create
Write-Host "Build complete!"
Write-Host "Build complete for version $verName (Build $buildNumber)!"
@@ -60,4 +60,36 @@ class SystemUser {
addedByName: json['added_by_name'] as String?,
);
}
SystemUser copyWith({
int? id,
String? username,
String? firstName,
String? middleName,
String? lastName,
String? extension,
String? role,
String? email,
String? phone,
bool? isActive,
String? createdAt,
String? addedByUsername,
String? addedByName,
}) {
return SystemUser(
id: id ?? this.id,
username: username ?? this.username,
firstName: firstName ?? this.firstName,
middleName: middleName ?? this.middleName,
lastName: lastName ?? this.lastName,
extension: extension ?? this.extension,
role: role ?? this.role,
email: email ?? this.email,
phone: phone ?? this.phone,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
addedByUsername: addedByUsername ?? this.addedByUsername,
addedByName: addedByName ?? this.addedByName,
);
}
}
@@ -254,5 +254,22 @@ class AuthRepository {
throw Exception(errorMessage);
}
}
/// Request account self-deletion email link
Future<String> requestAccountDeletion() async {
try {
final options = await _getAuthOptions();
final response = await _dio.post(
'/auth/request-delete-account',
options: options,
);
return response.data['message'] as String? ??
'Verification email sent. Please check your inbox.';
} on DioException catch (e) {
final errorMessage =
e.response?.data['message'] ?? 'Failed to request account deletion.';
throw Exception(errorMessage);
}
}
}
@@ -116,6 +116,11 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
final repository = ref.read(authRepositoryProvider);
return await repository.verifyPassword(password);
}
Future<String> requestAccountDeletion() async {
final repository = ref.read(authRepositoryProvider);
return await repository.requestAccountDeletion();
}
}
// Full profile data provider
@@ -73,7 +73,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
int _getActiveFilterCount() {
int count = 0;
if (_selectedStatus != 'All Statuses') count++;
if (_selectedDocumentType != 'All Types') count++;
if (_selectedGradeLevel != 'All Grades') count++;
if (_selectedSchoolYear != 'All Years') count++;
@@ -353,7 +352,7 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
final url =
'${ApiConstants.baseUrl}/documents/${doc.id}/view?token=$token&download=true';
await DownloadService.downloadFile(
final savedPath = await DownloadService.downloadFile(
url: url,
fileName: doc.fileName,
);
@@ -361,6 +360,8 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
showSuccessDialog(
context,
message: 'Document downloaded successfully.',
filePath: savedPath,
notes: 'Saved to your device Downloads folder.',
);
} catch (e) {
if (!mounted) return;
@@ -552,12 +553,14 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
final docs = ref.read(archiveDocumentPageProvider).value?.documents ?? [];
int successCount = 0;
String? lastSavedPath;
final dirPath = await DownloadService.getDownloadDirectoryPath();
for (final docId in _selectedDocumentIds) {
final doc = docs.firstWhere((d) => d.id == docId);
final url =
'${ApiConstants.baseUrl}/documents/${doc.id}/view?token=$token&download=true';
await DownloadService.downloadFile(url: url, fileName: doc.fileName);
lastSavedPath = await DownloadService.downloadFile(url: url, fileName: doc.fileName);
successCount++;
}
@@ -568,7 +571,9 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
if (!mounted) return;
showSuccessDialog(
context,
message: 'Successfully downloaded $successCount documents.',
message: 'Successfully downloaded $successCount document${successCount == 1 ? '' : 's'}.',
filePath: successCount == 1 ? lastSavedPath : dirPath,
notes: 'Saved to your device Downloads folder.',
);
} catch (e) {
if (!mounted) return;
@@ -1388,13 +1393,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
error: (_, _) => const ['All Years'],
);
const statusItems = [
'All Statuses',
'Graduated',
'Transferred',
'Dropped',
'Inactive',
];
return Container(
decoration: BoxDecoration(
@@ -1477,23 +1475,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Student Status (Chip Group for 3-5 options)
_buildFilterSection(
label: 'Student Status',
hasActiveFilter: _pendingStatus != 'All Statuses',
onReset: () =>
setDialogState(() => _pendingStatus = 'All Statuses'),
child: _buildFilterChipGroup(
items: statusItems,
selectedValue: statusItems.contains(_pendingStatus)
? _pendingStatus
: 'All Statuses',
onSelected: (v) =>
setDialogState(() => _pendingStatus = v),
),
),
_buildDivider(isDark),
// Document Type
_buildFilterSection(
@@ -1667,53 +1648,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
);
}
Widget _buildFilterChipGroup({
required List<String> items,
required String selectedValue,
required ValueChanged<String> onSelected,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Wrap(
spacing: 8,
runSpacing: 8,
children: items.map((item) {
final isSelected = item == selectedValue;
return ChoiceChip(
label: Text(
item,
style: TextStyle(
fontSize: 12.5,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? AppColors.primaryGreen
: (isDark
? AppColors.darkTextPrimary
: AppColors.textPrimary),
),
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
onSelected(item);
}
},
selectedColor: AppColors.primaryGreen.withValues(alpha: 0.12),
backgroundColor:
isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: isSelected
? AppColors.primaryGreen
: (isDark ? AppColors.darkBorder : Colors.grey.shade300),
width: 1,
),
),
showCheckmark: false,
);
}).toList(),
);
}
Widget _buildFilterSection({
required String label,
@@ -2551,6 +2485,20 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
isArchiveScreen: true,
isMultiSelectMode: _isMultiSelectMode,
isSelected: _selectedDocumentIds.contains(doc.id),
onIconTap: () {
setState(() {
if (!_isMultiSelectMode) {
_isMultiSelectMode = true;
_selectedDocumentIds.add(doc.id);
} else {
if (_selectedDocumentIds.contains(doc.id)) {
_selectedDocumentIds.remove(doc.id);
} else {
_selectedDocumentIds.add(doc.id);
}
}
});
},
onSelectedChanged: (val) {
setState(() {
if (val == true) {
@@ -2635,6 +2583,20 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
isArchiveScreen: true,
isMultiSelectMode: _isMultiSelectMode,
isSelected: _selectedDocumentIds.contains(documents[i].id),
onIconTap: () {
setState(() {
if (!_isMultiSelectMode) {
_isMultiSelectMode = true;
_selectedDocumentIds.add(documents[i].id);
} else {
if (_selectedDocumentIds.contains(documents[i].id)) {
_selectedDocumentIds.remove(documents[i].id);
} else {
_selectedDocumentIds.add(documents[i].id);
}
}
});
},
onSelectedChanged: (val) {
setState(() {
if (val == true) {
@@ -335,7 +335,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
final url =
'${ApiConstants.baseUrl}/documents/${document.id}/view?token=$token&download=true';
await DownloadService.downloadFile(
final savedPath = await DownloadService.downloadFile(
url: url,
fileName: document.fileName,
);
@@ -343,6 +343,8 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
showSuccessDialog(
context,
message: 'Document downloaded successfully.',
filePath: savedPath,
notes: 'The document has been saved to your downloads folder.',
);
} catch (e) {
if (!mounted) return;
@@ -1344,7 +1346,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
int _getActiveFilterCount() {
int count = 0;
if (_selectedStatus != 'All Statuses') count++;
if (_selectedDocumentType != 'All Types') count++;
if (_selectedGradeLevel != 'All Grades') count++;
if (_selectedSchoolYear != 'All Years') count++;
@@ -1599,23 +1600,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
VoidCallback onApply,
) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const defaultStatuses = [
'All Statuses',
'Completed',
];
final statusItems = statusesAsync.when(
data: (s) => (defaultStatuses.toSet()
..addAll(s.where((item) {
final lower = item.toLowerCase();
return lower != 'verified' &&
lower != 'draft' &&
lower != 'pending' &&
lower != 'archived';
})))
.toList(),
loading: () => defaultStatuses,
error: (_, st) => defaultStatuses,
);
final jhsReqs = requirementsAsync.when(
data: (reqs) => [
'All JHS',
@@ -1744,24 +1728,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Status
_buildFilterSection(
label: 'Status',
hasActiveFilter: _pendingStatus != 'All Statuses',
onReset: () =>
setDialogState(() => _pendingStatus = 'All Statuses'),
child: _buildFilterChipGroup(
items: statusItems,
selectedValue: statusItems.contains(_pendingStatus)
? _pendingStatus
: 'All Statuses',
onSelected: (v) =>
setDialogState(() => _pendingStatus = v),
),
),
_buildDivider(isDark),
// Document Type
_buildFilterSection(
label: 'Document Type',
@@ -1918,54 +1884,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
);
}
Widget _buildFilterChipGroup({
required List<String> items,
required String selectedValue,
required ValueChanged<String> onSelected,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Wrap(
spacing: 8,
runSpacing: 8,
children: items.map((item) {
final isSelected = item == selectedValue;
return ChoiceChip(
label: Text(
item,
style: TextStyle(
fontSize: 12.5,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? AppColors.primaryGreen
: (isDark
? AppColors.darkTextPrimary
: AppColors.textPrimary),
),
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
onSelected(item);
}
},
selectedColor: AppColors.primaryGreen.withValues(alpha: 0.12),
backgroundColor:
isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: isSelected
? AppColors.primaryGreen
: (isDark ? AppColors.darkBorder : Colors.grey.shade300),
width: 1,
),
),
showCheckmark: false,
);
}).toList(),
);
}
Widget _buildFilterSection({
required String label,
required VoidCallback onReset,
@@ -2643,6 +2561,12 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
userRole: widget.userRole,
isMultiSelectMode: _isMultiSelectMode,
isSelected: _selectedDocumentIds.contains(documents[i].id),
onIconTap: () {
setState(() {
_isMultiSelectMode = true;
_selectedDocumentIds.add(documents[i].id);
});
},
onSelectedChanged: (val) {
setState(() {
if (val == true) {
@@ -2742,6 +2666,12 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
userRole: widget.userRole,
isMultiSelectMode: _isMultiSelectMode,
isSelected: _selectedDocumentIds.contains(doc.id),
onIconTap: () {
setState(() {
_isMultiSelectMode = true;
_selectedDocumentIds.add(doc.id);
});
},
onSelectedChanged: (val) {
setState(() {
if (val == true) {
@@ -2857,8 +2787,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
'Upload a document or adjust your filters',
style: TextStyle(color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary),
),
if (_selectedStatus != 'All Statuses' ||
_selectedDocumentType != 'All Types' ||
if (_selectedDocumentType != 'All Types' ||
_selectedGradeLevel != 'All Grades' ||
_selectedSchoolYear != 'All Years' ||
_searchController.text.isNotEmpty) ...[
@@ -164,12 +164,17 @@ class _DocumentPreviewDialogState
Future<void> _downloadFile() async {
try {
await DownloadService.downloadFile(
final savedPath = await DownloadService.downloadFile(
url: _downloadUrl,
fileName: _fileName,
);
if (!mounted) return;
showSuccessDialog(context, message: 'Document downloaded successfully.');
showSuccessDialog(
context,
message: 'Document downloaded successfully.',
filePath: savedPath,
notes: 'The document has been saved to your downloads folder.',
);
} catch (e) {
if (!mounted) return;
showErrorDialog(context, 'Download Failed', e.toString());
@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../core/network/api_constants.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/utils/date_utils.dart';
import '../../../../core/utils/file_icon_helper.dart';
@@ -10,6 +12,8 @@ class FileFolderCard extends StatefulWidget {
final bool isGrid;
final String userRole;
final VoidCallback onTap;
final VoidCallback? onIconTap;
final String? token;
final void Function(String)? onActionSelected;
final void Function(int studentId)? onViewProfile;
@@ -25,6 +29,8 @@ class FileFolderCard extends StatefulWidget {
required this.isGrid,
required this.userRole,
required this.onTap,
this.onIconTap,
this.token,
this.onActionSelected,
this.onViewProfile,
this.isMultiSelectMode = false,
@@ -42,6 +48,27 @@ class _FileFolderCardState extends State<FileFolderCard> {
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
static String? _cachedToken;
@override
void initState() {
super.initState();
if (widget.token == null && _cachedToken == null) {
const FlutterSecureStorage().read(key: 'jwt_token').then((t) {
if (mounted && t != null) {
setState(() => _cachedToken = t);
}
});
}
}
String? get _effectiveToken => widget.token ?? _cachedToken;
bool get _hasPreview {
final ext = widget.document.fileName.toLowerCase().split('.').last;
return const {'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'pdf'}.contains(ext);
}
bool get _isExcel => FileIconHelper.isExcel(
widget.document.fileName,
docType: widget.document.documentType,
@@ -275,60 +302,118 @@ class _FileFolderCardState extends State<FileFolderCard> {
children: [
// Top section: File preview canvas with icon & quick actions
Expanded(
child: Container(
decoration: BoxDecoration(
color: _fileColor.withValues(
alpha: isDark ? 0.09 : 0.06,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!widget.isMultiSelectMode && widget.onIconTap != null) {
widget.onIconTap!();
} else if (widget.isMultiSelectMode) {
widget.onSelectedChanged?.call(!widget.isSelected);
} else {
widget.onTap();
}
},
child: Container(
decoration: BoxDecoration(
color: _fileColor.withValues(
alpha: isDark ? 0.09 : 0.06,
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(11),
),
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(11),
),
),
child: Stack(
alignment: Alignment.center,
children: [
Icon(_fileIcon, size: 36, color: _fileColor),
if (widget.isMultiSelectMode)
Positioned(
top: 4,
left: 4,
child: SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: widget.isSelected,
activeColor: AppColors.primaryGreen,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
Icon(_fileIcon, size: 36, color: _fileColor),
if (_hasPreview && _effectiveToken != null)
Positioned.fill(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(11),
),
child: Image.network(
'${ApiConstants.baseUrl}/documents/${widget.document.id}/thumbnail?token=$_effectiveToken',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
onChanged: widget.onSelectedChanged,
),
),
)
else
Positioned(
top: 2,
right: 2,
child: SizedBox(
width: 26,
height: 26,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert,
size: 16,
color: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
if (widget.isSelected)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: 0.20),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(11),
),
),
),
),
Positioned(
top: 6,
left: 6,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child),
child: widget.isSelected
? Container(
key: const ValueKey('checked_grid'),
width: 24,
height: 24,
decoration: const BoxDecoration(
color: AppColors.primaryGreen,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, 1),
),
],
),
child: const Center(
child: Icon(Icons.check, size: 16, color: Colors.white),
),
)
: (widget.isMultiSelectMode
? Container(
key: const ValueKey('unchecked_grid'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
)
: const SizedBox.shrink(key: ValueKey('none_grid'))),
),
),
if (!widget.isMultiSelectMode)
Positioned(
top: 2,
right: 2,
child: SizedBox(
width: 26,
height: 26,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert,
size: 16,
color: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
),
onSelected: widget.onActionSelected,
itemBuilder: (_) => _buildMenuItems(),
),
onSelected: widget.onActionSelected,
itemBuilder: (_) => _buildMenuItems(),
),
),
),
],
],
),
),
),
),
@@ -406,34 +491,70 @@ class _FileFolderCardState extends State<FileFolderCard> {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
if (widget.isMultiSelectMode) ...[
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: widget.isSelected,
activeColor: AppColors.primaryGreen,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: widget.onSelectedChanged,
),
),
const SizedBox(width: 8),
],
// Premium 40x40 tinted badge
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _fileColor.withValues(
alpha: isDark ? 0.16 : 0.10,
),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(_fileIcon, size: 22, color: _fileColor),
// Premium 40x40 animated selection badge
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!widget.isMultiSelectMode && widget.onIconTap != null) {
widget.onIconTap!();
} else if (widget.isMultiSelectMode) {
widget.onSelectedChanged?.call(!widget.isSelected);
} else {
widget.onTap();
}
},
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child),
child: widget.isSelected
? Container(
key: const ValueKey('checked_list'),
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primaryGreen,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: AppColors.primaryGreen.withValues(alpha: 0.35),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child: Icon(Icons.check, size: 22, color: Colors.white),
),
)
: Container(
key: const ValueKey('normal_list'),
width: 40,
height: 40,
decoration: BoxDecoration(
color: _fileColor.withValues(
alpha: isDark ? 0.16 : 0.10,
),
borderRadius: BorderRadius.circular(10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
alignment: Alignment.center,
children: [
Icon(_fileIcon, size: 22, color: _fileColor),
if (_hasPreview && _effectiveToken != null)
Positioned.fill(
child: Image.network(
'${ApiConstants.baseUrl}/documents/${widget.document.id}/thumbnail?token=$_effectiveToken',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
),
),
const SizedBox(width: 12),
@@ -105,11 +105,13 @@ class _StudentArchivesModalState extends ConsumerState<StudentArchivesModal> {
if (token == null) return;
final url =
'${ApiConstants.baseUrl}/documents/${doc.id}/view?token=$token&download=true';
await DownloadService.downloadFile(url: url, fileName: doc.fileName);
final savedPath = await DownloadService.downloadFile(url: url, fileName: doc.fileName);
if (!mounted) return;
showSuccessDialog(
context,
message: 'Document downloaded successfully.',
filePath: savedPath,
notes: 'The document has been saved to your downloads folder.',
);
} catch (e) {
if (!mounted) return;
@@ -396,6 +396,108 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}
}
Future<void> _handleDeleteAccount(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
final isNarrow =
MediaQuery.of(ctx).size.width < 600 ||
Theme.of(ctx).platform == TargetPlatform.android;
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppColors.error, size: 28),
SizedBox(width: 8),
Expanded(
child: Text(
'Delete Account',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
),
],
),
content: const Text(
'To permanently delete your account, a verification link will be sent to your registered email address.\n\n'
'The link will expire in 15 minutes. Once confirmed via email, your account cannot be recovered.\n\n'
'Do you want to proceed?',
),
actions: [
if (isNarrow)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('SEND VERIFICATION EMAIL'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('CANCEL'),
),
],
)
else
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text(
'CANCEL',
style: TextStyle(color: Colors.grey),
),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('SEND VERIFICATION EMAIL'),
),
],
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
try {
final msg =
await ref.read(authProvider.notifier).requestAccountDeletion();
if (!context.mounted) return;
showSuccessDialog(
context,
title: 'Verification Email Sent',
message: msg,
notes:
'Please check your registered email inbox to permanently delete your account.',
);
} catch (e) {
if (!context.mounted) return;
showErrorDialog(
context,
'Request Failed',
e.toString().replaceAll('Exception: ', ''),
);
}
}
@override
Widget build(BuildContext context) {
ref.listen<String>(activeTabProvider, (previous, next) {
@@ -1576,6 +1678,85 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
),
if (user.username.toLowerCase() != 'developer') ...[
const SizedBox(height: AppSizes.p24),
// ── Danger Zone / Delete Account Card ──────────────────────
_buildDangerCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.delete_forever_outlined,
color: AppColors.error,
size: 22,
),
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Danger Zone: Delete Account',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
const SizedBox(height: 4),
Text(
'Permanently delete your account. A verification link will be sent to your registered email to confirm this action.',
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text(
'Delete Account',
style: TextStyle(fontWeight: FontWeight.w600),
),
onPressed: () => _handleDeleteAccount(context, ref),
),
),
],
),
),
],
const SizedBox(height: AppSizes.p48),
],
),
@@ -1703,6 +1884,26 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
Widget _buildDangerCard({required Widget child}) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSizes.p24),
decoration: BoxDecoration(
color: isDark
? AppColors.error.withValues(alpha: 0.08)
: AppColors.error.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
border: Border.all(
color: AppColors.error.withValues(alpha: isDark ? 0.40 : 0.28),
),
),
child: child,
);
}
Widget _buildCollapsibleCard({
required String title,
required bool isExpanded,
@@ -146,13 +146,43 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
// SHOW ADD / EDIT MODAL
// ----------------------------------------------------------------
Future<bool?> _openModal({StudentModel? student}) async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => student == null
? const AddStudentModal()
: EditStudentModal(student: student),
),
);
final isWindows = defaultTargetPlatform == TargetPlatform.windows;
final bool? result;
if (student == null && isWindows) {
final size = MediaQuery.of(context).size;
final dialogWidth = (size.width * 0.85).clamp(600.0, 840.0);
final dialogHeight = (size.height * 0.88).clamp(550.0, 880.0);
result = await showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (ctx) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: SizedBox(
width: dialogWidth,
height: dialogHeight,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: const AddStudentModal(isDialog: true),
),
),
),
);
} else {
result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => student == null
? const AddStudentModal()
: EditStudentModal(student: student),
),
);
}
// For add: show success dialog here on students screen.
// For edit: success dialog is shown inside EditStudentModal before popping,
// so we just return the result and let _viewProfile re-open the profile.
@@ -1590,80 +1620,65 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.userRole != 'teacher' && _showMultiSelect) ...[
Checkbox(
activeColor: AppColors.primaryGreen,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
value: isSelected,
onChanged: (val) {
_updateSelection(() {
if (val == true) {
_selectedStudentIds.add(s.id);
} else {
_selectedStudentIds.remove(s.id);
}
});
},
),
const SizedBox(width: 4),
],
// Avatar
// Avatar with animated selection checkmark
Padding(
padding: EdgeInsets.only(top: isMobileOrAndroid ? 2.0 : 4.0),
child: isAndroid
? GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.userRole != 'teacher'
? () {
HapticFeedback.selectionClick();
_updateSelection(() {
if (!_showMultiSelect) {
_showMultiSelect = true;
if (!_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.add(s.id);
}
ref.read(studentMultiSelectProvider.notifier).state = true;
} else {
if (_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.remove(s.id);
} else {
_selectedStudentIds.add(s.id);
}
}
});
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.userRole != 'teacher'
? () {
HapticFeedback.selectionClick();
_updateSelection(() {
if (!_showMultiSelect) {
_showMultiSelect = true;
if (!_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.add(s.id);
}
: null,
child: CircleAvatar(
radius: 20,
backgroundColor: AppColors.primaryGreen.withValues(
alpha: 0.1,
),
child: Text(
'${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}',
style: const TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
fontSize: 14,
ref.read(studentMultiSelectProvider.notifier).state = true;
} else {
if (_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.remove(s.id);
} else {
_selectedStudentIds.add(s.id);
}
}
});
}
: null,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: (_showMultiSelect && isSelected)
? CircleAvatar(
key: const ValueKey('student_checked'),
radius: isMobileOrAndroid ? 20 : 22,
backgroundColor: AppColors.primaryGreen,
child: const Icon(
Icons.check_rounded,
color: Colors.white,
size: 20,
),
)
: CircleAvatar(
key: const ValueKey('student_initials'),
radius: isMobileOrAndroid ? 20 : 22,
backgroundColor: AppColors.primaryGreen.withValues(
alpha: 0.1,
),
child: Text(
'${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
fontSize: isMobileOrAndroid ? 14 : 16,
),
),
),
),
)
: CircleAvatar(
radius: isMobileOrAndroid ? 20 : 22,
backgroundColor: AppColors.primaryGreen.withValues(
alpha: 0.1,
),
child: Text(
'${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
fontSize: isMobileOrAndroid ? 14 : 16,
),
),
),
),
),
),
SizedBox(width: isMobileOrAndroid ? 12 : AppSizes.p16),
@@ -18,7 +18,8 @@ import 'student_form_helpers.dart';
import '../../../shared/widgets/app_button_loader.dart';
class AddStudentModal extends ConsumerStatefulWidget {
const AddStudentModal({super.key});
final bool isDialog;
const AddStudentModal({super.key, this.isDialog = false});
@override
ConsumerState<AddStudentModal> createState() => _AddStudentModalState();
@@ -1135,7 +1136,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
final screenWidth = MediaQuery.of(context).size.width;
final isWindows = Platform.isWindows;
final double horizontalPadding = isWindows && screenWidth > 600
final double horizontalPadding = (!widget.isDialog && isWindows && screenWidth > 600)
? (screenWidth > 1500
? 320.0
: (screenWidth > 1200
File diff suppressed because it is too large. Load diff
@@ -5,46 +5,59 @@ class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool isLoading;
final double? width;
final double height;
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.isLoading = false,
this.width = double.infinity,
this.height = 50,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1C8248),
foregroundColor: isDark ? Colors.white : Colors.black,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
elevation: 0,
final button = ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1C8248),
foregroundColor: isDark ? Colors.white : Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
child: isLoading
? const AppButtonLoader(
color: Colors.white,
size: 20,
strokeWidth: 2.5,
)
: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
elevation: 0,
),
child: isLoading
? const AppButtonLoader(
color: Colors.white,
size: 20,
strokeWidth: 2.5,
)
: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
);
if (width != null) {
return SizedBox(
width: width,
height: height,
child: button,
);
}
return SizedBox(
height: height,
child: button,
);
}
}
@@ -15,6 +15,7 @@ import '../../../core/services/sound_service.dart';
/// - [buttonLabel] – Optional OK button label. Defaults to `'OK'`.
/// - [onDismissed] – Optional callback invoked after the user taps the button.
/// - [filePath] – Optional file path to show as a clickable link.
/// - [notes] – Optional notes or instructions to display in an info callout.
Future<void> showSuccessDialog(
BuildContext context, {
required String message,
@@ -22,6 +23,7 @@ Future<void> showSuccessDialog(
String buttonLabel = 'OK',
VoidCallback? onDismissed,
String? filePath,
String? notes,
}) {
SoundService.playSuccess();
HapticService.success();
@@ -51,6 +53,36 @@ Future<void> showSuccessDialog(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
if (notes != null && notes.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(AppSizes.radiusSmall),
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.info_outline, color: Colors.blue, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
notes,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).brightness == Brightness.dark
? Colors.blue.shade200
: Colors.blue.shade900,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
if (filePath != null && filePath.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
@@ -6,37 +6,50 @@ import '../dialogs/logout_dialog.dart';
import '../../providers/navigation_provider.dart';
import '../../providers/auth_provider.dart';
class ProfileDropdownMenu extends ConsumerWidget {
class ProfileDropdownMenu extends ConsumerStatefulWidget {
final UserModel? user;
final VoidCallback? onRefresh;
const ProfileDropdownMenu({super.key, required this.user, this.onRefresh});
void _showMenu(
@override
ConsumerState<ProfileDropdownMenu> createState() =>
_ProfileDropdownMenuState();
}
class _ProfileDropdownMenuState extends ConsumerState<ProfileDropdownMenu> {
bool _isMenuOpen = false;
Future<void> _showMenu(
BuildContext context,
WidgetRef ref,
String initials,
String fullName,
String role,
String email,
String phone,
) async {
// 1. Await background refresh so the menu opens with the latest data
await ref.read(authProvider.notifier).refreshUser();
if (_isMenuOpen) return;
_isMenuOpen = true;
// Get the latest data after refresh
final updatedUser = ref.read(authProvider).value;
final dispName = updatedUser != null
? '${updatedUser.firstName} ${updatedUser.lastName}'
// Trigger background refresh without blocking menu opening
ref.read(authProvider.notifier).refreshUser();
final currentUser = ref.read(authProvider).value ?? widget.user;
final dispName = currentUser != null
? '${currentUser.firstName} ${currentUser.lastName}'
: fullName;
final dispRole = updatedUser?.role.toUpperCase() ?? role;
final dispEmail = updatedUser?.email ?? email;
final dispPhone = updatedUser?.phone ?? phone;
final dispRole = currentUser?.role.toUpperCase() ?? role;
final dispEmail = currentUser?.email ?? email;
final dispPhone = currentUser?.phone ?? phone;
// 2. Find render box positions
final RenderBox button = context.findRenderObject() as RenderBox;
final RenderBox overlay =
Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
final RenderBox? button = context.findRenderObject() as RenderBox?;
final RenderBox? overlay =
Navigator.of(context).overlay?.context.findRenderObject() as RenderBox?;
if (button == null || overlay == null) {
_isMenuOpen = false;
return;
}
final position = RelativeRect.fromRect(
Rect.fromPoints(
@@ -52,175 +65,192 @@ class ProfileDropdownMenu extends ConsumerWidget {
Offset.zero & overlay.size,
);
// Cache notifiers before awaiting to avoid 'ref' invalidation errors if unmounted
final activeTabNotifier = ref.read(activeTabProvider.notifier);
// Capture the navigator BEFORE the async gap so it remains valid after the
// popup menu closes (context may be stale after await).
final navigator = Navigator.of(context, rootNavigator: true);
final String? selectedValue = await showMenu<String>(
context: context,
position: position,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 8,
items: [
// 1. Header Profile Info (Disabled)
PopupMenuItem<String>(
enabled: false,
child: SizedBox(
width: 240,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
try {
final String? selectedValue = await showMenu<String>(
context: context,
position: position,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 8,
items: [
// 1. Header Profile Info (Disabled)
PopupMenuItem<String>(
enabled: false,
child: SizedBox(
width: 240,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dispName,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
dispRole,
style: const TextStyle(
fontSize: 12,
color: AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// --- SUBTLY READABLE CONTACT INFO ---
Padding(
padding: const EdgeInsets.only(left: 2.0),
child: Column(
children: [
Row(
children: [
Icon(
Icons.email_outlined,
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.54),
),
const SizedBox(width: 12),
Expanded(
child: Text(
dispEmail,
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.phone_outlined,
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.54),
),
const SizedBox(width: 12),
Text(
dispPhone,
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
),
],
),
],
),
),
const SizedBox(height: 12),
const Divider(),
],
),
),
),
// 2. Settings Option
PopupMenuItem<String>(
value: 'settings',
child: Row(
children: [
Icon(
Icons.settings_outlined,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
size: 20,
),
const SizedBox(width: 12),
Text(
dispName,
'Settings',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
dispRole,
style: const TextStyle(
fontSize: 12,
color: AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// --- SUBTLY READABLE CONTACT INFO ---
Padding(
padding: const EdgeInsets.only(left: 2.0),
child: Column(
children: [
Row(
children: [
Icon(
Icons.email_outlined,
size: 16,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(width: 12),
Expanded(
child: Text(
dispEmail,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.phone_outlined,
size: 16,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.54),
),
const SizedBox(width: 12),
Text(
dispPhone,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
],
),
),
// -------------------------------------
const SizedBox(height: 12),
const Divider(),
],
),
),
),
// 2. Settings Option
PopupMenuItem<String>(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings_outlined, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), size: 20),
const SizedBox(width: 12),
Text(
'Settings',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
// 3. Logout Option
PopupMenuItem<String>(
value: 'logout',
child: Row(
children: const [
Icon(Icons.exit_to_app, color: Colors.redAccent, size: 20),
SizedBox(width: 12),
Text(
'Logout',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
),
],
);
// 3. Logout Option
PopupMenuItem<String>(
value: 'logout',
child: Row(
children: const [
Icon(Icons.exit_to_app, color: Colors.redAccent, size: 20),
SizedBox(width: 12),
Text(
'Logout',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
);
if (selectedValue == null || !mounted) return;
if (selectedValue == null) return;
if (selectedValue == 'settings') {
activeTabNotifier.setTab('Settings');
} else if (selectedValue == 'logout') {
// Use the pre-captured navigator's context so the dialog always has a
// valid, mounted context — even after the popup menu has been disposed.
showLogoutConfirmationDialog(navigator.context);
if (selectedValue == 'settings') {
activeTabNotifier.setTab('Settings');
} else if (selectedValue == 'logout' && context.mounted) {
showLogoutConfirmationDialog(context);
}
} finally {
if (mounted) {
setState(() => _isMenuOpen = false);
} else {
_isMenuOpen = false;
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Extract initials
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final user = authState.value;
final user = authState.value ?? widget.user;
final String initials =
user != null && user!.firstName.isNotEmpty && user!.lastName.isNotEmpty
? '${user!.firstName[0]}${user!.lastName[0]}'.toUpperCase()
: 'SA';
user != null && user.firstName.isNotEmpty && user.lastName.isNotEmpty
? '${user.firstName[0]}${user.lastName[0]}'.toUpperCase()
: 'SA';
final String fullName = user != null
? '${user!.firstName} ${user!.lastName}'
? '${user.firstName} ${user.lastName}'
: 'Super Admin';
final String role = user?.role.toUpperCase() ?? 'ADMIN';
// Data fallbacks
final String email = user?.email ?? 'admin@tis-rms.edu.ph';
final String phone = user?.phone ?? '+63 900 000 0000';
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () =>
_showMenu(context, ref, initials, fullName, role, email, phone),
onTap: () => _showMenu(context, initials, fullName, role, email, phone),
child: CircleAvatar(
backgroundColor: AppColors.primaryGreen,
child: Text(
+2 -2
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.1+2
environment:
sdk: ^3.11.5
@@ -158,6 +158,6 @@ msix_config:
display_name: TIS RMS
publisher_display_name: BSIT3DSB
identity_name: plsp.bsit3dsb.tisrms
msix_version: 1.0.0.1
msix_version: 1.0.1.2
logo_path: "assets/images/logo.png"
capabilities: internetClient
-457
View File
@@ -1,457 +0,0 @@
# TIS RMS Design Improvement Plan
## Audit Summary
**Platform**: Flutter (Windows Desktop + Android)
**Screens Analyzed**: 10 screen modules, 34 dart files, 3 layouts, 18 shared components
**Design System**: AppColors (46 lines), AppSizes (20 lines), AppTheme (308 lines)
**Scope**: Both Android and Windows platforms
---
## Priority Levels
- **P0 - Critical**: Broken functionality, visual bugs, or accessibility failures
- **P1 - High**: Inconsistency, maintainability blockers, or significant UX friction
- **P2 - Medium**: Polish, optimization, or minor inconsistencies
- **P3 - Low**: Nice-to-have improvements, future-proofing
---
## CATEGORY 1: Design System & Theming Issues
### 1.1 [P1] Hardcoded Colors Throughout Screens
**Problem**: ~68+ instances of `withOpacity()` (deprecated) instead of `withValues(alpha:)`, and inline hex colors bypassing `AppColors`.
**Files Affected**:
- `documents_screen.dart` — inline `Colors.grey.shade200`, `Colors.black54`, etc.
- `students_screen.dart` — hardcoded greys for borders and backgrounds
- `archives_screen.dart` — duplicate color definitions
- `reports_screen.dart` — hardcoded table header colors
- `capstone_members_screen.dart` — **zero dark mode support**, all light theme colors hardcoded
- `app_theme.dart:29,43,180,207` — uses `withOpacity()` (deprecated API)
**Recommendation**:
- Migrate all 68 `withOpacity()` calls to `withValues(alpha:)`
- Add missing tokens to `AppColors`: `borderLight`, `borderDark`, `hoverLight`, `hoverDark`, `overlayLight`, `overlayDark`
- Fix `capstone_members_screen.dart` to use theme-aware colors
### 1.2 [P1] Incomplete Dark Mode on Multiple Screens
**Problem**: Dark mode checks are manual (`Theme.of(context).brightness == Brightness.dark`) scattered in every screen rather than using `colorScheme` tokens. Several screens have incomplete coverage.
**Files Affected**:
- `capstone_members_screen.dart` — no dark mode at all
- `reports_screen.dart` — partial dark mode (table headers and data rows miss dark variants)
- `splash_screen.dart` — manual server entry dialog lacks dark mode styling
- Document/student widget files — inconsistent dark mode in filter panels
**Recommendation**:
- Use `Theme.of(context).colorScheme.surface` / `.onSurface` / `.surfaceContainerHighest` instead of manual brightness checks where possible
- Audit and fix every screen for complete dark mode coverage
- Create a `context.isDark` extension to reduce boilerplate
### 1.3 [P2] AppSizes Token Gaps
**Problem**: `AppSizes` only defines 8 padding values and 4 radii. Missing: icon sizes, elevation values, breakpoints, font sizes, line heights, animation durations.
**Files Affected**: All screens use raw `double` literals for these.
**Recommendation**: Extend `AppSizes` with:
```dart
// Icon Sizes
static const double iconSmall = 16.0;
static const double iconMedium = 24.0;
static const double iconLarge = 32.0;
// Elevations
static const double elevationNone = 0.0;
static const double elevationLow = 2.0;
static const double elevationMedium = 4.0;
// Breakpoints
static const double breakpointMobile = 480.0;
static const double breakpointTablet = 800.0;
static const double breakpointDesktop = 1200.0;
// Animation Durations
static const Duration animFast = Duration(milliseconds: 200);
static const Duration animMedium = Duration(milliseconds: 300);
```
### 1.4 [P2] No Typography Scale
**Problem**: Font sizes are hardcoded throughout: `fontSize: 12`, `fontSize: 14`, `fontSize: 16`, `fontSize: 20`, etc. No central typography scale.
**Files Affected**: Every screen file.
**Recommendation**: Use Material 3 `TextTheme` consistently (`Theme.of(context).textTheme.bodyMedium`, `.titleLarge`, etc.) instead of `GoogleFonts.inter(fontSize: X)` inline.
---
## CATEGORY 2: Architecture & Code Organization
### 2.1 [P0] God-Class Screen Files
**Problem**: The 7 largest screen files are monolithic classes with 1,900-3,600+ lines each. Each `_ScreenState` contains 15-25+ private `_build*` methods, making them extremely hard to maintain, test, and review.
**Files Affected**:
| File | Lines | Private Methods |
|------|-------|----------------|
| `reports_screen.dart` | 3,611 | ~25 |
| `documents_screen.dart` | 3,251 | ~20 |
| `students_screen.dart` | 2,902 | ~18 |
| `archives_screen.dart` | 2,449 | ~15 |
| `requirements_settings_screen.dart` | 2,233 | ~15 |
| `users_screen.dart` | 2,121 | ~12 |
| `settings_screen.dart` | 1,920 | ~10 |
**Recommendation**: Extract each `_build*` method into a standalone widget in a `widgets/` subdirectory. Target: no screen file exceeds 500 lines. Example for `documents_screen.dart`:
- `DocumentsHeader` widget
- `DocumentsFilterPanel` widget
- `DocumentsGridView` widget
- `DocumentsListView` widget
- `DocumentsMobileList` widget
- `BulkOperationsBar` widget
### 2.2 [P1] Duplicated Widget Patterns (500+ Lines of Duplication)
**Problem**: Identical patterns are copy-pasted across 3-5 screens.
| Duplicated Pattern | Files | Est. Duplicate Lines |
|-------------------|-------|---------------------|
| Error state widget (wifi_off icon + retry) | documents, students, users, archives, settings | ~120 lines |
| Empty state widget (icon + text) | documents, students, archives, users | ~80 lines |
| Search dialog (`showDialog` with `TextField`) | documents, students, archives, users | ~200 lines |
| Filter dropdown section | documents, students, archives | ~150 lines |
| Pagination controls | documents, students, archives, users | Uses `AppPagination` (good) |
**Recommendation**: Extract shared widgets:
- `AppErrorState({String message, VoidCallback onRetry})`
- `AppEmptyState({IconData icon, String title, String? subtitle})`
- `showAppSearchDialog(context, {onSearch, searchHistory})`
- `FilterDropdownSection({title, items, selectedValue, onChanged})`
### 2.3 [P1] Incomplete SnackBar-to-Dialog Migration
**Problem**: The codebase is mid-migration from `SnackBar` to custom `showSuccessDialog`/`showErrorDialog`. ~17 `SnackBar` instances remain, creating inconsistent feedback patterns.
**Files with remaining SnackBars**:
- `students_screen.dart` — `_BulkEnrollModal` uses SnackBar for errors
- `documents/widgets/requirements_settings_modal.dart` — SnackBar for both success/error
- `archives_screen.dart` — some operations still use SnackBar
- `login_screen.dart` — mixed feedback patterns
**Recommendation**: Complete the migration. Replace all remaining `SnackBar` usages with `showSuccessDialog` / `showErrorDialog`.
---
## CATEGORY 3: Android-Specific Design Issues
### 3.1 [P0] Portrait Lock on All Orientations
**Problem**: `main.dart:48-51` locks orientation to `portraitUp`/`portraitDown` globally, including on tablets where landscape is expected.
**Recommendation**: Only lock portrait on phones; allow landscape on tablets (check `shortestSide >= 600`).
### 3.2 [P1] Touch Target Sizes
**Problem**: Several interactive elements have touch targets below the Material 3 minimum of 48x48dp.
- Sidebar collapse/expand icon buttons
- Pagination chevron buttons (`AppPagination`) — no minimum size constraint
- Filter chips in compact mobile layouts
- Action icon buttons in table rows
**Recommendation**: Wrap all interactive icon elements with `ConstrainedBox(constraints: BoxConstraints(minWidth: 48, minHeight: 48))` or use `IconButton` with proper padding.
### 3.3 [P1] Android Bottom Navigation Inconsistencies
**Problem**: `android_bottom_nav_layout.dart` uses a `NavigationBar` with 5 items (Dashboard, Documents, Students, Archives, Reports) but the hamburger drawer adds Settings, Users, About — creating a split navigation model that's not intuitive.
**Recommendation**:
- Consider a unified drawer-based navigation for Android with grouped sections
- Or reduce bottom nav to 4 items (Material 3 recommends 3-5) and move Reports to the drawer
### 3.4 [P2] No Back Button Handling
**Problem**: Per the AGENTS.md TODO, back button prevention for multi-select mode and folder navigation is not yet implemented on Android.
**Recommendation**: Implement `PopScope` (replaces deprecated `WillPopScope`) on Documents, Students, and Archives screens to handle:
- 1st back: deactivate multi-select
- 2nd back: navigate to parent folder or dashboard
### 3.5 [P2] No Pull-to-Refresh on Android
**Problem**: Mobile screens use polling timers (5s-15s intervals) but lack pull-to-refresh gesture, which is the expected interaction on mobile.
**Recommendation**: Wrap list views in `RefreshIndicator` for manual refresh on Android.
---
## CATEGORY 4: Windows Desktop-Specific Design Issues
### 4.1 [P1] Sidebar Visual Polish
**Problem**: The Windows sidebar lacks hover state animations on nav items when collapsed. The collapsed sidebar shows only icons with tooltips, but tooltip positioning can overlap with the sidebar edge.
**Recommendation**:
- Add `InkWell` hover feedback with subtle background color change
- Adjust tooltip `preferBelow` and `verticalOffset` for collapsed state
### 4.2 [P1] No Keyboard Navigation / Shortcuts
**Problem**: No keyboard shortcuts exist for common desktop operations:
- No `Ctrl+F` for search
- No `Ctrl+N` for new student/document
- No `Tab` / `Shift+Tab` focus traversal optimization
- No `Escape` to close modals (some modals handle this, others don't)
**Recommendation**: Add `Shortcuts` + `Actions` widgets for common operations. Ensure all modals respond to `Escape`.
### 4.3 [P2] Window Title Bar
**Problem**: Custom `WindowCaption` replaces the native title bar (`TitleBarStyle.hidden`), but the splash screen implements its own caption separately from the layout shells.
**Recommendation**: Unify window caption handling — single `WindowCaption` widget used by all routes.
### 4.4 [P2] Right-Click Context Menus
**Problem**: Desktop users expect right-click context menus on items (documents, students, files). Currently only `PopupMenuButton` icon is used.
**Recommendation**: Add `GestureDetector.onSecondaryTapUp` or use `ContextMenuRegion` for right-click context menus on desktop.
---
## CATEGORY 5: Responsive Design Issues
### 5.1 [P1] Inconsistent Breakpoints
**Problem**: Breakpoint values are scattered and inconsistent across screens.
| Screen | Breakpoints Used |
|--------|-----------------|
| `responsive_layout.dart` | 800 |
| `documents_screen.dart` | 400, 600, 800, 900, 1200 |
| `students_screen.dart` | 480, 600, 700, 800 |
| `reports_screen.dart` | 600, 800, 1200 |
| `dashboard_screen.dart` | 600, 800 |
| `splash_screen.dart` | 800 |
**Recommendation**: Define `AppBreakpoints` constant class:
```dart
class AppBreakpoints {
static const double compact = 480; // Phone
static const double medium = 800; // Tablet / small desktop
static const double expanded = 1200; // Desktop
}
```
Use consistently across all screens.
### 5.2 [P2] Grid Column Count Logic Duplication
**Problem**: Multiple screens compute grid columns with `LayoutBuilder` + `MediaQuery` width using slightly different formulas (some divide by 200, others by 250, etc.).
**Recommendation**: Create a `responsiveGridColumns(double width)` utility function.
---
## CATEGORY 6: UX & Interaction Design
### 6.1 [P1] No Loading Skeletons / Shimmer
**Problem**: All loading states use `CircularProgressIndicator` centered on screen. This causes layout shift when data loads and provides no visual context of what's loading.
**Recommendation**: Implement shimmer/skeleton loading for:
- Dashboard stat cards
- Student list/grid
- Document list/grid
- Archives list
### 6.2 [P1] Modal Overuse on Desktop
**Problem**: Operations that could use inline editing (rename, status change) use full-screen modals, which is jarring on a desktop with ample screen space.
**Recommendation**:
- Use inline editing / side panels for quick operations on desktop
- Reserve modals for complex multi-step operations (add student, upload document)
### 6.3 [P1] No Undo / Confirmation Consistency
**Problem**: Destructive operations have inconsistent confirmation patterns:
- Delete document: requires confirmation dialog
- Bulk delete: requires confirmation dialog
- Archive: requires confirmation dialog
- Remove from print queue: no confirmation
- Clear search history: no confirmation
**Recommendation**: Standardize: all destructive operations get confirmation. For reversible operations, use "undo" snackbar instead.
### 6.4 [P2] Empty States Lack Call-to-Action
**Problem**: Empty states show an icon and text like "No students found" but don't guide the user to add one.
**Recommendation**: Add primary action button in empty states: "Add your first student", "Upload a document", etc.
### 6.5 [P2] No Onboarding / First-Run Experience
**Problem**: New users see a setup banner but no guided walkthrough. The system requires: create academic year → assign sections → add teachers → add students → upload documents.
**Recommendation**: Add a setup progress checklist on the dashboard for first-time setup.
### 6.6 [P3] Polling Timer UX
**Problem**: Dashboard polls every 5s, Documents every 15s. No visual indicator of sync status or last-updated time. Users can't tell if data is fresh.
**Recommendation**: Add a subtle "Last updated: X seconds ago" indicator or a refresh icon with timestamp.
---
## CATEGORY 7: Accessibility
### 7.1 [P0] No Semantic Labels
**Problem**: Almost no `Semantics` widgets used across the entire codebase. Screen readers cannot navigate the app.
**Files Affected**: All screens.
**Recommendation**: Add `Semantics` labels to:
- All icon buttons (`semanticLabel` parameter)
- All images and decorative elements
- Navigation items
- Stat cards (describe the metric value)
- Status badges
### 7.2 [P1] Color-Only Status Indicators
**Problem**: Document statuses use color only (green=complete, orange=pending, red=missing) without text or icon differentiation.
**Recommendation**: Always pair status colors with text labels and/or icons. Users with color vision deficiency cannot distinguish them otherwise.
### 7.3 [P2] Focus Indicator Visibility
**Problem**: No custom focus indicator styles. Default Material focus rings may not be visible on green-themed surfaces.
**Recommendation**: Ensure focus indicators have sufficient contrast on all surface colors.
---
## CATEGORY 8: Performance-Related Design
### 8.1 [P1] IndexedStack Loads All Visited Screens in Memory
**Problem**: Both `WindowsSidebarLayout` and `AndroidBottomNavLayout` use `IndexedStack` with a `_visitedIndices` set. Once a screen is visited, it stays in the widget tree permanently.
**Recommendation**: For screens with heavy data (Documents, Students, Reports), consider using `AutomaticKeepAliveClientMixin` with `PageView` or lazy `TabBarView` instead, allowing Flutter to dispose off-screen widgets.
### 8.2 [P2] Large Lists Without Virtualization
**Problem**: Some list views build all items at once instead of using `ListView.builder` with `itemCount`.
**Recommendation**: Audit all `Column(children: items.map(...))` patterns and replace with `ListView.builder` for lists > 20 items.
---
## CATEGORY 9: Visual Polish
### 9.1 [P2] Inconsistent Card Elevation & Shadows
**Problem**: Cards across screens use different elevation values (0, 1, 2, 4) and some use `Container` with `BoxShadow` while others use `Card` widget.
**Recommendation**: Standardize on `Card` widget with theme-defined elevation. Remove all `Container`+`BoxShadow` patterns.
### 9.2 [P2] No Micro-Animations
**Problem**: State transitions are instant (tab switches, filter applications, data loading). No subtle animations.
**Recommendation**: Add:
- `AnimatedSwitcher` for content transitions
- `AnimatedContainer` for filter panel expand/collapse
- Staggered item animations for grid/list appearance
### 9.3 [P3] Inconsistent Icon Styles
**Problem**: Mix of Material Icons (filled), Material Icons Outlined, and a few custom icons. No consistent icon style.
**Recommendation**: Standardize on outlined icons (Material Symbols Outlined) for consistency.
---
## Actionable Implementation Task Checklist
### Phase 1: Foundation & Design System Setup
- [X] **Task 1.1**: Extend `AppColors` (`lib/core/constants/app_colors.dart`)
- Add `borderLight`, `borderDark`, `hoverLight`, `hoverDark`, `overlayLight`, `overlayDark`.
- Fix dark mode color palette references.
- [X] **Task 1.2**: Create `AppBreakpoints` (`lib/core/constants/app_breakpoints.dart`)
- Define `compact = 480`, `medium = 800`, `expanded = 1200`.
- [X] **Task 1.3**: Extend `AppSizes` (`lib/core/constants/app_sizes.dart`)
- Add standard icon sizes (`iconSmall`, `iconMedium`, `iconLarge`), elevations, and animation durations.
- [X] **Task 1.4**: Add BuildContext Theme Extension (`lib/core/utils/theme_extension.dart`)
- Add `context.isDark`, `context.colors`, and `context.textTheme` helpers.
- [X] **Task 1.5**: Migrate Deprecated Color API
- Replace ~68 instances of `withOpacity()` with `withValues(alpha:)`.
- [X] **Task 1.6**: Fix `CapstoneMembersScreen` Dark Mode
- Remove hardcoded light theme colors in `capstone_members_screen.dart`.
### Phase 2: Shared Component Extraction
- [X] **Task 2.1**: Build `AppErrorState` Component (`lib/ui/shared/widgets/app_error_state.dart`)
- Extract duplicate error UI from 5 screens into a single configurable widget.
- [X] **Task 2.2**: Build `AppEmptyState` Component (`lib/ui/shared/widgets/app_empty_state.dart`)
- Extract duplicate empty state UI with title, subtitle, icon, and optional CTA button.
- [X] **Task 2.3**: Build `FilterDropdownSection` & `FilterChipGroup`
- Consolidate duplicated filter UI logic across `documents_screen.dart`, `students_screen.dart`, `archives_screen.dart`.
- [X] **Task 2.4**: Complete SnackBar to Dialog Migration
- Replace 17 remaining `SnackBar` calls with `showSuccessDialog` / `showErrorDialog`.
### Phase 3: Screen Refactoring & Decomposition
- [X] **Task 3.1**: Refactor `documents_screen.dart` (3,251 lines)
- Extract `DocumentsHeader`, `DocumentsFilterPanel`, `DocumentsGridView`, `DocumentsListView`, `DocumentsMobileList`, `BulkOperationsBar`.
- [X] **Task 3.2**: Refactor `reports_screen.dart` (3,611 lines)
- Extract table cards, header filters, export dialog triggers, and compliance widgets into `lib/ui/screens/reports/widgets/`.
- [X] **Task 3.3**: Refactor `students_screen.dart` (2,902 lines)
- Extract filter headers, table views, grid views, and mobile list items.
- [X] **Task 3.4**: Refactor `archives_screen.dart` (2,449 lines) and `settings` screens.
### Phase 4: Platform-Specific Optimization & Project TODO Alignment
- [x] **Task 4.1**: Android Optimizations & Back Prevention (`PopScope`)
- Replace global orientation lock in `main.dart` with phone-only portrait lock (allow tablet landscape).
- Implement `PopScope` back handling on Documents Screen, Students Screen, and Archives Screen:
- 1st back inside multi-select: deactivate multi-select. 2nd back: navigate to Dashboard.
- 1st back inside folder: return to 'Student Folders' tab. 2nd back: navigate to Dashboard.
- Stack action buttons vertically on Android (`Column` with full-width `ElevatedButton`).
- Add `RefreshIndicator` pull-to-refresh on mobile lists.
- [x] **Task 4.2**: Windows Desktop Optimizations & Drag-and-Drop Fixes
- Fix desktop drag-and-drop file upload in Add Student Modal and Edit Student Modal.
- Add keyboard shortcuts (`Shortcuts` + `Actions` for `Ctrl+F`, `Escape`, etc.).
- Add right-click context menus (`onSecondaryTapUp`).
- Improve sidebar hover states and tooltip placements.
- [x] **Task 4.3**: Screen-Specific Feature Fixes (per Project TODOs)
- **Documents Screen**: Remove timestamp from copied filename format; remove Excel-to-PDF feature.
- **Reports Screen**: Fix card overflow on overall compliance, students with issues, and total missing documents cards; add clickable path links in success dialogs.
- **Students Screen (OCR Validation)**: Reject non 7-12 grade levels; add manual validation step for year/grade/section using icon-only buttons (no container, background, or border).
- [x] **Task 4.4**: Accessibility & Visual Polish
- Add `Semantics` labels to interactive elements and icons.
- Replace color-only status indicators with text + icon badges.
- Add shimmer skeleton loading states for cards and tables (`AppSkeletonLoader`).
---
## Implementation Roadmap
### Phase 1: Foundation Fixes (1-2 weeks)
| # | Task | Priority | Impact |
|---|------|----------|--------|
| 1 | Extend `AppColors` with missing tokens | P1 | All screens |
| 2 | Create `AppBreakpoints` constants | P1 | All screens |
| 3 | Extend `AppSizes` with icon/elevation/animation tokens | P2 | All screens |
| 4 | Migrate 68 `withOpacity()` → `withValues(alpha:)` | P1 | All screens |
| 5 | Create `context.isDark` extension | P1 | All screens |
| 6 | Fix `capstone_members_screen.dart` dark mode | P0 | 1 screen |
### Phase 2: Shared Widget Extraction (2-3 weeks)
| # | Task | Priority | Impact |
|---|------|----------|--------|
| 7 | Extract `AppErrorState` shared widget | P1 | 5 screens |
| 8 | Extract `AppEmptyState` shared widget | P1 | 4 screens |
| 9 | Extract `showAppSearchDialog()` utility | P1 | 4 screens |
| 10 | Extract `FilterDropdownSection` widget | P1 | 3 screens |
| 11 | Complete SnackBar → Dialog migration (17 instances) | P1 | 5 screens |
### Phase 3: Screen Decomposition (3-4 weeks)
| # | Task | Priority | Impact |
|---|------|----------|--------|
| 12 | Decompose `documents_screen.dart` (3,251 lines) | P0 | Maintainability |
| 13 | Decompose `reports_screen.dart` (3,611 lines) | P0 | Maintainability |
| 14 | Decompose `students_screen.dart` (2,902 lines) | P0 | Maintainability |
| 15 | Decompose `archives_screen.dart` (2,449 lines) | P1 | Maintainability |
| 16 | Decompose `settings` screens (2,233 + 1,920 lines) | P1 | Maintainability |
### Phase 4: Platform-Specific UX (2-3 weeks)
| # | Task | Priority | Impact |
|---|------|----------|--------|
| 17 | Android: Implement `PopScope` back handling | P2 | Android UX |
| 18 | Android: Add `RefreshIndicator` pull-to-refresh | P2 | Android UX |
| 19 | Android: Unlock landscape on tablets | P0 | Tablet UX |
| 20 | Windows: Add keyboard shortcuts (`Ctrl+F`, `Escape`) | P1 | Desktop UX |
| 21 | Windows: Add right-click context menus | P2 | Desktop UX |
| 22 | Windows: Improve sidebar hover states | P1 | Desktop polish |
### Phase 5: UX & Polish (2-3 weeks)
| # | Task | Priority | Impact |
|---|------|----------|--------|
| 23 | Add shimmer/skeleton loading states | P1 | All screens |
| 24 | Add semantic labels for accessibility | P0 | Accessibility |
| 25 | Fix color-only status indicators | P1 | Accessibility |
| 26 | Add empty state CTAs | P2 | UX |
| 27 | Standardize card elevation/shadows | P2 | Visual consistency |
| 28 | Add micro-animations (`AnimatedSwitcher`) | P2 | Polish |
| 29 | Use `TextTheme` instead of inline font sizes | P2 | Consistency |
| 30 | Standardize icon style (outlined) | P3 | Visual consistency |
---
## Quick Wins (Can Be Done Immediately)
1. Fix `capstone_members_screen.dart` dark mode (~30 min)
2. Create `context.isDark` extension (~10 min)
3. Add `AppBreakpoints` constant class (~15 min)
4. Extend `AppSizes` with missing tokens (~20 min)
5. Complete SnackBar migration (17 instances, ~1 hour)
6. Add `Semantics` labels to all `IconButton` widgets (~2 hours)
---
## Summary Metrics
| Category | Issues Found | P0 | P1 | P2 | P3 |
|----------|-------------|----|----|----|----|
| Design System & Theming | 4 | 0 | 2 | 2 | 0 |
| Architecture & Code | 3 | 1 | 2 | 0 | 0 |
| Android-Specific | 5 | 1 | 1 | 2 | 1 |
| Windows Desktop | 4 | 0 | 1 | 2 | 1 |
| Responsive Design | 2 | 0 | 1 | 1 | 0 |
| UX & Interaction | 6 | 0 | 2 | 2 | 2 |
| Accessibility | 3 | 1 | 1 | 1 | 0 |
| Performance | 2 | 0 | 1 | 1 | 0 |
| Visual Polish | 3 | 0 | 0 | 2 | 1 |
| **Total** | **32** | **3** | **11** | **13** | **5** |