From 71526fe61ee45224d9db9746362942016abc92a4 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Fri, 25 Sep 2026 19:04:23 +0800 Subject: [PATCH] fix(reports): resolve Windows layout overflows and refine UI - Fix 1px bottom overflow on Overall Compliance KPI card by adjusting height and padding - Fix text truncation for Pending Docs KPI card - Fix 49px bottom overflow on Students Per Year card by adjusting container and chart heights - Fix LayoutBuilder inside IntrinsicHeight issue on desktop report charts - Refine security settings, audit trail platform tracking, and document handling - Clean up unused conversion providers and LibreOffice service --- backend/src/controllers/authController.js | 67 +- backend/src/controllers/documentController.js | 82 - backend/src/controllers/userController.js | 39 +- backend/src/routes/documents.js | 1 - backend/src/services/libreOfficeService.js | 263 ---- frontend/AGENTS.md | 57 +- .../com/teamsb/tis_rms/AlarmReceiver.kt | 34 +- .../kotlin/com/teamsb/tis_rms/MainActivity.kt | 59 +- .../core/services/notification_service.dart | 97 +- frontend/lib/core/services/sound_service.dart | 8 +- frontend/lib/core/utils/date_utils.dart | 12 + frontend/lib/core/utils/download_service.dart | 26 + .../repositories/document_repository.dart | 14 - frontend/lib/main.dart | 12 + .../lib/ui/providers/conversion_provider.dart | 47 - .../ui/providers/preferences_provider.dart | 2 +- .../ui/screens/archives/archives_screen.dart | 20 +- .../audit_trail/audit_trail_screen.dart | 1351 +++++++++++------ .../dashboard/widgets/dashboard_kpis.dart | 84 +- .../screens/documents/documents_screen.dart | 20 +- .../widgets/document_preview_modal.dart | 54 +- .../document_version_history_sheet.dart | 5 +- .../documents/widgets/file_folder_card.dart | 15 - .../documents/widgets/print_queue_modal.dart | 43 +- .../widgets/student_archives_modal.dart | 6 +- .../ui/screens/reports/reports_screen.dart | 446 ++++-- .../ui/screens/settings/security_screen.dart | 123 ++ .../ui/screens/settings/settings_screen.dart | 367 ++--- .../settings/widgets/security_section.dart | 412 ++--- .../ui/shared/modals/view_activity_modal.dart | 2 +- 30 files changed, 2163 insertions(+), 1605 deletions(-) delete mode 100644 backend/src/services/libreOfficeService.js delete mode 100644 frontend/lib/ui/providers/conversion_provider.dart create mode 100644 frontend/lib/ui/screens/settings/security_screen.dart diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index e00260a..ba65b31 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -7,31 +7,58 @@ const { createNotification } = require('./notificationController'); const { sendPasswordResetOtp, sendAccountDeletionEmail } = require('../services/emailService'); const { getBestServerBaseUrl } = require('./userController'); -// ── QEV Email Validator ─────────────────────────────────────────────────────── -// Returns { valid: bool, reason: string } — never throws. -const validateEmailQEV = async (email) => { +// ── MyEmailVerifier (MEV) Email Validator ───────────────────────────────────── +// Returns { valid: bool, reason: string, details?: object } — never throws. +const validateEmailMEV = async (email) => { if (!email || !email.trim()) return { valid: false, reason: 'Email is empty' }; - const apiKey = process.env.QEV_API_KEY; - if (!apiKey) return { valid: true, reason: 'QEV key not configured – skipping' }; + const apiKey = process.env.MEV_API_KEY; + if (!apiKey) { + console.warn('[MEV] MEV_API_KEY not configured in .env – skipping verification'); + return { valid: true, reason: 'MEV key not configured – skipping' }; + } + try { - const qev = require('quickemailverification').client(apiKey).quickemailverification(); - const result = await new Promise((resolve, reject) => { - qev.verify(email.trim(), (err, response) => { - if (err) return reject(err); - resolve(response.body); - }); - }); - // result.result: 'valid' | 'invalid' | 'unknown' - const isValid = result.result === 'valid'; - const reason = isValid ? 'valid' : (result.reason || result.result || 'invalid'); - return { valid: isValid, reason }; + const url = `https://api.myemailverifier.com/api/validate_single.php?apikey=${encodeURIComponent(apiKey)}&email=${encodeURIComponent(email.trim())}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + console.warn(`[MEV] HTTP ${response.status} from MyEmailVerifier`); + return { valid: true, reason: 'Validation service temporarily unavailable' }; + } + + const data = await response.json(); + const status = (data.Status || '').trim().toLowerCase(); + const diagnosis = data.Diagnosis || ''; + const isDisposable = data.Disposable_Domain === 1 || + data.Disposable_Domain === '1' || + data.Disposable_Domain === 'true' || + data.Disposable_Domain === true; + + if (isDisposable) { + return { valid: false, reason: 'Disposable email addresses are not allowed', details: data }; + } + + if (status === 'invalid') { + return { valid: false, reason: diagnosis || 'Email address does not exist or is invalid', details: data }; + } + + const isValid = status === 'valid'; + return { + valid: isValid, + reason: isValid ? 'valid' : (diagnosis || 'Email verification failed'), + details: data, + }; } catch (err) { - console.warn('[QEV] Email validation error (skipping):', err.message); - // Fail-open: if QEV is unreachable, allow the email - return { valid: true, reason: 'QEV unreachable – skipping' }; + console.warn('[MEV] Email validation error (skipping):', err.message); + return { valid: true, reason: 'MEV unreachable – skipping' }; } }; -exports.validateEmailQEV = validateEmailQEV; +exports.validateEmailMEV = validateEmailMEV; +exports.validateEmailQEV = validateEmailMEV; // Backward compatibility // ── Session Helpers ─────────────────────────────────────────────────────────── const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex'); diff --git a/backend/src/controllers/documentController.js b/backend/src/controllers/documentController.js index 87b7204..3e4b519 100644 --- a/backend/src/controllers/documentController.js +++ b/backend/src/controllers/documentController.js @@ -6,7 +6,6 @@ const { execFile } = require('child_process'); const util = require('util'); const execFileAsync = util.promisify(execFile); const { createNotification } = require('./notificationController'); -const LibreOfficeService = require('../services/libreOfficeService'); const sharp = require('sharp'); const emailService = require('../services/emailService'); @@ -901,87 +900,6 @@ exports.copyDocument = (req, res) => { } }; -// ============================================================ -// POST /api/documents/:id/convert-to-pdf — convert Excel to PDF -// ============================================================ -exports.convertToPdf = async (req, res) => { - const { id } = req.params; - try { - const doc = db.prepare('SELECT * FROM documents WHERE id = ?').get(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 convert this document.' }); - } - } - - const originalPath = doc.file_path; - if (!fs.existsSync(originalPath)) { - return res.status(404).json({ message: 'Physical file not found on disk' }); - } - - const ext = path.extname(doc.file_name).toLowerCase(); - const validExcelExts = ['.xlsx', '.xls', '.csv']; - if (!validExcelExts.includes(ext)) { - return res.status(400).json({ message: 'Only Excel files (.xlsx, .xls, .csv) can be converted to PDF.' }); - } - - const dir = path.dirname(originalPath); - const baseName = path.basename(doc.file_name, ext); - - // Perform conversion with headless LibreOffice - const { pdfPath: tempPdfPath } = await LibreOfficeService.convertToPdf(originalPath, dir); - - // Determine non-colliding new filename with .pdf extension - const existingNames = db.prepare( - 'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL' - ).all(doc.student_id).map((r) => r.file_name); - - let newFileName = `${baseName}.pdf`; - let counter = 1; - while (existingNames.includes(newFileName)) { - newFileName = `${baseName} (${counter}).pdf`; - counter++; - } - - const finalPdfPath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`); - fs.renameSync(tempPdfPath, finalPdfPath); - - // Insert new PDF document row into database - const convertedFileSize = fs.existsSync(finalPdfPath) ? fs.statSync(finalPdfPath).size : null; - const result = db.prepare(` - INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by, file_size) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(doc.student_id, doc.requirement_id, newFileName, finalPdfPath, doc.document_type, doc.status, req.user.id, convertedFileSize); - - const newDoc = db.prepare('SELECT * FROM documents WHERE id = ?').get(result.lastInsertRowid); - - // Log activity - const student = db.prepare('SELECT first_name, last_name FROM students WHERE id = ?').get(doc.student_id); - const studentName = student ? `${student.first_name} ${student.last_name}` : `Student #${doc.student_id}`; - logActivity(req.user.id, 'CREATE', 'document', result.lastInsertRowid, - `Converted Excel document "${doc.file_name}" to PDF "${newFileName}" for ${studentName}`); - createNotification(null, 'Document Converted', `Document "${doc.file_name}" converted to PDF for ${studentName}.`, 'document', 'document', result.lastInsertRowid); - - res.status(201).json({ - id: result.lastInsertRowid, - fileName: newFileName, - document: newDoc, - message: 'Document converted to PDF successfully', - }); - } catch (error) { - console.error('ConvertToPdf Error:', error); - res.status(500).json({ message: error.message || 'Failed to convert document to PDF' }); - } -}; - // ============================================================ // POST /api/documents/bulk-delete — bulk delete documents // ============================================================ diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index a488f65..6cd2a31 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -10,8 +10,9 @@ const { sendAccountStatusEmail, } = require('../services/emailService'); const { getDetectedTunnelUrl } = require('../services/tunnelService'); -// validateEmailQEV is lazy-required to avoid circular dependency (authController → userController → authController) -const getQEV = () => require('./authController').validateEmailQEV; +// validateEmailMEV is lazy-required to avoid circular dependency (authController → userController → authController) +const getMEV = () => require('./authController').validateEmailMEV; +const getQEV = getMEV; // ── Base URL Helper (Auto-detects Tunnel vs LAN IP vs Domain) ──────────────── @@ -123,7 +124,7 @@ exports.getUsers = (req, res) => { }; // POST /api/users - Create a new user -exports.createUser = (req, res) => { +exports.createUser = async (req, res) => { const { username, firstName, middleName, lastName, extension, role, email, phone } = req.body; // Password is optional — auto-generated if not provided let providedPassword = req.body.password; @@ -137,6 +138,21 @@ exports.createUser = (req, res) => { return res.status(400).json({ message: 'Invalid role. Must be admin or teacher.' }); } + // Validate email with MyEmailVerifier before creating user + if (email && email.trim()) { + try { + const mevResult = await getMEV()(email.trim()); + if (!mevResult.valid) { + return res.status(400).json({ + message: 'Invalid or undeliverable email address.', + reason: mevResult.reason, + }); + } + } catch (mevErr) { + console.warn('[UserController] MEV pre-check skipped due to error:', mevErr.message); + } + } + // Temporary password = username + 123 (e.g. ccharles123) const temporaryPassword = `${username}123`; @@ -195,7 +211,7 @@ exports.createUser = (req, res) => { // PUT /api/users/:id - Update a user -exports.updateUser = (req, res) => { +exports.updateUser = async (req, res) => { const { id } = req.params; const { firstName, middleName, lastName, extension, role, email, phone } = req.body; @@ -211,6 +227,21 @@ exports.updateUser = (req, res) => { return res.status(404).json({ message: 'User not found.' }); } + // Validate email with MyEmailVerifier if changed and not empty + if (email !== undefined && email !== null && email.trim() !== '' && email.trim().toLowerCase() !== (user.email || '').trim().toLowerCase()) { + try { + const mevResult = await getMEV()(email.trim()); + if (!mevResult.valid) { + return res.status(400).json({ + message: 'Invalid or undeliverable email address.', + reason: mevResult.reason, + }); + } + } catch (mevErr) { + console.warn('[UserController] MEV pre-check skipped due to error:', mevErr.message); + } + } + // Prevent self-demotion (locked role for self) const effectiveRole = user.id === req.user.id ? user.role : (role || user.role); diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js index 206585a..bb12ef9 100644 --- a/backend/src/routes/documents.js +++ b/backend/src/routes/documents.js @@ -36,7 +36,6 @@ router.get('/:id/versions', authenticateToken, documentController.getDocumentVer router.post('/:id/upload-version', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.uploadMiddleware, documentController.uploadDocumentVersion); router.get('/:id/download', authenticateToken, documentController.downloadDocumentVersion); 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); router.post('/:id/restore', authenticateToken, authorizeRoles('admin', 'teacher'), documentController.restoreDocument); diff --git a/backend/src/services/libreOfficeService.js b/backend/src/services/libreOfficeService.js deleted file mode 100644 index 7a79a48..0000000 --- a/backend/src/services/libreOfficeService.js +++ /dev/null @@ -1,263 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { exec, execSync } = require('child_process'); -const util = require('util'); -const ExcelJS = require('exceljs'); -const execAsync = util.promisify(exec); - -/** - * Service for converting documents (Excel, etc.) to PDF using Headless LibreOffice. - * - * Features: - * 1. Headless LibreOffice CLI: Completely isolated, sandboxed execution with zero GUI - * popups, no COM object dependencies, and no risk of desktop/IDE interference. - * 2. Automatic Long Bond Paper (Folio 8.5" x 13") formatting via ExcelJS preprocessing. - * 3. Removes static/shrunk zoom percentages so tables fit 100% of printable page width. - */ -class LibreOfficeService { - /** - * Finds the path to the LibreOffice / soffice executable. - * @returns {string|null} - */ - static getExecutablePath() { - // 1. Explicit environment variable - if (process.env.LIBREOFFICE_PATH && fs.existsSync(process.env.LIBREOFFICE_PATH)) { - return process.env.LIBREOFFICE_PATH; - } - - const isWindows = process.platform === 'win32'; - - if (isWindows) { - const candidatePaths = [ - path.join(__dirname, '..', '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.com'), - path.join(__dirname, '..', '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.exe'), - path.join(__dirname, '..', '..', 'bin', 'LibreOffice', 'program', 'soffice.com'), - path.join(__dirname, '..', '..', 'bin', 'LibreOffice', 'program', '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', - ]; - - for (const candidate of candidatePaths) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - - try { - 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; - } - } catch (_) {} - } else { - const candidatePaths = [ - '/usr/bin/soffice', - '/usr/bin/libreoffice', - '/usr/local/bin/soffice', - '/Applications/LibreOffice.app/Contents/MacOS/soffice', - ]; - - for (const candidate of candidatePaths) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - - try { - const output = execSync('which soffice || which libreoffice', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); - const firstLine = output.trim().split(/\r?\n/)[0]; - if (firstLine && fs.existsSync(firstLine)) { - return firstLine; - } - } catch (_) {} - } - - return null; - } - - /** - * Checks if LibreOffice is available on the system. - * @returns {boolean} - */ - static isLibreOfficeAvailable() { - return this.getExecutablePath() !== null; - } - - /** - * Checks if the conversion engine is available. - * @returns {boolean} - */ - static isAvailable() { - return this.isLibreOfficeAvailable(); - } - - /** - * Preprocesses an Excel file using ExcelJS to configure all visible worksheets: - * 1. Paper size = 14 (Folio 8.5" x 13" / Long Bond Paper standard). - * 2. Removes any static/shrunken zoom scale (e.g. scale: 36%) so fitToPage takes effect. - * 3. Sets fitToWidth = 1 so content fills the entire page width. - * 4. Centers content horizontally with compact 0.25" margins. - * - * @param {string} inputFilePath - Source Excel path - * @param {string} outputDir - Directory to store temporary prepared Excel file - * @returns {Promise} - Path to prepared Excel file (or original if not applicable) - */ - static async prepareWorkbookForLongBond(inputFilePath, outputDir) { - const ext = path.extname(inputFilePath).toLowerCase(); - if (ext !== '.xlsx') { - return inputFilePath; - } - - try { - const wb = new ExcelJS.Workbook(); - await wb.xlsx.readFile(inputFilePath); - let modified = false; - - wb.eachSheet((ws) => { - if (ws.state === 'hidden' || ws.state === 'veryHidden') return; - if (!ws.pageSetup) ws.pageSetup = {}; - - // OpenXML standard Folio: 8.5 x 13 in (Philippine Long Bond Paper) - ws.pageSetup.paperSize = 14; - - // Remove hardcoded/shrunken zoom scale so fitToPage takes effect - delete ws.pageSetup.scale; - - ws.pageSetup.fitToPage = true; - ws.pageSetup.fitToWidth = 1; - if (!ws.pageSetup.fitToHeight) { - ws.pageSetup.fitToHeight = 1; - } - - // Center horizontally - ws.pageSetup.horizontalCentered = true; - - modified = true; - }); - - if (modified) { - const prepFileName = `__prep_${Date.now()}_${path.basename(inputFilePath)}`; - const prepFilePath = path.join(outputDir, prepFileName); - await wb.xlsx.writeFile(prepFilePath); - return prepFilePath; - } - } catch (err) { - console.warn('[DocumentConverter] Workbook preprocessing skipped:', err.message); - } - - return inputFilePath; - } - - /** - * Converts an Excel file to PDF using headless LibreOffice. - * - * @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} - */ - static async convertWithLibreOffice(inputFilePath, outputDir, expectedPdfPath) { - const sofficeBin = this.getExecutablePath(); - if (!sofficeBin) { - throw new Error('LibreOffice (soffice) was not found on the server.'); - } - - 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()}`); - - // LibreOffice names output PDF after input file basename - const generatedPdfName = `${path.basename(inputFilePath, path.extname(inputFilePath))}.pdf`; - const generatedPdfPath = path.join(outputDir, generatedPdfName); - - if (generatedPdfPath !== expectedPdfPath && fs.existsSync(generatedPdfPath)) { - if (fs.existsSync(expectedPdfPath)) { - try { fs.unlinkSync(expectedPdfPath); } catch (_) {} - } - fs.renameSync(generatedPdfPath, expectedPdfPath); - } - - 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 using Headless 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)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - if (!this.isLibreOfficeAvailable()) { - const isWindows = process.platform === 'win32'; - const helpMessage = isWindows - ? 'LibreOffice was not found. Please ensure LibreOffice is located in backend/bin/LibreOffice or install it from https://www.libreoffice.org.' - : 'LibreOffice was not found. Please install LibreOffice using "sudo apt-get install -y libreoffice".'; - throw new Error(helpMessage); - } - - const ext = path.extname(inputFilePath); - const baseName = path.basename(inputFilePath, ext); - const expectedPdfName = `${baseName}.pdf`; - const expectedPdfPath = path.join(outputDir, expectedPdfName); - - // Remove any stale pre-existing file with the same temporary output name - if (fs.existsSync(expectedPdfPath)) { - try { - fs.unlinkSync(expectedPdfPath); - } catch (_) {} - } - - // Preprocess workbook with ExcelJS to set paperSize = 14 (Folio 8.5x13) & fit-to-page - let preparedPath = inputFilePath; - let isTempPrepared = false; - try { - preparedPath = await this.prepareWorkbookForLongBond(inputFilePath, outputDir); - isTempPrepared = (preparedPath !== inputFilePath); - } catch (prepErr) { - console.warn('[DocumentConverter] Could not pre-process workbook:', prepErr.message); - } - - try { - await this.convertWithLibreOffice(preparedPath, outputDir, expectedPdfPath); - console.log(`[DocumentConverter] Successfully converted "${baseName}" using Headless LibreOffice.`); - return { - pdfPath: expectedPdfPath, - pdfFileName: expectedPdfName, - engine: 'LIBREOFFICE', - }; - } finally { - // Clean up temporary prepared workbook if created - if (isTempPrepared && fs.existsSync(preparedPath)) { - try { fs.unlinkSync(preparedPath); } catch (_) {} - } - } - } -} - -module.exports = LibreOfficeService; -module.exports.DocumentConverterService = LibreOfficeService; diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 692042e..3d41210 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -757,38 +757,49 @@ Files backup/restore - mirror mode or zip --> # WINDOWS SIDE NAV BAR - windows app -> side bar nav move the archive tab to overview group --> -# SETTINGS SCREEN -- Delete account -> update to self deactivates, remove email function for deletion, use email for deactivation add client side 2 times confirmation dialog -> add 3rd modal for final confirmation -> logout after + + + + +# REPORT SCREEN +- make the layout bento grids for windows app for mobile use normal responsive grid --- diff --git a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt index 455e115..b674f41 100644 --- a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt +++ b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt @@ -82,7 +82,15 @@ class AlarmReceiver : BroadcastReceiver() { } var newHighestId = highestOldId - ensureChannel(context) + val soundEnabled = prefs.getBoolean("flutter.pref_sound_enabled", false) + val vibrationEnabled = prefs.getBoolean("flutter.pref_vibration_enabled", false) + val channelId = when { + soundEnabled && vibrationEnabled -> "tis_rms_activities_sound_vibrate" + soundEnabled -> "tis_rms_activities_sound_only" + vibrationEnabled -> "tis_rms_activities_vibrate_only" + else -> "tis_rms_activities_silent" + } + ensureChannel(context, channelId, soundEnabled, vibrationEnabled) val notifManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager for (i in 0 until list.length()) { @@ -107,7 +115,7 @@ class AlarmReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = Notification.Builder(context, CHANNEL_ID) + val notification = Notification.Builder(context, channelId) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(title) .setContentText(message) @@ -126,16 +134,26 @@ class AlarmReceiver : BroadcastReceiver() { } } - private fun ensureChannel(context: Context) { + private fun ensureChannel(context: Context, channelId: String, soundEnabled: Boolean, vibrationEnabled: Boolean) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (manager.getNotificationChannel(CHANNEL_ID) != null) return + if (manager.getNotificationChannel(channelId) != null) return + val channelName = when (channelId) { + "tis_rms_activities_sound_vibrate" -> "Recent Activities (Sound & Vibrate)" + "tis_rms_activities_sound_only" -> "Recent Activities (Sound Only)" + "tis_rms_activities_vibrate_only" -> "Recent Activities (Vibrate Only)" + else -> "Recent Activities (Silent)" + } + val importance = if (soundEnabled || vibrationEnabled) NotificationManager.IMPORTANCE_HIGH else NotificationManager.IMPORTANCE_DEFAULT val channel = NotificationChannel( - CHANNEL_ID, - "Recent Activities", - NotificationManager.IMPORTANCE_HIGH + channelId, + channelName, + importance ).apply { description = "Notifications for recent activities and system events" - enableVibration(true) + enableVibration(vibrationEnabled) + if (!soundEnabled) { + setSound(null, null) + } } manager.createNotificationChannel(channel) } diff --git a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt index e9b4118..b8350bd 100644 --- a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt +++ b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt @@ -16,16 +16,57 @@ class MainActivity : FlutterActivity() { private fun ensureNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelId = "tis_rms_activities_channel" - val channelName = "Recent Activities" - val channelDescription = "Notifications for recent activities and system events" - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(channelId, channelName, importance).apply { - description = channelDescription - enableVibration(true) - } val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) + + val channels = listOf( + NotificationChannel( + "tis_rms_activities_sound_vibrate", + "Recent Activities (Sound & Vibrate)", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications with sound and vibration" + enableVibration(true) + }, + NotificationChannel( + "tis_rms_activities_sound_only", + "Recent Activities (Sound Only)", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications with sound only" + enableVibration(false) + }, + NotificationChannel( + "tis_rms_activities_vibrate_only", + "Recent Activities (Vibrate Only)", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications with vibration only" + enableVibration(true) + setSound(null, null) + }, + NotificationChannel( + "tis_rms_activities_silent", + "Recent Activities (Silent)", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Silent notifications without sound or vibration" + enableVibration(false) + setSound(null, null) + }, + NotificationChannel( + "tis_rms_activities_channel", + "Recent Activities", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifications for recent activities and system events" + enableVibration(false) + setSound(null, null) + } + ) + + for (ch in channels) { + notificationManager.createNotificationChannel(ch) + } } } } diff --git a/frontend/lib/core/services/notification_service.dart b/frontend/lib/core/services/notification_service.dart index 380050c..42aecb9 100644 --- a/frontend/lib/core/services/notification_service.dart +++ b/frontend/lib/core/services/notification_service.dart @@ -57,15 +57,52 @@ class NotificationService { AndroidFlutterLocalNotificationsPlugin >(); - const AndroidNotificationChannel channel = AndroidNotificationChannel( - 'tis_rms_activities_channel', - 'Recent Activities', - description: 'Notifications for recent activities and system events', - importance: Importance.max, - playSound: true, - enableVibration: true, - ); - await androidImplementation?.createNotificationChannel(channel); + const channels = [ + AndroidNotificationChannel( + 'tis_rms_activities_sound_vibrate', + 'Recent Activities (Sound & Vibrate)', + description: 'Notifications with sound and vibration', + importance: Importance.max, + playSound: true, + enableVibration: true, + ), + AndroidNotificationChannel( + 'tis_rms_activities_sound_only', + 'Recent Activities (Sound Only)', + description: 'Notifications with sound only', + importance: Importance.max, + playSound: true, + enableVibration: false, + ), + AndroidNotificationChannel( + 'tis_rms_activities_vibrate_only', + 'Recent Activities (Vibrate Only)', + description: 'Notifications with vibration only', + importance: Importance.max, + playSound: false, + enableVibration: true, + ), + AndroidNotificationChannel( + 'tis_rms_activities_silent', + 'Recent Activities (Silent)', + description: 'Silent notifications without sound or vibration', + importance: Importance.high, + playSound: false, + enableVibration: false, + ), + AndroidNotificationChannel( + 'tis_rms_activities_channel', + 'Recent Activities', + description: 'Notifications for recent activities and system events', + importance: Importance.max, + playSound: false, + enableVibration: false, + ), + ]; + + for (final ch in channels) { + await androidImplementation?.createNotificationChannel(ch); + } try { await androidImplementation?.requestNotificationsPermission(); @@ -114,22 +151,50 @@ class NotificationService { } catch (_) {} } - const AndroidNotificationDetails androidPlatformChannelSpecifics = + bool soundEnabled = false; + bool vibrationEnabled = false; + try { + final prefs = await SharedPreferences.getInstance(); + soundEnabled = prefs.getBool('pref_sound_enabled') ?? false; + vibrationEnabled = prefs.getBool('pref_vibration_enabled') ?? false; + } catch (_) {} + + String channelId; + if (soundEnabled && vibrationEnabled) { + channelId = 'tis_rms_activities_sound_vibrate'; + } else if (soundEnabled) { + channelId = 'tis_rms_activities_sound_only'; + } else if (vibrationEnabled) { + channelId = 'tis_rms_activities_vibrate_only'; + } else { + channelId = 'tis_rms_activities_silent'; + } + + final AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails( - 'tis_rms_activities_channel', // id - 'Recent Activities', // name + channelId, + 'Recent Activities', channelDescription: 'Notifications for recent activities and system events', - importance: Importance.max, + importance: (soundEnabled || vibrationEnabled) + ? Importance.max + : Importance.high, priority: Priority.max, - playSound: true, - enableVibration: true, + playSound: soundEnabled, + enableVibration: vibrationEnabled, visibility: NotificationVisibility.public, showWhen: true, ); - const NotificationDetails platformChannelSpecifics = NotificationDetails( + final DarwinNotificationDetails darwinPlatformChannelSpecifics = + DarwinNotificationDetails( + presentSound: soundEnabled, + ); + + final NotificationDetails platformChannelSpecifics = NotificationDetails( android: androidPlatformChannelSpecifics, + iOS: darwinPlatformChannelSpecifics, + macOS: darwinPlatformChannelSpecifics, ); final notificationId = diff --git a/frontend/lib/core/services/sound_service.dart b/frontend/lib/core/services/sound_service.dart index 82c3a2c..331ee6e 100644 --- a/frontend/lib/core/services/sound_service.dart +++ b/frontend/lib/core/services/sound_service.dart @@ -7,11 +7,9 @@ import 'package:flutter/services.dart'; class SoundService { static AudioPlayer? _player; static bool _initialized = false; - static bool _muted = false; - /// Whether sound effects are globally muted. - static bool get isMuted => _muted; - static set isMuted(bool value) => _muted = value; + /// Whether sound effects are globally muted (default true / disabled). + static bool isMuted = true; static bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS); @@ -29,7 +27,7 @@ class SoundService { } static Future _playSound(String assetFileName, {bool isErrorOrAlert = false}) async { - if (_muted) return; + if (isMuted) return; try { if (_isDesktop) { if (isErrorOrAlert) { diff --git a/frontend/lib/core/utils/date_utils.dart b/frontend/lib/core/utils/date_utils.dart index 53f4ce1..e00afcf 100644 --- a/frontend/lib/core/utils/date_utils.dart +++ b/frontend/lib/core/utils/date_utils.dart @@ -61,6 +61,18 @@ String formatDateTime(String raw) { } } +/// Returns a formatted date-time string in 12-hour format: `MMM d, yyyy h:mm a` +/// adjusted to Philippine Standard Time. Example: `Sep 25, 2026, 10:45 AM` +String formatDateTime12H(String raw) { + if (raw.isEmpty) return ''; + try { + final dt = parseToPht(raw); + return intl.DateFormat('MMM d, yyyy, h:mm a').format(dt); + } catch (_) { + return raw.split('T').first; + } +} + /// Returns a formatted date-time string explicitly for Modals in `MMM d, yyyy, hh:mm a` format /// adjusted to Philippine Standard Time. Example: `Mar 4, 2005, 12:30 PM` String formatModalDate(String raw) { diff --git a/frontend/lib/core/utils/download_service.dart b/frontend/lib/core/utils/download_service.dart index 5fa1602..3b41c14 100644 --- a/frontend/lib/core/utils/download_service.dart +++ b/frontend/lib/core/utils/download_service.dart @@ -41,6 +41,32 @@ class DownloadService { return path; } + /// Returns the platform-specific directory used for temporary documents. + /// On Windows, returns a private app folder in AppData (not in %TEMP%), + /// preventing Windows Storage Sense and Disk Cleanup from deleting cached files. + static Future getDocumentTempDirectory() async { + if (Platform.isWindows) { + try { + final supportDir = await getApplicationSupportDirectory(); + final privateTemp = Directory('${supportDir.path}\\temp_documents'); + if (!await privateTemp.exists()) { + await privateTemp.create(recursive: true); + } + return privateTemp; + } catch (_) { + final localAppData = Platform.environment['LOCALAPPDATA']; + if (localAppData != null && localAppData.isNotEmpty) { + final privateTemp = Directory('$localAppData\\TIS_RMS\\temp_documents'); + if (!await privateTemp.exists()) { + await privateTemp.create(recursive: true); + } + return privateTemp; + } + } + } + return getTemporaryDirectory(); + } + static Future requestPermissions() async { if (Platform.isAndroid) { var status = await Permission.storage.status; diff --git a/frontend/lib/domain/repositories/document_repository.dart b/frontend/lib/domain/repositories/document_repository.dart index 7265a2f..d48a605 100644 --- a/frontend/lib/domain/repositories/document_repository.dart +++ b/frontend/lib/domain/repositories/document_repository.dart @@ -322,20 +322,6 @@ class DocumentRepository { } } - Future convertExcelToPdf(int id) async { - try { - final options = await _getAuthOptions(); - final response = await _dio.post('/documents/$id/convert-to-pdf', options: options); - final docData = response.data['document'] as Map?; - if (docData != null) { - return DocumentModel.fromJson(docData); - } - throw Exception(response.data['message'] ?? 'Failed to convert document.'); - } on DioException catch (e) { - final msg = e.response?.data?['message'] ?? 'Failed to convert Excel to PDF.'; - throw Exception(msg); - } - } /// Uploads in-memory bytes (e.g. a converted PDF) as a new document in the /// student's folder. diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index e39e4c7..6ce5f3e 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -18,7 +18,10 @@ import 'core/services/foreground_sync_service.dart'; import 'core/services/fcm_service.dart'; import 'ui/providers/theme_provider.dart'; import 'ui/shared/widgets/inactivity_wrapper.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:workmanager/workmanager.dart'; +import 'core/services/sound_service.dart'; +import 'core/services/haptic_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -74,6 +77,15 @@ void main() async { await NotificationService().initialize(); await ForegroundSyncService.stop(); + // Initialize user preferences for sound and vibration (both default to disabled) + try { + final prefs = await SharedPreferences.getInstance(); + final soundEnabled = prefs.getBool('pref_sound_enabled') ?? false; + final vibrationEnabled = prefs.getBool('pref_vibration_enabled') ?? false; + SoundService.isMuted = !soundEnabled; + HapticService.isEnabled = vibrationEnabled; + } catch (_) {} + // Firebase / FCM — only on Android/iOS mobile devices if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { try { diff --git a/frontend/lib/ui/providers/conversion_provider.dart b/frontend/lib/ui/providers/conversion_provider.dart deleted file mode 100644 index be57039..0000000 --- a/frontend/lib/ui/providers/conversion_provider.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../domain/entities/document_model.dart'; -import '../../domain/repositories/document_repository.dart'; -import 'archives_provider.dart'; -import 'document_provider.dart'; - -final conversionProvider = - StateNotifierProvider>((ref) { - final repository = ref.watch(documentRepositoryProvider); - return ConversionNotifier(repository, ref); -}); - -class ConversionNotifier extends StateNotifier> { - final DocumentRepository _repository; - final Ref _ref; - int? _convertingDocId; - - ConversionNotifier(this._repository, this._ref) - : super(const AsyncValue.data(null)); - - int? get convertingDocId => _convertingDocId; - bool isConverting(int docId) => - state.isLoading && _convertingDocId == docId; - - Future convertToPdf(int documentId) async { - _convertingDocId = documentId; - state = const AsyncValue.loading(); - - try { - final convertedDoc = await _repository.convertExcelToPdf(documentId); - - // Invalidate relevant providers to automatically refresh lists - _ref.invalidate(documentPageProvider); - _ref.invalidate(studentFoldersProvider); - _ref.invalidate(archiveDocumentPageProvider); - _ref.invalidate(archiveStudentFoldersProvider); - - state = const AsyncValue.data(null); - return convertedDoc; - } catch (e, st) { - state = AsyncValue.error(e, st); - rethrow; - } finally { - _convertingDocId = null; - } - } -} diff --git a/frontend/lib/ui/providers/preferences_provider.dart b/frontend/lib/ui/providers/preferences_provider.dart index 4e9b4c1..70197b0 100644 --- a/frontend/lib/ui/providers/preferences_provider.dart +++ b/frontend/lib/ui/providers/preferences_provider.dart @@ -16,7 +16,7 @@ class SoundEnabledNotifier extends Notifier { Future _loadFromPrefs() async { final prefs = await SharedPreferences.getInstance(); - final enabled = prefs.getBool(_kSoundEnabledKey) ?? true; + final enabled = prefs.getBool(_kSoundEnabledKey) ?? false; SoundService.isMuted = !enabled; state = enabled; } diff --git a/frontend/lib/ui/screens/archives/archives_screen.dart b/frontend/lib/ui/screens/archives/archives_screen.dart index f500ef5..537e7f4 100644 --- a/frontend/lib/ui/screens/archives/archives_screen.dart +++ b/frontend/lib/ui/screens/archives/archives_screen.dart @@ -18,7 +18,6 @@ import '../../shared/dialogs/success_dialog.dart'; import '../../shared/dialogs/error_dialog.dart'; import '../../providers/auth_provider.dart'; import '../../providers/navigation_provider.dart'; -import '../../providers/conversion_provider.dart'; import '../documents/widgets/bulk_operations_bar.dart'; import '../documents/widgets/document_preview_modal.dart'; import '../documents/widgets/file_folder_card.dart'; @@ -371,24 +370,7 @@ class _ArchivesScreenState extends ConsumerState if (!mounted) return; showErrorDialog(context, 'Download Failed', e.toString()); } - } else if (action == 'convert_pdf') { - try { - final converted = await ref - .read(conversionProvider.notifier) - .convertToPdf(doc.id); - if (!mounted) return; - showSuccessDialog( - context, - message: 'Excel converted to PDF successfully as "${converted.fileName}".', - ); - } catch (e) { - if (!mounted) return; - showErrorDialog( - context, - 'Conversion Failed', - e.toString().replaceFirst('Exception: ', ''), - ); - } + } else if (action == 'view_profile' && doc.studentId != null) { showStudentProfileModal( context, diff --git a/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart b/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart index fc302ce..c0afa4a 100644 --- a/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart +++ b/frontend/lib/ui/screens/audit_trail/audit_trail_screen.dart @@ -8,6 +8,7 @@ import '../../providers/activity_provider.dart'; import '../../providers/login_sessions_provider.dart'; import '../../shared/modals/view_activity_modal.dart'; import '../../shared/widgets/app_pagination.dart'; +import '../../shared/inputs/app_search_bar.dart'; enum AuditTab { activities, users, sessions } @@ -32,14 +33,6 @@ class _AuditTrailScreenState extends ConsumerState { DateTime? _fromDate; DateTime? _toDate; - // Action chips: All, Add, Update, Archive, Delete - static const List _actionFilters = [ - 'All', - 'Add', - 'Update', - 'Archive', - 'Delete', - ]; @override void initState() { @@ -70,45 +63,6 @@ class _AuditTrailScreenState extends ConsumerState { } } - Future _pickDateRange(BuildContext context) async { - final values = await showCalendarDatePicker2Dialog( - context: context, - config: CalendarDatePicker2WithActionButtonsConfig( - calendarType: CalendarDatePicker2Type.range, - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 1)), - selectedDayHighlightColor: AppColors.primaryGreen, - ), - dialogSize: const Size(325, 400), - value: [?_fromDate, ?_toDate], - borderRadius: BorderRadius.circular(16), - ); - - if (values != null && values.isNotEmpty) { - setState(() { - _fromDate = values[0]; - _toDate = values.length > 1 ? values[1] : values[0]; - }); - - final fromStr = _fromDate != null - ? "${_fromDate!.year}-${_fromDate!.month.toString().padLeft(2, '0')}-${_fromDate!.day.toString().padLeft(2, '0')}" - : ''; - final toStr = _toDate != null - ? "${_toDate!.year}-${_toDate!.month.toString().padLeft(2, '0')}-${_toDate!.day.toString().padLeft(2, '0')}" - : ''; - - if (_selectedTab == AuditTab.activities) { - ref.read(activityQueryProvider.notifier).setDateFrom(fromStr); - ref.read(activityQueryProvider.notifier).setDateTo(toStr); - } else if (_selectedTab == AuditTab.users) { - ref.read(userHistoryQueryProvider.notifier).setDateFrom(fromStr); - ref.read(userHistoryQueryProvider.notifier).setDateTo(toStr); - } else { - ref.read(loginSessionsQueryProvider.notifier).setDateFrom(fromStr); - ref.read(loginSessionsQueryProvider.notifier).setDateTo(toStr); - } - } - } Color _getActionColor(String action) { final act = action.toUpperCase(); @@ -144,6 +98,622 @@ class _AuditTrailScreenState extends ConsumerState { return Icons.history_rounded; } + String _getBasicActionTitle(String action) { + final act = action.toLowerCase(); + if (act.contains('create') || act.contains('add')) { + return 'Added user'; + } + if (act.contains('update') || act.contains('edit')) { + return 'Updated user'; + } + if (act.contains('deactivate')) { + return 'Deactivated user'; + } + if (act.contains('activate')) { + return 'Activated user'; + } + if (act.contains('delete')) { + return 'Deleted user'; + } + if (act.contains('reset')) { + return 'Reset user password'; + } + return '$action user'; + } + + void _applySearch(String val) { + if (_selectedTab == AuditTab.activities) { + ref.read(activityQueryProvider.notifier).setSearch(val); + } else if (_selectedTab == AuditTab.users) { + ref.read(userHistoryQueryProvider.notifier).setSearch(val); + } else { + ref.read(loginSessionsQueryProvider.notifier).setSearch(val); + } + } + + void _refreshCurrentTab() { + if (_selectedTab == AuditTab.activities) { + ref.invalidate(recentActivitiesPageProvider); + } else if (_selectedTab == AuditTab.users) { + ref.invalidate(userHistoryPageProvider); + } else { + ref.invalidate(loginSessionsPageProvider); + } + } + + Future _showSearchDialog(BuildContext context) async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final hint = _selectedTab == AuditTab.activities + ? 'Search description or user...' + : _selectedTab == AuditTab.users + ? 'Search user or admin...' + : 'Search username or name...'; + + await showDialog( + context: context, + barrierColor: Colors.black54, + builder: (ctx) { + return Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, kToolbarHeight + 24, 16, 0), + child: Material( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: AppSearchBar( + hint: hint, + controller: _searchController, + collapsible: false, + maxWidth: 600, + onSubmitted: (val) { + Navigator.of(ctx).pop(); + _applySearch(val); + }, + onChanged: (val) { + _applySearch(val); + }, + ), + ), + ), + ); + }, + ); + if (mounted) setState(() {}); + } + + Future _showFilterDialog(BuildContext context) async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final actQuery = ref.read(activityQueryProvider); + final usrQuery = ref.read(userHistoryQueryProvider); + + DateTime? tempFrom = _fromDate; + DateTime? tempTo = _toDate; + String tempAction = _selectedTab == AuditTab.activities + ? actQuery.action + : _selectedTab == AuditTab.users + ? usrQuery.action + : ''; + String tempRole = _selectedTab == AuditTab.users ? usrQuery.role : ''; + + await showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.35), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + final isActivities = _selectedTab == AuditTab.activities; + final isUsers = _selectedTab == AuditTab.users; + + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.symmetric( + horizontal: 20, + vertical: MediaQuery.of(context).size.height < 600 ? 16 : 40, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Container( + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 16, 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.tune_rounded, + size: 18, + color: AppColors.primaryGreen, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isActivities + ? 'Filter Activities' + : isUsers + ? 'Filter User Events' + : 'Filter Login Sessions', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ), + Divider( + height: 1, + thickness: 1, + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + + // Body + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date Range Picker + Text( + 'Date Range', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + InkWell( + onTap: () async { + final values = await showCalendarDatePicker2Dialog( + context: ctx, + config: CalendarDatePicker2WithActionButtonsConfig( + calendarType: CalendarDatePicker2Type.range, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 1)), + selectedDayHighlightColor: AppColors.primaryGreen, + ), + dialogSize: const Size(325, 400), + value: [?tempFrom, ?tempTo], + borderRadius: BorderRadius.circular(16), + ); + if (values != null && values.isNotEmpty) { + setDialogState(() { + tempFrom = values[0]; + tempTo = values.length > 1 ? values[1] : values[0]; + }); + } + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + border: Border.all( + color: (tempFrom != null || tempTo != null) + ? AppColors.primaryGreen + : Theme.of(context).dividerColor.withValues(alpha: 0.3), + ), + borderRadius: BorderRadius.circular(10), + color: (tempFrom != null || tempTo != null) + ? AppColors.primaryGreen.withValues(alpha: 0.06) + : (isDark ? AppColors.darkSurface2 : Colors.grey.shade50), + ), + child: Row( + children: [ + Icon( + Icons.calendar_today_rounded, + size: 16, + color: (tempFrom != null || tempTo != null) + ? AppColors.primaryGreen + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + tempFrom != null && tempTo != null + ? '${tempFrom!.month}/${tempFrom!.day}/${tempFrom!.year} - ${tempTo!.month}/${tempTo!.day}/${tempTo!.year}' + : tempFrom != null + ? 'From ${tempFrom!.month}/${tempFrom!.day}/${tempFrom!.year}' + : 'Select date range...', + style: TextStyle( + fontSize: 13, + fontWeight: (tempFrom != null || tempTo != null) + ? FontWeight.bold + : FontWeight.normal, + color: (tempFrom != null || tempTo != null) + ? AppColors.primaryGreen + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + if (tempFrom != null || tempTo != null) + GestureDetector( + onTap: () { + setDialogState(() { + tempFrom = null; + tempTo = null; + }); + }, + child: const Icon(Icons.close_rounded, size: 16), + ), + ], + ), + ), + ), + + // Action Filter Section (Activities and Users) + if (isActivities || isUsers) ...[ + const SizedBox(height: 18), + Text( + 'Action', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: (isActivities + ? ['All', 'Add', 'Update', 'Archive', 'Delete'] + : ['All', 'Add', 'Update', 'Deactivate', 'Activate', 'Delete']) + .map((act) { + final isSel = (act == 'All' && (tempAction.isEmpty || tempAction == 'All')) || + tempAction.toLowerCase() == act.toLowerCase(); + return ChoiceChip( + label: Text(act), + selected: isSel, + showCheckmark: false, + selectedColor: AppColors.primaryGreen.withValues(alpha: 0.18), + backgroundColor: isDark + ? AppColors.darkSurface2 + : Colors.grey.shade100, + side: BorderSide( + color: isSel + ? AppColors.primaryGreen + : Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + labelStyle: TextStyle( + fontSize: 12, + fontWeight: isSel ? FontWeight.bold : FontWeight.normal, + color: isSel + ? AppColors.primaryGreen + : Theme.of(context).colorScheme.onSurface, + ), + onSelected: (selected) { + setDialogState(() { + tempAction = (act == 'All' || !selected) ? '' : act; + }); + }, + ); + }).toList(), + ), + ], + + // Role Filter Section (Users tab only) + if (isUsers) ...[ + const SizedBox(height: 18), + Text( + 'User Role', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: ['All', 'Admin', 'Teacher', 'Staff'].map((role) { + final isSel = (role == 'All' && (tempRole.isEmpty || tempRole == 'All')) || + tempRole.toLowerCase() == role.toLowerCase(); + return ChoiceChip( + label: Text(role), + selected: isSel, + showCheckmark: false, + selectedColor: AppColors.primaryGreen.withValues(alpha: 0.18), + backgroundColor: isDark + ? AppColors.darkSurface2 + : Colors.grey.shade100, + side: BorderSide( + color: isSel + ? AppColors.primaryGreen + : Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + labelStyle: TextStyle( + fontSize: 12, + fontWeight: isSel ? FontWeight.bold : FontWeight.normal, + color: isSel + ? AppColors.primaryGreen + : Theme.of(context).colorScheme.onSurface, + ), + onSelected: (selected) { + setDialogState(() { + tempRole = (role == 'All' || !selected) ? '' : role; + }); + }, + ); + }).toList(), + ), + ], + ], + ), + ), + ), + + // Footer Buttons + Divider( + height: 1, + thickness: 1, + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + setDialogState(() { + tempFrom = null; + tempTo = null; + tempAction = ''; + tempRole = ''; + }); + }, + child: const Text('Reset', style: TextStyle(color: Colors.redAccent)), + ), + const SizedBox(width: 10), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + ), + onPressed: () { + setState(() { + _fromDate = tempFrom; + _toDate = tempTo; + }); + + final fromStr = tempFrom != null + ? "${tempFrom!.year}-${tempFrom!.month.toString().padLeft(2, '0')}-${tempFrom!.day.toString().padLeft(2, '0')}" + : ''; + final toStr = tempTo != null + ? "${tempTo!.year}-${tempTo!.month.toString().padLeft(2, '0')}-${tempTo!.day.toString().padLeft(2, '0')}" + : ''; + + if (isActivities) { + ref.read(activityQueryProvider.notifier).setDateFrom(fromStr); + ref.read(activityQueryProvider.notifier).setDateTo(toStr); + ref.read(activityQueryProvider.notifier).setAction(tempAction); + } else if (isUsers) { + ref.read(userHistoryQueryProvider.notifier).setDateFrom(fromStr); + ref.read(userHistoryQueryProvider.notifier).setDateTo(toStr); + ref.read(userHistoryQueryProvider.notifier).setAction(tempAction); + ref.read(userHistoryQueryProvider.notifier).setRole(tempRole); + } else { + ref.read(loginSessionsQueryProvider.notifier).setDateFrom(fromStr); + ref.read(loginSessionsQueryProvider.notifier).setDateTo(toStr); + } + + Navigator.of(ctx).pop(); + }, + child: const Text('Apply Filters'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildTabSwitcher(bool isDark, {bool isMobile = false}) { + return Container( + height: isMobile ? 34 : 36, + decoration: BoxDecoration( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.grey.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.all(3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTabItem( + label: 'Activities', + icon: Icons.list_alt_rounded, + tab: AuditTab.activities, + isMobile: isMobile, + ), + _buildTabItem( + label: 'Users', + icon: Icons.account_circle_outlined, + tab: AuditTab.users, + isMobile: isMobile, + ), + _buildTabItem( + label: 'Sessions', + icon: Icons.devices_rounded, + tab: AuditTab.sessions, + isMobile: isMobile, + ), + ], + ), + ); + } + + Widget _buildActiveFiltersStrip(BuildContext context, bool isDark) { + final actQuery = ref.watch(activityQueryProvider); + final usrQuery = ref.watch(userHistoryQueryProvider); + final sesQuery = ref.watch(loginSessionsQueryProvider); + + final currentSearch = _selectedTab == AuditTab.activities + ? actQuery.search + : _selectedTab == AuditTab.users + ? usrQuery.search + : sesQuery.search; + + final currentAction = _selectedTab == AuditTab.activities + ? actQuery.action + : _selectedTab == AuditTab.users + ? usrQuery.action + : ''; + + final currentRole = _selectedTab == AuditTab.users ? usrQuery.role : ''; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + if (_fromDate != null || _toDate != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Chip( + avatar: const Icon(Icons.calendar_today_rounded, size: 13), + label: Text( + _fromDate != null && _toDate != null + ? '${_fromDate!.month}/${_fromDate!.day} - ${_toDate!.month}/${_toDate!.day}' + : 'From ${_fromDate!.month}/${_fromDate!.day}', + style: const TextStyle(fontSize: 11), + ), + deleteIcon: const Icon(Icons.close, size: 14), + onDeleted: () { + setState(() { + _fromDate = null; + _toDate = null; + }); + if (_selectedTab == AuditTab.activities) { + ref.read(activityQueryProvider.notifier).setDateFrom(''); + ref.read(activityQueryProvider.notifier).setDateTo(''); + } else if (_selectedTab == AuditTab.users) { + ref.read(userHistoryQueryProvider.notifier).setDateFrom(''); + ref.read(userHistoryQueryProvider.notifier).setDateTo(''); + } else { + ref.read(loginSessionsQueryProvider.notifier).setDateFrom(''); + ref.read(loginSessionsQueryProvider.notifier).setDateTo(''); + } + }, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + ), + ), + if (currentAction.isNotEmpty && currentAction != 'All') + Padding( + padding: const EdgeInsets.only(right: 6), + child: Chip( + avatar: const Icon(Icons.bolt_rounded, size: 13), + label: Text( + 'Action: $currentAction', + style: const TextStyle(fontSize: 11), + ), + deleteIcon: const Icon(Icons.close, size: 14), + onDeleted: () { + if (_selectedTab == AuditTab.activities) { + ref.read(activityQueryProvider.notifier).setAction(''); + } else if (_selectedTab == AuditTab.users) { + ref.read(userHistoryQueryProvider.notifier).setAction(''); + } + }, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + ), + ), + if (currentRole.isNotEmpty && currentRole != 'All') + Padding( + padding: const EdgeInsets.only(right: 6), + child: Chip( + avatar: const Icon(Icons.badge_rounded, size: 13), + label: Text( + 'Role: $currentRole', + style: const TextStyle(fontSize: 11), + ), + deleteIcon: const Icon(Icons.close, size: 14), + onDeleted: () { + ref.read(userHistoryQueryProvider.notifier).setRole(''); + }, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + ), + ), + if (currentSearch.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Chip( + avatar: const Icon(Icons.search, size: 13), + label: Text( + '"$currentSearch"', + style: const TextStyle(fontSize: 11), + ), + deleteIcon: const Icon(Icons.close, size: 14), + onDeleted: () { + _searchController.clear(); + _applySearch(''); + }, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + ), + ), + TextButton( + onPressed: _clearFilters, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 6), + foregroundColor: Colors.redAccent, + ), + child: const Text('Reset', style: TextStyle(fontSize: 11)), + ), + ], + ), + ); + } + + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -154,26 +724,27 @@ class _AuditTrailScreenState extends ConsumerState { final usrQuery = ref.watch(userHistoryQueryProvider); final sesQuery = ref.watch(loginSessionsQueryProvider); - final currentActionFilter = _selectedTab == AuditTab.activities - ? (actQuery.action.isEmpty ? 'All' : actQuery.action) + final currentSearch = _selectedTab == AuditTab.activities + ? actQuery.search : _selectedTab == AuditTab.users - ? (usrQuery.action.isEmpty ? 'All' : usrQuery.action) - : 'All'; + ? usrQuery.search + : sesQuery.search; - final hasActiveFilters = (_selectedTab == AuditTab.activities - ? (actQuery.action.isNotEmpty || - actQuery.entityTypes.isNotEmpty || - actQuery.search.isNotEmpty || - actQuery.dateFrom.isNotEmpty) - : _selectedTab == AuditTab.users - ? (usrQuery.action.isNotEmpty || - usrQuery.role.isNotEmpty || - usrQuery.search.isNotEmpty || - usrQuery.dateFrom.isNotEmpty) - : (sesQuery.search.isNotEmpty || - sesQuery.dateFrom.isNotEmpty)) || - _fromDate != null || - _toDate != null; + final currentAction = _selectedTab == AuditTab.activities + ? actQuery.action + : _selectedTab == AuditTab.users + ? usrQuery.action + : ''; + + final currentRole = _selectedTab == AuditTab.users ? usrQuery.role : ''; + + int activeFilterCount = 0; + if (_fromDate != null || _toDate != null) activeFilterCount++; + if (currentAction.isNotEmpty && currentAction != 'All') activeFilterCount++; + if (currentRole.isNotEmpty && currentRole != 'All') activeFilterCount++; + if (currentSearch.isNotEmpty) activeFilterCount++; + + final hasActiveFilters = activeFilterCount > 0; return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -181,13 +752,11 @@ class _AuditTrailScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Top App Header & Controls ───────────────────────────── + // ── Compact Header & Controls ───────────────────────────── Container( - padding: EdgeInsets.fromLTRB( - isMobile ? 12 : 24, - isMobile ? 10 : 20, - isMobile ? 12 : 24, - isMobile ? 8 : 12, + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 12 : 20, + vertical: isMobile ? 8 : 10, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, @@ -199,412 +768,228 @@ class _AuditTrailScreenState extends ConsumerState { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // Row: Title and Refresh / Date Range Buttons - if (isMobile) - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: AppColors.primaryGreen.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.manage_history_rounded, - color: AppColors.primaryGreen, - size: 18, - ), + if (isMobile) ...[ + // Mobile Layout: Full-width segmented tab switcher + if (isAdmin) + Container( + width: double.infinity, + height: 38, + decoration: BoxDecoration( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.grey.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'History', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - OutlinedButton.icon( - onPressed: () => _pickDateRange(context), - icon: Icon( - Icons.calendar_today_rounded, - size: 13, - color: (_fromDate != null || _toDate != null) - ? AppColors.primaryGreen - : null, - ), - label: Text( - _fromDate != null && _toDate != null - ? '${_fromDate!.month}/${_fromDate!.day}-${_toDate!.month}/${_toDate!.day}' - : (_fromDate != null - ? 'From ${_fromDate!.month}/${_fromDate!.day}' - : 'Date'), - style: TextStyle( - fontSize: 11, - fontWeight: (_fromDate != null || _toDate != null) - ? FontWeight.bold - : FontWeight.normal, - color: (_fromDate != null || _toDate != null) - ? AppColors.primaryGreen - : null, - ), - ), - style: OutlinedButton.styleFrom( - side: BorderSide( - color: (_fromDate != null || _toDate != null) - ? AppColors.primaryGreen - : Theme.of(context).dividerColor.withValues(alpha: 0.4), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - visualDensity: VisualDensity.compact, - ), - ), - const SizedBox(width: 4), - IconButton( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: 'Refresh', - icon: const Icon(Icons.refresh_rounded, size: 20), - onPressed: () { - if (_selectedTab == AuditTab.activities) { - ref.invalidate(recentActivitiesPageProvider); - } else if (_selectedTab == AuditTab.users) { - ref.invalidate(userHistoryPageProvider); - } else { - ref.invalidate(loginSessionsPageProvider); - } - }, - ), - ], - ) - else - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.all(3), + child: Row( children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.primaryGreen.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.manage_history_rounded, - color: AppColors.primaryGreen, - size: 24, - ), - ), - const SizedBox(width: 12), - Text( - 'Audit Trail & History', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], + Expanded( + child: _buildTabItem( + label: 'Activities', + icon: Icons.list_alt_rounded, + tab: AuditTab.activities, + isMobile: true, + ), ), - const SizedBox(height: 4), - Text( - 'Track detailed operation history, student/document activities, and user events.', - style: TextStyle( - fontSize: 13, - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), + Expanded( + child: _buildTabItem( + label: 'Users', + icon: Icons.account_circle_outlined, + tab: AuditTab.users, + isMobile: true, + ), + ), + Expanded( + child: _buildTabItem( + label: 'Sessions', + icon: Icons.devices_rounded, + tab: AuditTab.sessions, + isMobile: true, ), ), ], ), - Row( - children: [ - // Date Range Filter Button - OutlinedButton.icon( - onPressed: () => _pickDateRange(context), - icon: Icon( - Icons.calendar_today_rounded, - size: 16, - color: (_fromDate != null || _toDate != null) - ? AppColors.primaryGreen - : null, - ), - label: Text( - _fromDate != null && _toDate != null - ? '${_fromDate!.month}/${_fromDate!.day} - ${_toDate!.month}/${_toDate!.day}' - : (_fromDate != null - ? 'From ${_fromDate!.month}/${_fromDate!.day}' - : 'Date Range'), - style: TextStyle( - fontSize: 13, - fontWeight: (_fromDate != null || _toDate != null) - ? FontWeight.bold - : FontWeight.normal, - color: (_fromDate != null || _toDate != null) - ? AppColors.primaryGreen - : null, - ), - ), - style: OutlinedButton.styleFrom( - side: BorderSide( - color: (_fromDate != null || _toDate != null) - ? AppColors.primaryGreen - : Theme.of(context).dividerColor, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - ), - ), - const SizedBox(width: 8), - // Refresh Button - IconButton( - tooltip: 'Refresh', - icon: const Icon(Icons.refresh_rounded), - onPressed: () { - if (_selectedTab == AuditTab.activities) { - ref.invalidate(recentActivitiesPageProvider); - } else if (_selectedTab == AuditTab.users) { - ref.invalidate(userHistoryPageProvider); - } else { - ref.invalidate(loginSessionsPageProvider); - } - }, - ), - ], + ) + else + Text( + 'Activities', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, ), - ], - ), - const SizedBox(height: 16), - - // Tab switcher (Activities, Users, Sessions) - if (isAdmin) - Container( - height: 40, - decoration: BoxDecoration( - color: isDark - ? Colors.black.withValues(alpha: 0.25) - : Colors.grey.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), ), - padding: const EdgeInsets.all(4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildTabItem( - label: 'Activities', - icon: Icons.list_alt_rounded, - tab: AuditTab.activities, - ), - _buildTabItem( - label: 'Users', - icon: Icons.account_circle_outlined, - tab: AuditTab.users, - ), - _buildTabItem( - label: 'Sessions', - icon: Icons.devices_rounded, - tab: AuditTab.sessions, - ), - ], - ), - ), + const SizedBox(height: 6), - const SizedBox(height: 14), - - // Row: Search field and Action Filter Chips - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 12, - runSpacing: 10, - children: [ - // Search box - SizedBox( - width: isMobile ? double.infinity : 280, - height: 40, - child: TextField( - controller: _searchController, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - hintText: _selectedTab == AuditTab.activities - ? 'Search description or user...' + // Sub-row: Section title or filter chips on left, Search/Filter/Refresh on right + Row( + children: [ + if (hasActiveFilters) + Expanded( + child: _buildActiveFiltersStrip(context, isDark), + ) + else ...[ + Text( + _selectedTab == AuditTab.activities + ? 'Recent Activity Logs' : _selectedTab == AuditTab.users - ? 'Search user or admin...' - : 'Search username or name...', - hintStyle: TextStyle( - fontSize: 13, + ? 'User Event Logs' + : 'Login & Session Logs', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, color: Theme.of(context) .colorScheme .onSurface - .withValues(alpha: 0.45), - ), - prefixIcon: const Icon(Icons.search_rounded, size: 18), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear_rounded, size: 16), - onPressed: () { - _searchController.clear(); - if (_selectedTab == AuditTab.activities) { - ref - .read(activityQueryProvider.notifier) - .setSearch(''); - } else if (_selectedTab == AuditTab.users) { - ref - .read(userHistoryQueryProvider.notifier) - .setSearch(''); - } else { - ref - .read(loginSessionsQueryProvider.notifier) - .setSearch(''); - } - }, - ) - : null, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - filled: true, - fillColor: isDark - ? AppColors.darkSurfaceCard - : Colors.grey.withValues(alpha: 0.08), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide( - color: Theme.of(context).dividerColor.withValues(alpha: 0.2), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide( - color: Theme.of(context).dividerColor.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: AppColors.primaryGreen, - width: 1.5, - ), + .withValues(alpha: 0.5), ), ), - onSubmitted: (value) { - if (_selectedTab == AuditTab.activities) { - ref - .read(activityQueryProvider.notifier) - .setSearch(value); - } else if (_selectedTab == AuditTab.users) { - ref - .read(userHistoryQueryProvider.notifier) - .setSearch(value); - } else { - ref - .read(loginSessionsQueryProvider.notifier) - .setSearch(value); - } - }, - ), - ), + const Spacer(), + ], - // Action filter chips: All, Add, Update, Archive, Delete (Only for Activities & Users) - if (_selectedTab != AuditTab.sessions) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: _actionFilters.map((actionName) { - final isSelected = currentActionFilter.toLowerCase() == - actionName.toLowerCase() || - (actionName == 'All' && - (currentActionFilter == 'All' || - currentActionFilter.isEmpty)); - - Color chipColor = AppColors.primaryGreen; - if (actionName == 'Add') { - chipColor = const Color(0xFF10B981); - } else if (actionName == 'Update') { - chipColor = const Color(0xFF3B82F6); - } else if (actionName == 'Archive') { - chipColor = const Color(0xFFF59E0B); - } else if (actionName == 'Delete') { - chipColor = const Color(0xFFEF4444); + // Search Icon + Tooltip( + message: currentSearch.isNotEmpty ? 'Clear Search' : 'Search', + child: IconButton( + icon: Icon( + currentSearch.isNotEmpty ? Icons.close : Icons.search, + size: 20, + color: isDark ? AppColors.darkTextPrimary : Colors.black87, + ), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(), + onPressed: () { + if (currentSearch.isNotEmpty) { + _searchController.clear(); + _applySearch(''); + } else { + _showSearchDialog(context); } + }, + ), + ), + const SizedBox(width: 8), - return Padding( - padding: const EdgeInsets.only(right: 6), - child: FilterChip( - label: Text(actionName), - selected: isSelected, - showCheckmark: false, - selectedColor: chipColor.withValues(alpha: 0.18), - backgroundColor: isDark - ? AppColors.darkSurfaceCard - : Colors.grey.withValues(alpha: 0.08), - side: BorderSide( - color: isSelected - ? chipColor - : Theme.of(context) - .dividerColor - .withValues(alpha: 0.2), - width: isSelected ? 1.5 : 1, - ), - labelStyle: TextStyle( - fontSize: 12, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.w500, - color: isSelected - ? chipColor - : Theme.of(context).colorScheme.onSurface, - ), - onSelected: (_) { - final target = actionName == 'All' ? '' : actionName; - if (_selectedTab == AuditTab.activities) { - ref - .read(activityQueryProvider.notifier) - .setAction(target); - } else { - ref - .read(userHistoryQueryProvider.notifier) - .setAction(target); - } - }, - ), - ); - }).toList(), + // Filter Icon + Tooltip( + message: 'Filter History', + child: IconButton( + onPressed: () => _showFilterDialog(context), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(), + icon: Badge( + isLabelVisible: activeFilterCount > 0, + label: Text(activeFilterCount.toString()), + child: Icon( + Icons.tune_rounded, + size: 20, + color: activeFilterCount > 0 + ? AppColors.primaryGreen + : (isDark ? AppColors.darkTextPrimary : Colors.black87), + ), + ), + ), + ), + const SizedBox(width: 8), + + // Refresh Icon + Tooltip( + message: 'Refresh', + child: IconButton( + icon: const Icon(Icons.refresh_rounded, size: 20), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(), + onPressed: _refreshCurrentTab, + ), + ), + ], + ), + ] else ...[ + // Desktop Layout: Single row with tabs on left and controls on right + Row( + children: [ + if (isAdmin) + _buildTabSwitcher(isDark, isMobile: false) + else + Text( + 'Activities', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + + // Search Icon (same as other screens) + Tooltip( + message: currentSearch.isNotEmpty + ? 'Clear Search' + : 'Search (Ctrl+F)', + child: IconButton( + icon: Icon( + currentSearch.isNotEmpty + ? Icons.close + : Icons.search, + size: 22, + color: isDark + ? AppColors.darkTextPrimary + : Colors.black87, + ), + onPressed: () { + if (currentSearch.isNotEmpty) { + _searchController.clear(); + _applySearch(''); + } else { + _showSearchDialog(context); + } + }, ), ), - // Clear filters button - if (hasActiveFilters) - TextButton.icon( - onPressed: _clearFilters, - icon: const Icon(Icons.clear_all_rounded, size: 18), - label: const Text('Reset', style: TextStyle(fontSize: 12)), - style: TextButton.styleFrom( - foregroundColor: Colors.redAccent, + // Filter Icon (same as other screens) + Tooltip( + message: 'Filter History', + child: IconButton( + onPressed: () => _showFilterDialog(context), + icon: Badge( + isLabelVisible: activeFilterCount > 0, + label: Text(activeFilterCount.toString()), + child: Icon( + Icons.tune_rounded, + size: 21, + color: activeFilterCount > 0 + ? AppColors.primaryGreen + : (isDark + ? AppColors.darkTextPrimary + : Colors.black87), + ), + ), ), ), + + // Refresh Icon + Tooltip( + message: 'Refresh', + child: IconButton( + icon: const Icon(Icons.refresh_rounded, size: 21), + onPressed: _refreshCurrentTab, + ), + ), + ], + ), + + // Active Filter Chips Strip (compact) + if (hasActiveFilters) ...[ + const SizedBox(height: 6), + _buildActiveFiltersStrip(context, isDark), ], - ), + ], ], ), ), @@ -627,6 +1012,7 @@ class _AuditTrailScreenState extends ConsumerState { required String label, required IconData icon, required AuditTab tab, + bool isMobile = false, }) { final isSelected = _selectedTab == tab; return GestureDetector( @@ -640,7 +1026,10 @@ class _AuditTrailScreenState extends ConsumerState { }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 10 : 16, + vertical: isMobile ? 4 : 6, + ), decoration: BoxDecoration( color: isSelected ? AppColors.primaryGreen : Colors.transparent, borderRadius: BorderRadius.circular(9), @@ -655,19 +1044,21 @@ class _AuditTrailScreenState extends ConsumerState { : null, ), child: Row( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, - size: 16, + size: isMobile ? 15 : 16, color: isSelected ? Colors.white : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), ), - const SizedBox(width: 8), + SizedBox(width: isMobile ? 5 : 8), Text( label, style: TextStyle( - fontSize: 13, + fontSize: isMobile ? 12.5 : 13, fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, color: isSelected ? Colors.white @@ -783,7 +1174,7 @@ class _AuditTrailScreenState extends ConsumerState { context: context, title: '${item.entityType.toUpperCase()} - ${item.action}', description: item.description, - date: pht.formatDateTime(item.createdAt), + date: pht.formatDateTime12H(item.createdAt), performedBy: item.performedBy ?? item.username ?? 'System', action: item.action, actionColor: actionColor, @@ -867,7 +1258,7 @@ class _AuditTrailScreenState extends ConsumerState { const Spacer(), // Timestamp Text( - pht.formatDateTime(item.createdAt), + pht.formatDateTime12H(item.createdAt), style: TextStyle( fontSize: 11, color: Theme.of(context) @@ -926,6 +1317,7 @@ class _AuditTrailScreenState extends ConsumerState { ), // Pagination Footer Container( + width: double.infinity, padding: EdgeInsets.symmetric( horizontal: isMobile ? 12 : 20, vertical: isMobile ? 8 : 12, @@ -1103,10 +1495,10 @@ class _AuditTrailScreenState extends ConsumerState { onTap: () { ViewActivityModal.show( context: context, - title: 'User ${item.action.toUpperCase()}', + title: _getBasicActionTitle(item.action), description: 'User @${item.username} (${item.fullName.isNotEmpty ? item.fullName : 'No Name'}) with role "${item.role}" was ${item.action}.', - date: pht.formatDateTime(item.createdAt), + date: pht.formatDateTime12H(item.createdAt), performedBy: item.performedByName ?? item.performedByUsername ?? 'System', @@ -1192,7 +1584,7 @@ class _AuditTrailScreenState extends ConsumerState { const Spacer(), // Timestamp Text( - pht.formatDateTime(item.createdAt), + pht.formatDateTime12H(item.createdAt), style: TextStyle( fontSize: 11, color: Theme.of(context) @@ -1204,19 +1596,32 @@ class _AuditTrailScreenState extends ConsumerState { ], ), const SizedBox(height: 8), - // User info + // Action Title in basic words Text( - item.fullName.isNotEmpty - ? '${item.fullName} (@${item.username})' - : '@${item.username}', + _getBasicActionTitle(item.action), style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface, ), ), - const SizedBox(height: 6), - // Performed By + const SizedBox(height: 3), + // Target user info (sub-description) + Text( + item.fullName.isNotEmpty + ? '${item.fullName} (@${item.username})' + : '@${item.username}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 5), + // Action by (sub-sub-description) Row( children: [ Icon( @@ -1229,7 +1634,7 @@ class _AuditTrailScreenState extends ConsumerState { ), const SizedBox(width: 4), Text( - 'Performed by: ${item.performedByName ?? item.performedByUsername ?? 'System'}', + 'Action by: ${item.performedByName ?? item.performedByUsername ?? 'System'}', style: TextStyle( fontSize: 12, color: Theme.of(context) @@ -1253,6 +1658,7 @@ class _AuditTrailScreenState extends ConsumerState { ), // Pagination Footer Container( + width: double.infinity, padding: EdgeInsets.symmetric( horizontal: isMobile ? 12 : 20, vertical: isMobile ? 8 : 12, @@ -1448,9 +1854,9 @@ class _AuditTrailScreenState extends ConsumerState { title: isActive ? 'Active Session' : 'Completed Session', description: 'User @${log.username} (${log.fullName.isNotEmpty ? log.fullName : 'No Name'}) logged in via $platformDisplay (${log.ipAddress ?? 'Unknown IP'}).\n\n' - 'Login: ${pht.formatDateTime(log.loginAt)}\n' - '${log.logoutAt != null ? 'Logout: ${pht.formatDateTime(log.logoutAt!)}' : 'Session is currently active.'}', - date: pht.formatDateTime(log.loginAt), + 'Login: ${pht.formatDateTime12H(log.loginAt)}\n' + '${log.logoutAt != null ? 'Logout: ${pht.formatDateTime12H(log.logoutAt!)}' : 'Session is currently active.'}', + date: pht.formatDateTime12H(log.loginAt), performedBy: log.fullName.isNotEmpty ? log.fullName : log.username, action: isActive ? 'ACTIVE' : 'LOGGED OUT', actionColor: isActive ? const Color(0xFF10B981) : Colors.grey, @@ -1566,7 +1972,7 @@ class _AuditTrailScreenState extends ConsumerState { const Spacer(), // Login Timestamp Text( - pht.formatDateTime(log.loginAt), + pht.formatDateTime12H(log.loginAt), style: TextStyle( fontSize: 11, color: Theme.of(context) @@ -1656,7 +2062,7 @@ class _AuditTrailScreenState extends ConsumerState { ), const SizedBox(width: 4), Text( - 'Left: ${pht.formatDateTime(log.logoutAt!)}', + 'Left: ${pht.formatDateTime12H(log.logoutAt!)}', style: TextStyle( fontSize: 12, color: Theme.of(context) @@ -1682,6 +2088,7 @@ class _AuditTrailScreenState extends ConsumerState { ), // Pagination Footer Container( + width: double.infinity, padding: EdgeInsets.symmetric( horizontal: isMobile ? 12 : 20, vertical: isMobile ? 8 : 12, diff --git a/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart b/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart index 62116d9..cdba91f 100644 --- a/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart +++ b/frontend/lib/ui/screens/dashboard/widgets/dashboard_kpis.dart @@ -62,33 +62,35 @@ class _KpisContent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (isWide) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _StudentDocCard( - title: 'Top Students by Documents', - icon: Icons.emoji_events_rounded, - iconColor: Colors.amber.shade700, - students: kpis.topStudents, - allTopStudents: kpis.topStudents, - allBottomStudents: kpis.bottomStudents, - isTop: true, + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _StudentDocCard( + title: 'Top Students by Documents', + icon: Icons.emoji_events_rounded, + iconColor: Colors.amber.shade700, + students: kpis.topStudents, + allTopStudents: kpis.topStudents, + allBottomStudents: kpis.bottomStudents, + isTop: true, + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: _StudentDocCard( - title: 'Needs Attention', - icon: Icons.warning_amber_rounded, - iconColor: Colors.orange, - students: kpis.bottomStudents, - allTopStudents: kpis.topStudents, - allBottomStudents: kpis.bottomStudents, - isTop: false, + const SizedBox(width: 16), + Expanded( + child: _StudentDocCard( + title: 'Needs Attention', + icon: Icons.warning_amber_rounded, + iconColor: Colors.orange, + students: kpis.bottomStudents, + allTopStudents: kpis.topStudents, + allBottomStudents: kpis.bottomStudents, + isTop: false, + ), ), - ), - ], + ], + ), ) else Column( @@ -165,8 +167,29 @@ class _KpisContent extends StatelessWidget { const SizedBox(height: 16), - // Bento Block 3: Full-width Upload Trend Line - _UploadTrendCard(entries: kpis.uploadTrend), + // Bento Block 3: Upload Trend (flex: 3) + Storage Analytics (flex: 2) + if (isWide) + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 3, + child: _UploadTrendCard(entries: kpis.uploadTrend), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: _StorageCard(analytics: kpis.storageAnalytics), + ), + ], + ), + ) + else ...[ + _UploadTrendCard(entries: kpis.uploadTrend), + const SizedBox(height: 16), + _StorageCard(analytics: kpis.storageAnalytics), + ], const SizedBox(height: 16), @@ -227,11 +250,6 @@ class _KpisContent extends StatelessWidget { ], ), - const SizedBox(height: 16), - - // Storage analytics (full width) - _StorageCard(analytics: kpis.storageAnalytics), - const SizedBox(height: 32), ], ); @@ -1332,7 +1350,7 @@ class _UploadTrendCard extends StatelessWidget { icon: Icons.show_chart_rounded, subtitle: 'Documents uploaded over the last 30 days', child: SizedBox( - height: 160, + height: 180, child: LineChart( LineChartData( minY: 0, diff --git a/frontend/lib/ui/screens/documents/documents_screen.dart b/frontend/lib/ui/screens/documents/documents_screen.dart index 6e0ca18..18263bf 100644 --- a/frontend/lib/ui/screens/documents/documents_screen.dart +++ b/frontend/lib/ui/screens/documents/documents_screen.dart @@ -14,7 +14,6 @@ import '../../shared/dialogs/document_properties_dialog.dart'; import '../../shared/widgets/app_pagination.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../providers/navigation_provider.dart'; -import '../../providers/conversion_provider.dart'; import '../../shared/dialogs/success_dialog.dart'; import '../../shared/dialogs/error_dialog.dart'; @@ -348,24 +347,7 @@ class _DocumentsScreenState extends ConsumerState } } else if (action == 'archive') { _confirmArchive(document); - } else if (action == 'convert_pdf') { - try { - final converted = await ref - .read(conversionProvider.notifier) - .convertToPdf(documentId); - if (!mounted) return; - showSuccessDialog( - context, - message: 'Excel converted to PDF successfully as "${converted.fileName}".', - ); - } catch (e) { - if (!mounted) return; - showErrorDialog( - context, - 'Conversion Failed', - e.toString().replaceFirst('Exception: ', ''), - ); - } + } else if (action == 'open_with') { // Open the document preview modal which has the Open With action showDocumentPreview(context: context, document: document); diff --git a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart index f4d1898..63ead3d 100644 --- a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart @@ -4,7 +4,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -15,7 +14,6 @@ import '../../../../core/utils/download_service.dart'; import '../../../../domain/entities/document_model.dart'; import '../../../../domain/repositories/document_repository.dart'; import '../../../providers/document_provider.dart'; -import '../../../providers/conversion_provider.dart'; import '../../../providers/auth_provider.dart'; import '../../../shared/dialogs/error_dialog.dart'; import '../../../shared/dialogs/success_dialog.dart'; @@ -298,7 +296,7 @@ class _DocumentPreviewDialogState filePath = widget.localFile!.path; } else { if (_token == null || widget.document == null) return; - final tempDir = await getTemporaryDirectory(); + final tempDir = await DownloadService.getDocumentTempDirectory(); filePath = '${tempDir.path}${Platform.pathSeparator}$_fileName'; final file = File(filePath); if (!await file.exists()) { @@ -357,7 +355,7 @@ class _DocumentPreviewDialogState if (widget.document == null || _isOpeningExternal) return; setState(() => _isOpeningExternal = true); try { - final tempDir = await getTemporaryDirectory(); + final tempDir = await DownloadService.getDocumentTempDirectory(); final tempPath = '${tempDir.path}${Platform.pathSeparator}$_fileName'; _editTempFile = File(tempPath); if (!await _editTempFile!.exists()) { @@ -368,8 +366,16 @@ class _DocumentPreviewDialogState if (Platform.isWindows) { await Process.run('cmd', ['/c', 'start', '""', tempPath], runInShell: true); } else { - final uri = Uri.file(tempPath); - await launchUrl(uri, mode: LaunchMode.externalApplication); + final result = await OpenFilex.open(tempPath); + if (result.type == ResultType.noAppToOpen) { + throw Exception('No application installed on your device to edit this file.'); + } else if (result.type == ResultType.fileNotFound) { + throw Exception('File not found: $tempPath'); + } else if (result.type == ResultType.permissionDenied) { + throw Exception('Permission denied to open file.'); + } else if (result.type == ResultType.error) { + throw Exception(result.message); + } } // Prompt user to upload the edited file as a new version @@ -478,32 +484,6 @@ class _DocumentPreviewDialogState } } - bool _isConverting = false; - - Future _convertToPdf() async { - if (widget.document == null || _isConverting) return; - setState(() => _isConverting = true); - try { - final converted = await ref - .read(conversionProvider.notifier) - .convertToPdf(widget.document!.id); - if (!mounted) return; - showSuccessDialog( - context, - message: 'Excel converted to PDF successfully as "${converted.fileName}".', - ); - } catch (e) { - if (!mounted) return; - showErrorDialog( - context, - 'Conversion Failed', - e.toString().replaceFirst('Exception: ', ''), - ); - } finally { - if (mounted) setState(() => _isConverting = false); - } - } - @override Widget build(BuildContext context) { final screenW = MediaQuery.of(context).size.width; @@ -513,16 +493,6 @@ class _DocumentPreviewDialogState final List<_PreviewActionItem> directActions = []; final List<_PreviewActionItem> menuActions = []; - - if (_isExcel && widget.document != null) { - directActions.add(_PreviewActionItem( - id: 'convert_pdf', - label: 'Convert to PDF', - iconData: Icons.picture_as_pdf_outlined, - onTap: _isConverting ? null : _convertToPdf, - isLoading: _isConverting, - )); - } if (_isExcel || _isPdf || _isOffice || _isImage) { // Open in default app directActions.add(_PreviewActionItem( diff --git a/frontend/lib/ui/screens/documents/widgets/document_version_history_sheet.dart b/frontend/lib/ui/screens/documents/widgets/document_version_history_sheet.dart index 993f0c0..6c1e0c0 100644 --- a/frontend/lib/ui/screens/documents/widgets/document_version_history_sheet.dart +++ b/frontend/lib/ui/screens/documents/widgets/document_version_history_sheet.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:path_provider/path_provider.dart'; import '../../../../core/constants/app_colors.dart'; import '../../../../core/utils/download_service.dart'; import '../../../../core/utils/file_icon_helper.dart'; @@ -88,7 +87,7 @@ class _VersionHistorySheetState extends State<_VersionHistorySheet> { if (_loading.contains(version.id)) return; setState(() => _loading.add(version.id)); try { - final tempDir = await getTemporaryDirectory(); + final tempDir = await DownloadService.getDocumentTempDirectory(); final tempPath = '${tempDir.path}${Platform.pathSeparator}v${version.versionNumber}_${version.fileName}'; final tempFile = File(tempPath); if (!await tempFile.exists()) { @@ -233,7 +232,7 @@ class _VersionHistorySheetState extends State<_VersionHistorySheet> { controller: scrollController, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), itemCount: _versions!.length, - separatorBuilder: (_, __) => + separatorBuilder: (_, _) => Divider(height: 1, color: isDark ? AppColors.darkBorder : Colors.grey.shade100), itemBuilder: (ctx, i) { final version = _versions![i]; diff --git a/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart b/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart index 7aad53e..c049a42 100644 --- a/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart +++ b/frontend/lib/ui/screens/documents/widgets/file_folder_card.dart @@ -96,21 +96,6 @@ class _FileFolderCardState extends State { ], ), ), - if (_isExcel) - const PopupMenuItem( - value: 'convert_pdf', - child: Row( - children: [ - Icon( - Icons.picture_as_pdf_outlined, - size: 18, - color: Colors.deepOrangeAccent, - ), - SizedBox(width: 12), - Text('Convert to PDF', style: TextStyle(fontSize: 14)), - ], - ), - ), const PopupMenuItem( value: 'queue', child: Row( diff --git a/frontend/lib/ui/screens/documents/widgets/print_queue_modal.dart b/frontend/lib/ui/screens/documents/widgets/print_queue_modal.dart index c8fbf91..100c901 100644 --- a/frontend/lib/ui/screens/documents/widgets/print_queue_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/print_queue_modal.dart @@ -143,19 +143,28 @@ class _PrintQueueModalState extends ConsumerState { }).toList(); if (excelItems.isNotEmpty) { - final shouldConvert = await showDialog( + if (excelItems.length == currentItems.length) { + showErrorDialog( + context, + 'Cannot Print Spreadsheets', + 'The print list only contains spreadsheet files (.xlsx, .xls, .csv). Spreadsheets cannot be printed directly. Please convert them to PDF or print PDF/image documents.', + ); + return; + } + + final shouldProceed = await showDialog( context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), title: const Row( children: [ - Icon(Icons.picture_as_pdf_rounded, color: Colors.deepOrangeAccent), + Icon(Icons.info_outline_rounded, color: Colors.orange), SizedBox(width: 10), - Text('Convert Excel to PDF?'), + Text('Spreadsheet Files Skipped'), ], ), content: Text( - 'The print list contains ${excelItems.length} spreadsheet file(s) (.xlsx/.xls/.csv) which cannot be printed directly.\n\nWould you like to automatically convert them to PDF and proceed to print?', + 'The print list contains ${excelItems.length} spreadsheet file(s) (.xlsx/.xls/.csv) which cannot be printed directly.\n\nOnly PDF and image files will be printed. Would you like to proceed with printing the remaining files?', ), actions: [ TextButton( @@ -168,34 +177,14 @@ class _PrintQueueModalState extends ConsumerState { foregroundColor: Colors.white, ), onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Convert & Print'), + child: const Text('Proceed'), ), ], ), ); - if (shouldConvert != true) return; - - setState(() => _isPrinting = true); - try { - final docRepo = ref.read(documentRepositoryProvider); - for (final exItem in excelItems) { - final convertedDoc = await docRepo.convertExcelToPdf(exItem.documentId); - await docRepo.removeFromPrintQueue(exItem.queueId); - await docRepo.addToPrintQueue(convertedDoc.id); - } - ref.invalidate(printQueueProvider); - currentItems = await ref.read(printQueueProvider.future); - } catch (e) { - if (!mounted) return; - setState(() => _isPrinting = false); - showErrorDialog( - context, - 'Conversion Failed', - e.toString().replaceFirst('Exception: ', ''), - ); - return; - } + if (shouldProceed != true) return; + currentItems = currentItems.where((item) => !excelItems.contains(item)).toList(); } setState(() => _isPrinting = true); diff --git a/frontend/lib/ui/screens/documents/widgets/student_archives_modal.dart b/frontend/lib/ui/screens/documents/widgets/student_archives_modal.dart index e317ab9..ba1e616 100644 --- a/frontend/lib/ui/screens/documents/widgets/student_archives_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/student_archives_modal.dart @@ -9,7 +9,6 @@ import '../../../../core/utils/file_icon_helper.dart'; import '../../../../core/utils/theme_extension.dart'; import '../../../../domain/entities/document_model.dart'; import '../../../providers/archives_provider.dart'; -import '../../../providers/conversion_provider.dart'; import '../../../providers/document_provider.dart'; import '../../../shared/dialogs/document_properties_dialog.dart'; import '../../../shared/dialogs/error_dialog.dart'; @@ -631,10 +630,7 @@ class _StudentArchivesModalState extends ConsumerState { context, document: item, ); - } else if (val == 'convert_pdf') { - ref - .read(conversionProvider.notifier) - .convertToPdf(item.id); + } else if (val == 'restore') { _handleRestoreSingle(item); } else if (val == 'delete') { diff --git a/frontend/lib/ui/screens/reports/reports_screen.dart b/frontend/lib/ui/screens/reports/reports_screen.dart index e7a4408..7367041 100644 --- a/frontend/lib/ui/screens/reports/reports_screen.dart +++ b/frontend/lib/ui/screens/reports/reports_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:excel/excel.dart' hide Border, TextSpan; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -502,6 +503,8 @@ class _ReportsScreenState extends ConsumerState { } } + final isAndroid = Theme.of(context).platform == TargetPlatform.android || (!kIsWeb && Platform.isAndroid); + return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.transparent, @@ -517,7 +520,7 @@ class _ReportsScreenState extends ConsumerState { child: SingleChildScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(AppSizes.p24), + padding: EdgeInsets.all(isAndroid ? 14 : AppSizes.p24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -540,27 +543,32 @@ class _ReportsScreenState extends ConsumerState { ), error: (err, st) => _errorWidget('Error fetching analytics: $err'), - data: (data) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildComplianceHeader(context, data), - const SizedBox(height: AppSizes.p20), + data: (data) => LayoutBuilder( + builder: (context, constraints) { + final isWindowsApp = + Theme.of(context).platform == TargetPlatform.windows + ? constraints.maxWidth >= 720 + : (!isAndroid && constraints.maxWidth >= 850); - // 1. Filter Panel (collapsible) - if (!Platform.isAndroid) ...[ - _buildFilterPanel(context), - const SizedBox(height: AppSizes.p20), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildComplianceHeader(context, data), + const SizedBox(height: AppSizes.p20), - // 2. Focused Compliance KPI Cards (4 cards) - _buildMetricsGrid(data), - const SizedBox(height: AppSizes.p24), + // 1. Filter Panel (collapsible) - hidden on Android + if (!isAndroid) ...[ + _buildFilterPanel(context), + const SizedBox(height: AppSizes.p20), + ], - // 3. Analytics Grid Row 1: Grade Compliance & Missing Requirements Breakdown - LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= 1000) { - return Row( + // 2. Focused Compliance KPI Cards (Bento on Windows, normal responsive grid on mobile) + _buildMetricsGrid(data, isWindowsApp: isWindowsApp), + const SizedBox(height: AppSizes.p24), + + // 3. Analytics Bento Block 1: Grade Compliance & Missing Requirements Breakdown + if (isWindowsApp) + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( @@ -570,7 +578,7 @@ class _ReportsScreenState extends ConsumerState { isDesktop: true, ), ), - const SizedBox(width: AppSizes.p24), + const SizedBox(width: AppSizes.p20), Expanded( flex: 5, child: _buildMissingDocsChart( @@ -579,31 +587,26 @@ class _ReportsScreenState extends ConsumerState { ), ), ], - ); - } else { - return Column( + ) + else + Column( children: [ _buildGradeComplianceChart( data.students, isDesktop: false, ), - const SizedBox(height: AppSizes.p24), + const SizedBox(height: AppSizes.p16), _buildMissingDocsChart( data.missingDocsBreakdown, isDesktop: false, ), ], - ); - } - }, - ), - const SizedBox(height: AppSizes.p24), + ), + const SizedBox(height: AppSizes.p24), - // 4. Analytics Grid Row 2: Status Breakdown & Historical Trends - LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= 1000) { - return Row( + // 4. Analytics Bento Block 2: Status Breakdown & Historical Trends + if (isWindowsApp) + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( @@ -613,7 +616,7 @@ class _ReportsScreenState extends ConsumerState { isDesktop: true, ), ), - const SizedBox(width: AppSizes.p24), + const SizedBox(width: AppSizes.p20), Expanded( flex: 6, child: _buildYearlyComparisonChart( @@ -621,26 +624,25 @@ class _ReportsScreenState extends ConsumerState { ), ), ], - ); - } else { - return Column( + ) + else + Column( children: [ _buildStatusDonutChart( data.studentCounts, isDesktop: false, ), - const SizedBox(height: AppSizes.p24), + const SizedBox(height: AppSizes.p16), _buildYearlyComparisonChart(isDesktop: false), ], - ); - } - }, - ), - const SizedBox(height: AppSizes.p24), + ), + const SizedBox(height: AppSizes.p24), - // 5. Interactive Student Compliance Table - _buildComplianceTable(data), - ], + // 5. Interactive Student Compliance Table (Full Width) + _buildComplianceTable(data), + ], + ); + }, ), ), ], @@ -760,7 +762,8 @@ class _ReportsScreenState extends ConsumerState { Widget _buildComplianceHeader(BuildContext context, ReportStats data) { final isDark = Theme.of(context).brightness == Brightness.dark; - final isDesktop = MediaQuery.of(context).size.width > 900; + final isAndroid = Theme.of(context).platform == TargetPlatform.android || (!kIsWeb && Platform.isAndroid); + final isDesktop = !isAndroid && MediaQuery.of(context).size.width > 900; if (isDesktop) { return Row( @@ -845,14 +848,16 @@ class _ReportsScreenState extends ConsumerState { child: OutlinedButton.icon( onPressed: () => _showComplianceFilterModal(context), icon: const Icon(Icons.filter_list_rounded, size: 16), - label: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - activeFilterCount > 0 ? 'Filter ($activeFilterCount)' : 'Filter', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), - ), + label: Text( + activeFilterCount > 0 ? 'Filter ($activeFilterCount)' : 'Filter', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + visualDensity: VisualDensity.compact, + alignment: Alignment.center, foregroundColor: activeFilterCount > 0 ? AppColors.primaryGreen : (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary), @@ -1333,7 +1338,7 @@ class _ReportsScreenState extends ConsumerState { } // ── KPI Cards: 4 Focused Compliance Metrics ───────────────────────────── - Widget _buildMetricsGrid(ReportStats reportData) { + Widget _buildMetricsGrid(ReportStats reportData, {required bool isWindowsApp}) { final students = reportData.students; final breakdown = reportData.missingDocsBreakdown; @@ -1343,65 +1348,264 @@ class _ReportsScreenState extends ConsumerState { final complianceRate = students.isNotEmpty ? (compliantCount / students.length * 100) : 0.0; - return LayoutBuilder( - builder: (ctx, constraints) { - final cols = constraints.maxWidth >= 1000 - ? 4 - : (constraints.maxWidth >= 550 ? 2 : 1); - return GridView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - mainAxisExtent: constraints.maxWidth < 550 ? 150 : 160, - ), + final isDark = Theme.of(context).brightness == Brightness.dark; + + if (isWindowsApp) { + // Bento Grid row for Windows Desktop App (Hero card + 3 supporting metric cards) + return SizedBox( + height: 156, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildPrimaryKpiCard( - title: 'Overall Compliance', - value: '${complianceRate.toStringAsFixed(1)}%', - subtitle: '$compliantCount of ${students.length} students complete', - icon: Icons.verified_outlined, - color: complianceRate >= 80 - ? AppColors.primaryGreen - : complianceRate >= 50 - ? Colors.orange - : Colors.red, - progress: complianceRate / 100, + Expanded( + flex: 3, + child: _buildHeroComplianceCard( + complianceRate: complianceRate, + compliantCount: compliantCount, + totalStudents: students.length, + isDark: isDark, + ), ), - _buildPrimaryKpiCard( - title: 'Complete Records', - value: '$compliantCount', - subtitle: 'All documents submitted', - icon: Icons.check_circle_outline_rounded, - color: AppColors.primaryGreen, + const SizedBox(width: 14), + Expanded( + flex: 2, + child: _buildPrimaryKpiCard( + title: 'Complete Records', + value: '$compliantCount', + subtitle: 'All documents submitted', + icon: Icons.check_circle_outline_rounded, + color: AppColors.primaryGreen, + ), ), - _buildPrimaryKpiCard( - title: 'Needs Follow-up', - value: '$withIssuesCount', - subtitle: 'Missing 1 or more documents', - icon: Icons.warning_amber_rounded, - color: withIssuesCount == 0 - ? AppColors.primaryGreen - : Colors.orange.shade700, + const SizedBox(width: 14), + Expanded( + flex: 2, + child: _buildPrimaryKpiCard( + title: 'Needs Follow-up', + value: '$withIssuesCount', + subtitle: 'Missing 1+ documents', + icon: Icons.warning_amber_rounded, + color: withIssuesCount == 0 + ? AppColors.primaryGreen + : Colors.orange.shade700, + ), ), - _buildPrimaryKpiCard( - title: 'Pending Documents', - value: '$totalMissing', - subtitle: 'Across ${breakdown.length} requirement types', - icon: Icons.description_outlined, - color: totalMissing == 0 - ? AppColors.primaryGreen - : Colors.redAccent, + const SizedBox(width: 14), + Expanded( + flex: 2, + child: _buildPrimaryKpiCard( + title: 'Pending Docs', + value: '$totalMissing', + subtitle: 'Across ${breakdown.length} types', + icon: Icons.description_outlined, + color: totalMissing == 0 + ? AppColors.primaryGreen + : Colors.redAccent, + ), ), ], - ); - }, + ), + ); + } else { + // Normal responsive grid for mobile (2 columns) + return LayoutBuilder( + builder: (ctx, constraints) { + final isVeryNarrow = constraints.maxWidth < 340; + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: isVeryNarrow ? 1.25 : 1.45, + children: [ + _buildPrimaryKpiCard( + title: 'Compliance Rate', + value: '${complianceRate.toStringAsFixed(1)}%', + subtitle: '$compliantCount / ${students.length} complete', + icon: Icons.verified_outlined, + color: complianceRate >= 80 + ? AppColors.primaryGreen + : complianceRate >= 50 + ? Colors.orange + : Colors.red, + progress: complianceRate / 100, + isMobile: true, + ), + _buildPrimaryKpiCard( + title: 'Complete', + value: '$compliantCount', + subtitle: 'All documents in', + icon: Icons.check_circle_outline_rounded, + color: AppColors.primaryGreen, + isMobile: true, + ), + _buildPrimaryKpiCard( + title: 'Needs Follow-up', + value: '$withIssuesCount', + subtitle: 'Missing documents', + icon: Icons.warning_amber_rounded, + color: withIssuesCount == 0 + ? AppColors.primaryGreen + : Colors.orange.shade700, + isMobile: true, + ), + _buildPrimaryKpiCard( + title: 'Pending Docs', + value: '$totalMissing', + subtitle: '${breakdown.length} requirement types', + icon: Icons.description_outlined, + color: totalMissing == 0 + ? AppColors.primaryGreen + : Colors.redAccent, + isMobile: true, + ), + ], + ); + }, + ); + } + } + + /// Hero Bento KPI Card for Windows App + Widget _buildHeroComplianceCard({ + required double complianceRate, + required int compliantCount, + required int totalStudents, + required bool isDark, + }) { + final Color effectiveColor = complianceRate >= 80 + ? (isDark ? const Color(0xFF76BA8A) : AppColors.primaryGreen) + : complianceRate >= 50 + ? (isDark ? const Color(0xFFE5A663) : Colors.orange.shade700) + : (isDark ? const Color(0xFFD67878) : Colors.redAccent); + + final String statusLabel = complianceRate >= 80 + ? 'High' + : complianceRate >= 50 + ? 'Moderate' + : 'Action Needed'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: effectiveColor.withValues(alpha: 0.3)), + boxShadow: [ + BoxShadow( + color: effectiveColor.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.verified_rounded, + size: 18, + color: effectiveColor, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Overall Compliance', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark + ? AppColors.darkTextPrimary + : AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: effectiveColor.withValues(alpha: 0.3), + width: 0.8, + ), + ), + child: Text( + statusLabel, + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.bold, + color: effectiveColor, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + '${complianceRate.toStringAsFixed(1)}%', + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.w800, + color: effectiveColor, + letterSpacing: -0.5, + ), + ), + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (complianceRate / 100).clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: isDark + ? AppColors.darkSurface2 + : Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(effectiveColor), + ), + ), + const SizedBox(height: 6), + Text( + '$compliantCount of $totalStudents students complete', + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w500, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), ); } - /// Highlighted primary KPI card with colored accent border and optional progress. + /// Primary KPI card with colored accent border and optional progress. Widget _buildPrimaryKpiCard({ required String title, required String value, @@ -1409,6 +1613,7 @@ class _ReportsScreenState extends ConsumerState { required IconData icon, required Color color, double? progress, + bool isMobile = false, }) { final isDark = Theme.of(context).brightness == Brightness.dark; // In dark mode, slightly desaturate bright accents for a softer, eye-friendly look @@ -1425,7 +1630,10 @@ class _ReportsScreenState extends ConsumerState { : color; return Container( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 10 : 14, + vertical: isMobile ? 10 : 12, + ), decoration: BoxDecoration( color: isDark ? AppColors.darkSurfaceCard : Colors.white, borderRadius: BorderRadius.circular(12), @@ -1445,19 +1653,19 @@ class _ReportsScreenState extends ConsumerState { Row( children: [ Container( - padding: const EdgeInsets.all(6), + padding: EdgeInsets.all(isMobile ? 5 : 6), decoration: BoxDecoration( color: effectiveColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon(icon, size: 18, color: effectiveColor), + child: Icon(icon, size: isMobile ? 16 : 18, color: effectiveColor), ), const SizedBox(width: 8), Expanded( child: Text( title, style: TextStyle( - fontSize: 13, + fontSize: isMobile ? 12 : 13, fontWeight: FontWeight.w600, color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary, ), @@ -1477,7 +1685,7 @@ class _ReportsScreenState extends ConsumerState { child: Text( value, style: TextStyle( - fontSize: 28, + fontSize: isMobile ? 22 : 28, fontWeight: FontWeight.w800, color: effectiveColor, letterSpacing: -0.5, @@ -1485,26 +1693,26 @@ class _ReportsScreenState extends ConsumerState { ), ), if (progress != null) ...[ - const SizedBox(height: 6), + const SizedBox(height: 4), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress.clamp(0.0, 1.0), - minHeight: 5, + minHeight: isMobile ? 4 : 5, backgroundColor: isDark ? AppColors.darkSurface2 : Colors.grey.shade100, valueColor: AlwaysStoppedAnimation(effectiveColor), ), ), - const SizedBox(height: 4), + const SizedBox(height: 3), ] else ...[ const SizedBox(height: 2), ], Text( subtitle, style: TextStyle( - fontSize: 11.5, + fontSize: isMobile ? 10.5 : 11.5, fontWeight: FontWeight.w500, color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, ), @@ -3245,7 +3453,7 @@ class _ReportsScreenState extends ConsumerState { return Container( width: double.infinity, - height: isDesktop ? 460 : null, + height: isDesktop ? 485 : null, padding: const EdgeInsets.all(AppSizes.p24), decoration: _cardDecoration(), child: Column( @@ -3718,7 +3926,7 @@ class _ReportsScreenState extends ConsumerState { return Container( width: double.infinity, - height: isDesktop ? 460 : null, + height: isDesktop ? 485 : null, padding: const EdgeInsets.all(AppSizes.p24), decoration: _cardDecoration(), child: Column( @@ -3826,7 +4034,7 @@ class _ReportsScreenState extends ConsumerState { }, ), - const SizedBox(height: AppSizes.p24), + const SizedBox(height: 14), // ── Chart ────────────────────────────────────────────────────────── yearlyAsync.when( @@ -3883,7 +4091,7 @@ class _ReportsScreenState extends ConsumerState { : (data.length * 150.0); final Widget chartWidget = SizedBox( width: chartWidth, - height: 230, + height: 200, child: BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, @@ -4037,7 +4245,7 @@ class _ReportsScreenState extends ConsumerState { ); }, ), - const SizedBox(height: 24), + const SizedBox(height: 14), // ── Legend (dynamic based on selected statuses) ───────────────────── Consumer( builder: (context, ref, _) { diff --git a/frontend/lib/ui/screens/settings/security_screen.dart b/frontend/lib/ui/screens/settings/security_screen.dart new file mode 100644 index 0000000..bc489c3 --- /dev/null +++ b/frontend/lib/ui/screens/settings/security_screen.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/constants/app_colors.dart'; +import '../../../core/constants/app_sizes.dart'; +import '../../../core/services/haptic_service.dart'; +import '../../../../domain/entities/user_model.dart'; +import 'widgets/security_section.dart'; + +class SecurityScreen extends ConsumerWidget { + final UserModel user; + final Future Function(BuildContext, WidgetRef) onDeactivateAccount; + + const SecurityScreen({ + super.key, + required this.user, + required this.onDeactivateAccount, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + Widget buildCard({required Widget child}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSizes.p24), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite, + borderRadius: BorderRadius.circular(AppSizes.radiusLarge), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + border: Border.all( + color: isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + ), + child: child, + ); + } + + Widget buildDangerCard({required Widget child}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSizes.p24), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite, + borderRadius: BorderRadius.circular(AppSizes.radiusLarge), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + border: Border.all( + color: AppColors.error.withValues(alpha: isDark ? 0.45 : 0.35), + width: 1.5, + ), + ), + child: child, + ); + } + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + title: const Text( + 'Security', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + HapticService.light(); + Navigator.of(context).pop(); + }, + ), + centerTitle: false, + elevation: 0, + backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.white, + foregroundColor: theme.colorScheme.onSurface, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Divider( + height: 1, + color: theme.dividerColor.withValues(alpha: 0.1), + ), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: AppSizes.p24, + vertical: AppSizes.p32, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700), + child: buildCard( + child: SecuritySection( + user: user, + onDeactivateAccount: onDeactivateAccount, + buildCard: buildCard, + buildDangerCard: buildDangerCard, + isDark: isDark, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index 80e7e36..38e6158 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -25,7 +25,7 @@ import '../../providers/system_settings_provider.dart'; import '../../providers/theme_provider.dart'; import '../../providers/preferences_provider.dart'; import '../../../core/services/haptic_service.dart'; -import 'widgets/security_section.dart'; +import 'security_screen.dart'; class TitleCaseTextInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { @@ -96,14 +96,12 @@ class _SettingsScreenState extends ConsumerState { bool _isAcademicYearExpanded = false; bool _isAutoEnrollExpanded = false; bool _isAppearanceExpanded = false; - bool _isSecurityExpanded = false; void _collapseAllSections() { _isProfileExpanded = false; _isAcademicYearExpanded = false; _isAutoEnrollExpanded = false; _isAppearanceExpanded = false; - _isSecurityExpanded = false; } void _refreshAllSettingsData() { @@ -475,141 +473,24 @@ class _SettingsScreenState extends ConsumerState { }, ); - if (step1 != true || !context.mounted) return; + if (step1 != true) return; - // Step 2: Type-to-confirm - final confirmController = TextEditingController(); - final step2 = await showDialog( + // Brief delay to allow Step 1 dialog transition to complete + await Future.delayed(const Duration(milliseconds: 150)); + if (!context.mounted) return; + + // Step 2: Type DEACTIVATE confirmation dialog + final confirmed = await showDialog( context: context, barrierDismissible: false, - builder: (ctx) { - return StatefulBuilder( - builder: (ctx, setState) { - 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.lock_outline, color: AppColors.error, size: 26), - SizedBox(width: 8), - Expanded( - child: Text( - 'Final Confirmation', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.error, - ), - ), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'To confirm, type DEACTIVATE in the field below:', - style: TextStyle(fontWeight: FontWeight.w500), - ), - const SizedBox(height: 12), - TextField( - controller: confirmController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Type DEACTIVATE', - border: OutlineInputBorder(), - isDense: true, - ), - onChanged: (_) => setState(() {}), - ), - ], - ), - actions: [ - if (isNarrow) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.error, - foregroundColor: Colors.white, - ), - onPressed: confirmController.text.trim() == 'DEACTIVATE' - ? () => Navigator.pop(ctx, true) - : null, - child: const Text('DEACTIVATE MY ACCOUNT'), - ), - 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: confirmController.text.trim() == 'DEACTIVATE' - ? () => Navigator.pop(ctx, true) - : null, - child: const Text('DEACTIVATE'), - ), - ], - ), - ], - ); - }, - ); - }, - ); - confirmController.dispose(); - - if (step2 != true || !context.mounted) return; - - // Step 3: Final Email Notification Warning - final step3 = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Confirm Deactivation'), - content: const Text( - 'Are you absolutely sure?\n\n' - 'An email notification will be sent to confirm that your account has been deactivated. You will be logged out immediately.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('CANCEL', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.error, - foregroundColor: Colors.white, - ), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('YES, DEACTIVATE'), - ), - ], - ); - }, + builder: (ctx) => const _DeactivateConfirmationDialog(), ); - if (step3 != true || !context.mounted) return; + if (confirmed != true) return; + + // Brief delay to allow dialog pop animation to finish before logout invalidates tree + await Future.delayed(const Duration(milliseconds: 200)); + if (!context.mounted) return; try { await ref.read(authProvider.notifier).requestSelfDeactivation(); @@ -1323,7 +1204,7 @@ class _SettingsScreenState extends ConsumerState { AppColors .primaryGreen, foregroundColor: - isDark ? Colors.white : Colors.black, + isDark ? Colors.white : Colors.white, padding: const EdgeInsets.symmetric( vertical: 14, @@ -1889,17 +1770,69 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: AppSizes.p24), - // ── Security Card (Collapsible) ─────────── - _buildCollapsibleCard( - title: 'Security', - isExpanded: _isSecurityExpanded, - onToggle: () => setState(() => _isSecurityExpanded = !_isSecurityExpanded), - child: SecuritySection( - user: user, - onDeactivateAccount: _handleDeactivateAccount, - buildCard: _buildCard, - buildDangerCard: _buildDangerCard, - isDark: isDark, + // ── Security Screen Navigation Card ───────────────────── + _buildCard( + child: InkWell( + borderRadius: BorderRadius.circular(AppSizes.radiusLarge), + onTap: () { + HapticService.light(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SecurityScreen( + user: user, + onDeactivateAccount: _handleDeactivateAccount, + ), + ), + ); + }, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.security_rounded, + color: AppColors.primaryGreen, + size: 24, + ), + ), + const SizedBox(width: AppSizes.p16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Security', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + 'Manage password, active login sessions, and account status', + style: TextStyle( + fontSize: 13, + color: isDark + ? AppColors.darkTextSecondary + : Colors.grey.shade600, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + color: isDark + ? AppColors.darkTextSecondary + : Colors.grey.shade400, + ), + ], + ), ), ), @@ -2030,33 +1963,6 @@ class _SettingsScreenState extends ConsumerState { ); } - 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.darkSurfaceCard : AppColors.surfaceWhite, - borderRadius: BorderRadius.circular(AppSizes.radiusLarge), - boxShadow: [ - BoxShadow( - color: isDark - ? Colors.black.withValues(alpha: 0.25) - : Colors.black.withValues(alpha: 0.03), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - border: Border.all( - color: AppColors.error.withValues(alpha: isDark ? 0.45 : 0.35), - width: 1.5, - ), - ), - child: child, - ); - } Widget _buildCollapsibleCard({ required String title, @@ -2713,3 +2619,120 @@ class _EditScheduleDatesDialogState } } +class _DeactivateConfirmationDialog extends StatefulWidget { + const _DeactivateConfirmationDialog(); + + @override + State<_DeactivateConfirmationDialog> createState() => + _DeactivateConfirmationDialogState(); +} + +class _DeactivateConfirmationDialogState + extends State<_DeactivateConfirmationDialog> { + late final TextEditingController _confirmController; + + @override + void initState() { + super.initState(); + _confirmController = TextEditingController(); + } + + @override + void dispose() { + _confirmController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isNarrow = MediaQuery.of(context).size.width < 600 || + Theme.of(context).platform == TargetPlatform.android; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.lock_outline, color: AppColors.error, size: 26), + SizedBox(width: 8), + Expanded( + child: Text( + 'Final Confirmation', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Deactivating your account will immediately revoke your access and log you out. An email notification will be sent.\n\n' + 'To confirm, type DEACTIVATE in the field below:', + style: TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 12), + TextField( + controller: _confirmController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Type DEACTIVATE', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ], + ), + actions: [ + if (isNarrow) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: Colors.white, + ), + onPressed: _confirmController.text.trim() == 'DEACTIVATE' + ? () => Navigator.pop(context, true) + : null, + child: const Text('DEACTIVATE MY ACCOUNT'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('CANCEL'), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context, 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: _confirmController.text.trim() == 'DEACTIVATE' + ? () => Navigator.pop(context, true) + : null, + child: const Text('DEACTIVATE'), + ), + ], + ), + ], + ); + } +} + diff --git a/frontend/lib/ui/screens/settings/widgets/security_section.dart b/frontend/lib/ui/screens/settings/widgets/security_section.dart index 069b600..9ab6aff 100644 --- a/frontend/lib/ui/screens/settings/widgets/security_section.dart +++ b/frontend/lib/ui/screens/settings/widgets/security_section.dart @@ -28,134 +28,108 @@ class SecuritySection extends ConsumerStatefulWidget { } class _SecuritySectionState extends ConsumerState { - bool _isDevicesExpanded = false; - @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Logged-in Devices Card (Collapsible) ─────────── - widget.buildCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () { - setState(() => _isDevicesExpanded = !_isDevicesExpanded); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( + // ── Logged-in Devices Section ─────────── + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.devices_outlined, + color: AppColors.primaryGreen, + size: 22, + ), + const SizedBox(width: AppSizes.p12), + Expanded( + child: Row( + children: [ + const Text( + 'Logged-in Devices', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + _buildDeviceCountBadge(), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Manage devices where your account is currently active.', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 14), + _buildSessionsList(context, ref), + ], + ), + const SizedBox(height: AppSizes.p24), + Divider( + color: Theme.of(context).dividerColor.withValues(alpha: 0.15), + ), + const SizedBox(height: AppSizes.p20), + + // ── Change Password Card ─────────── + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => ChangePasswordModal.show(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: widget.isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.grey.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: widget.isDark ? AppColors.darkBorder : Colors.grey.shade200, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.lock_outline, color: AppColors.primaryGreen, size: 20), + ), + const SizedBox(width: AppSizes.p12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.devices_outlined, - color: AppColors.primaryGreen, - size: 24, + const Text( + 'Change Password', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), ), - const SizedBox(width: AppSizes.p12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text( - 'Logged-in Devices', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - _buildDeviceCountBadge(), - ], - ), - const SizedBox(height: 2), - Text( - 'Manage devices where your account is currently active.', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - AnimatedRotation( - turns: _isDevicesExpanded ? 0.5 : 0.0, - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut, - child: Icon( - Icons.keyboard_arrow_down_rounded, - color: widget.isDark - ? AppColors.darkTextSecondary - : Colors.grey.shade600, - size: 24, + const SizedBox(height: 2), + Text( + 'Set a new password for your account', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), ), ), ], ), ), - ), + Icon(Icons.chevron_right_rounded, color: widget.isDark ? AppColors.darkTextSecondary : Colors.grey), + ], ), - AnimatedCrossFade( - firstChild: const SizedBox(width: double.infinity, height: 0), - secondChild: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 14), - Divider( - color: Theme.of(context).dividerColor.withValues(alpha: 0.15), - ), - const SizedBox(height: 10), - _buildSessionsList(context, ref), - ], - ), - crossFadeState: _isDevicesExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 250), - sizeCurve: Curves.easeInOut, - ), - ], - ), - ), - const SizedBox(height: AppSizes.p24), - - // ── Change Password Card ─────────── - widget.buildCard( - child: InkWell( - borderRadius: BorderRadius.circular(AppSizes.radiusLarge), - onTap: () => ChangePasswordModal.show(context), - child: Row( - children: [ - const Icon(Icons.lock_outline, color: AppColors.primaryGreen, size: 24), - const SizedBox(width: AppSizes.p12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Change Password', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 2), - Text( - 'Set a new password for your account', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - Icon(Icons.chevron_right_rounded, color: widget.isDark ? AppColors.darkTextSecondary : Colors.grey), - ], ), ), ), @@ -258,6 +232,7 @@ class _SecuritySectionState extends ConsumerState { Widget _buildSessionsList(BuildContext context, WidgetRef ref) { final sessionsAsync = ref.watch(sessionsProvider); + final isNarrow = MediaQuery.of(context).size.width < 500; return sessionsAsync.when( data: (sessions) { @@ -272,14 +247,23 @@ class _SecuritySectionState extends ConsumerState { children: [ ...sessions.map((session) => _buildSessionItem(context, ref, session)), if (sessions.length > 1) ...[ - const SizedBox(height: 16), + const SizedBox(height: 12), Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - style: TextButton.styleFrom(foregroundColor: AppColors.error), + alignment: isNarrow ? Alignment.centerLeft : Alignment.centerRight, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: BorderSide(color: AppColors.error.withValues(alpha: 0.35)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), onPressed: () => _handleRevokeAllOther(context, ref), - icon: const Icon(Icons.logout_rounded, size: 18), - label: const Text('Sign out of all other devices'), + icon: const Icon(Icons.logout_rounded, size: 16), + label: const Text( + 'Sign out of all other devices', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), ), ), ] @@ -317,7 +301,9 @@ class _SecuritySectionState extends ConsumerState { : 'Windows'; final rawDevice = (session['device_name']?.toString() ?? '').trim(); - final deviceName = (rawDevice.isEmpty || rawDevice.toLowerCase() == 'unknown device' || rawDevice.toLowerCase() == 'device') + final deviceName = (rawDevice.isEmpty || + rawDevice.toLowerCase() == 'unknown device' || + rawDevice.toLowerCase() == 'device') ? (platformName == 'Windows' ? 'Windows PC' : platformName == 'Android' @@ -325,7 +311,7 @@ class _SecuritySectionState extends ConsumerState { : '$platformName Device') : rawDevice; - final ipAddress = session['ip_address'] ?? ''; + final ipAddress = (session['ip_address'] ?? '').toString().trim(); final lastSeen = session['last_seen_at'] != null ? DateFormat('MMM d, y, h:mm a').format(DateTime.parse(session['last_seen_at']).toLocal()) : 'Recently'; @@ -341,78 +327,134 @@ class _SecuritySectionState extends ConsumerState { platformIcon = Icons.laptop_mac_rounded; } - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: widget.isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.grey.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all( color: isCurrent - ? AppColors.primaryGreen.withValues(alpha: 0.12) - : Colors.grey.withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - child: Icon( - platformIcon, - color: isCurrent ? AppColors.primaryGreen : Colors.grey.shade600, - size: 20, + ? AppColors.primaryGreen.withValues(alpha: 0.35) + : (widget.isDark ? AppColors.darkBorder : Colors.grey.shade200), ), ), - title: Row( - children: [ - Expanded( - child: Text( - deviceName, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (isCurrent) - Container( - margin: const EdgeInsets.only(left: 8), - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: AppColors.primaryGreen.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'Current', - style: TextStyle( - color: AppColors.primaryGreen, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - subtitle: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 2), - Text( - '$platformName ${ipAddress.isNotEmpty ? "• $ipAddress" : ""}', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: isCurrent + ? AppColors.primaryGreen.withValues(alpha: 0.12) + : Colors.grey.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + platformIcon, + color: isCurrent ? AppColors.primaryGreen : Colors.grey.shade600, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + deviceName, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isCurrent) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Current', + style: TextStyle( + color: AppColors.primaryGreen, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + ), + if (!isCurrent) + IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: const Icon(Icons.logout_rounded, color: AppColors.error, size: 18), + tooltip: 'Sign out of this device', + onPressed: () => _handleRevokeSession(context, ref, session['id'].toString()), + ), + ], ), - Text( - 'Last seen: $lastSeen', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(left: 35), + child: Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + platformName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + if (ipAddress.isNotEmpty) ...[ + Text( + '•', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.35), + ), + ), + Text( + ipAddress, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + Text( + '•', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.35), + ), + ), + Text( + 'Active: $lastSeen', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], ), ), ], ), - trailing: !isCurrent - ? IconButton( - icon: const Icon(Icons.logout_rounded, color: AppColors.error, size: 20), - tooltip: 'Sign out of this device', - onPressed: () => _handleRevokeSession(context, ref, session['id'].toString()), - ) - : null, ); } diff --git a/frontend/lib/ui/shared/modals/view_activity_modal.dart b/frontend/lib/ui/shared/modals/view_activity_modal.dart index d4d297c..c7c69e7 100644 --- a/frontend/lib/ui/shared/modals/view_activity_modal.dart +++ b/frontend/lib/ui/shared/modals/view_activity_modal.dart @@ -181,7 +181,7 @@ class ViewActivityModal extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Performed By', + 'Action by', style: TextStyle( fontSize: 12, color: isDark ? AppColors.darkTextSecondary : Colors.grey,