Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ac37b003 | ||
|
|
e0cf59ef99 | ||
|
|
79a1dd5bb2 | ||
|
|
b7040bff4d | ||
|
|
c993937314 | ||
|
|
bd1a712b40 | ||
|
|
418218fa94 | ||
|
|
9c341bab54 | ||
|
|
1eb2e4527c | ||
|
|
d19834450d | ||
|
|
490fa4bc06 | ||
|
|
7dea17153d | ||
|
|
5537212ad4 | ||
|
|
bd75896906 | ||
|
|
7c778b40e2 | ||
|
|
6c93553dd0 | ||
|
|
e60f525929 | ||
|
|
1abbe7b64f | ||
|
|
a17c779d54 | ||
|
|
d85901e987 | ||
|
|
27e812c711 | ||
|
|
c73a927c0c | ||
|
|
45a53183ee | ||
|
|
502b2ea9f1 | ||
|
|
2416a598a1 | ||
|
|
2527cccc9f | ||
|
|
5b0f1cedac |
No files matched your search
@@ -1094,6 +1094,69 @@ Files backup/restore - mirror mode or zip -->
|
||||
## Priority 10: Adaptive Tablet / Desktop Navigation
|
||||
- [x] Implement adaptive Navigation Rail for tablet viewports and maintain persistent sidebar on desktop. -->
|
||||
|
||||
|
||||
# Settings screen
|
||||
- [x] Academic & graduation section: save cutoff date only shows when cutoff date is changed; added 'X' (clear) buttons to start and end date pickers in edit schedule dates dialog.
|
||||
- [x] Remove GitHub repository button from Developer & Support section.
|
||||
- [x] Report an Issue modal: created dedicated modal sending to lead developer (`crischarlesgarcia345@gmail.com`) with title, description, category (bug, feature, question), urgency (high, medium, low), attachments, and send button opening email client ready to send (or clipboard fallback).
|
||||
- [x] Fixed system theme toggle bug: Light and Dark mode options are now always clickable and correctly deactivate system theme when chosen.
|
||||
- [x] Security screen active devices: only displays devices with "Remember Me" enabled; added Load More / Load Less button when more than 3 devices are present.
|
||||
- [x] IP address location: added network / GeoIP location resolution next to IP addresses on both the Security screen and the Audit Trail / History screen (Sessions tab).
|
||||
|
||||
# DASHBOARD SCREEN
|
||||
- [x] Fixed admin dashboard flash on teacher login: converted dashboard providers to autoDispose watching authProvider and actively invalidated caches on login/switch.
|
||||
- [x] Removed excess top spacing above "Dashboard Overview" on Windows desktop app (aligned search, notifications, and profile menu to header row and reduced top inset).
|
||||
- [x] Removed "Top Students by Documents" and "Needs Attention" cards from the bottom of the dashboard.
|
||||
- [x] Fixed "4 missing" empty list bug: student count and student modal list are derived synchronously and directly from matching students.
|
||||
- [x] Implemented dynamic missing document requirement chips (Approach A): dynamically evaluates and sorts missing counts across all enabled mandatory requirements (SF9, SF10, PSA, etc.) descending by missing frequency.
|
||||
- [x] Needs Attention section fix: created dedicated endpoint (`/api/dashboard/needs-attention`) filtering strictly active enrolled students (`s.status = 'Enrolled'`) and dedicated `dashboardNeedsAttentionProvider` to isolate alerts from other KPI chart metrics.
|
||||
|
||||
# STUDENT SCREEN
|
||||
- enrolled tab:
|
||||
- [x] when no students it has clear filter button, remove it, then in the student list table, add add button at the bottom to add student and nagivation for all students tab to enroll a students
|
||||
- [x] when I update the student status to inactive, dropped, transferred, graduated stays on this tab, add in filter for inactive, dropped, transferred, graduation
|
||||
- [x] rename the tab as the active academic year
|
||||
- student profile:
|
||||
- [x] prevent or remove the buttons for not enrolled students upload docs
|
||||
- [x] history add load more and load less, it only show last 10 entries, and resets to default when not active tab
|
||||
- [x] remove the verified badge for files on records
|
||||
- [x] add button to navigate to document screen inside of their folder
|
||||
|
||||
|
||||
# Pagination on windows app
|
||||
- [x] use white font for dark theme and black for white theme for previous, next, and pagination numbers and add background like the numbers
|
||||
|
||||
# ARCHIVE SCREEN
|
||||
- [x] Removed Recycle Bin, delete, restore, upload new version, and copy buttons from the Archive Screen (including folder context menus, document context menus, document preview modal, and bulk operations bar).
|
||||
|
||||
# REPORTS SCREEN
|
||||
- [x] compliance tab student document compliance search bar not visible make it visible for all theme
|
||||
- [x] make the space consume, make the analytics prominently visible with minimal scrolling
|
||||
- [x] fix layout overflow in compliance tab yearly comparison chart on Windows app (adjusted card height to 360, dynamic chart height calculation, and wrapped scrollable chart in Expanded on desktop)
|
||||
|
||||
# STUDENT SCREEN
|
||||
- [x] fix students tab showing all students on re-click: preserved active academic year in studentQueryProvider reset and fail-safe fallback in studentPageProvider for enrolled tab
|
||||
|
||||
# DASHBOARD SCREEN
|
||||
- [x] move the recent activities to the last
|
||||
- [x] use user friendly labels on needs attention, like 3 students missing SF10, 4 students missing SF9, etc if the count is not more than 1 use student a singular form
|
||||
- [x] do not use many colors in needs attention and icons make it professional
|
||||
- [x] fix that the needs attention when fetching from websocket the UI shows a loading icon
|
||||
|
||||
# STUDENT SCREEN
|
||||
- [x] in active year tab, it has add student and enroll existing student, only appear when there is no students enrolled
|
||||
- [x] in windows app the "grade and section" and document completion counter is not aligned
|
||||
|
||||
# ACTIVITY HISTORY SCREEN
|
||||
- [x] it has too many x button after a search it has search bar x button but not working search bar and reset button, and the search icon that animates to x and clears the search
|
||||
|
||||
# WINDOWS RUNNER
|
||||
- [x] prevent multiple app windows from opening (single-instance mutex and focus existing window)
|
||||
|
||||
# ENROLLMENTS & STUDENT SCREEN
|
||||
- [x] sort enrollments chronologically ascending by school year and grade level across student profile, edit student, edit enrollment, and OCR review
|
||||
- [x] progression and regression validation: prevent grade skipping and downgrading across academic year chronology
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'api_constants.dart';
|
||||
|
||||
/// Discovers the TIS RMS server on the local network (LAN) and manages seamless fallback to Cloud Tunnel.
|
||||
/// 1. Verifies physical network interfaces, filtering out virtual/bridge/loopback adapters.
|
||||
/// 2. Probes local subnet hosts with bounded concurrency (pools of 25) using lightweight raw TCP checks first.
|
||||
/// 3. Confirms identity via HTTP X-TIS-RMS response header.
|
||||
/// 4. Prioritizes LAN for speed and offline resilience, seamlessly falling back to Cloud Tunnel (https://tis-rms.cc.cd/api).
|
||||
/// 1. Instant UDP discovery beacon (<50ms) to detect local server on Wi-Fi/LAN automatically.
|
||||
/// 2. Verifies physical network interfaces, filtering out virtual/bridge/loopback adapters.
|
||||
/// 3. Probes local subnet hosts with bounded concurrency (pools of 25) using lightweight raw TCP checks first.
|
||||
/// 4. Confirms identity via HTTP X-TIS-RMS response header.
|
||||
/// 5. Prioritizes LAN for speed and offline resilience, seamlessly falling back to Cloud Tunnel (https://tis-rms.cc.cd/api).
|
||||
class ServerDiscoveryService {
|
||||
static const int _port = 18484;
|
||||
static const int _discoveryPort = 18485;
|
||||
static const String _prefsKey = 'server_url';
|
||||
static const Duration _socketTimeout = Duration(milliseconds: 300);
|
||||
static const Duration _pingTimeout = Duration(seconds: 2);
|
||||
static const Duration _udpTimeout = Duration(milliseconds: 350);
|
||||
|
||||
// ─── Saved URL ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -204,18 +209,142 @@ class ServerDiscoveryService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── UDP Discovery Beacon (Instant <50ms auto-detection) ────────────────────
|
||||
|
||||
/// Broadcasts a lightweight UDP probe ('TIS_RMS_DISCOVER') on port 18485.
|
||||
/// If the TIS RMS backend / Server Manager is running on the LAN, it replies in <50ms.
|
||||
/// Returns the discovered server URL (e.g. "http://192.168.1.50:18484") or null.
|
||||
static Future<String?> quickUdpBeacon({
|
||||
Duration timeout = _udpTimeout,
|
||||
}) async {
|
||||
RawDatagramSocket? socket;
|
||||
Timer? timer;
|
||||
final completer = Completer<String?>();
|
||||
|
||||
try {
|
||||
socket = await RawDatagramSocket.bind(
|
||||
InternetAddress.anyIPv4,
|
||||
0,
|
||||
reuseAddress: true,
|
||||
);
|
||||
socket.broadcastEnabled = true;
|
||||
|
||||
final message = utf8.encode('TIS_RMS_DISCOVER');
|
||||
|
||||
// 1. Broadcast to standard global local subnet broadcast address
|
||||
try {
|
||||
socket.send(message, InternetAddress('255.255.255.255'), _discoveryPort);
|
||||
} catch (_) {}
|
||||
|
||||
// 2. Also broadcast explicitly to all active physical subnet broadcast IPs (e.g. 192.168.1.255)
|
||||
try {
|
||||
final prefixes = await getSubnetPrefixes();
|
||||
for (final prefix in prefixes) {
|
||||
final broadcastIp = '${prefix}255';
|
||||
try {
|
||||
socket.send(message, InternetAddress(broadcastIp), _discoveryPort);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Listen for UDP response from server
|
||||
socket.listen((RawSocketEvent event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final datagram = socket?.receive();
|
||||
if (datagram != null && !completer.isCompleted) {
|
||||
try {
|
||||
final text = utf8.decode(datagram.data).trim();
|
||||
if (text.contains('TIS_RMS_SERVER')) {
|
||||
int port = _port;
|
||||
try {
|
||||
final Map<String, dynamic> data = jsonDecode(text);
|
||||
if (data['port'] != null) {
|
||||
port = int.tryParse(data['port'].toString()) ?? _port;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final senderIp = datagram.address.address;
|
||||
final candidateUrl = 'http://$senderIp:$port';
|
||||
|
||||
timer?.cancel();
|
||||
completer.complete(candidateUrl);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
timer = Timer(timeout, () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
final result = await completer.future;
|
||||
if (result != null) {
|
||||
// Fast HTTP verify check (500ms) to ensure API server is fully ready
|
||||
final verified = await ping(result, timeout: const Duration(milliseconds: 500));
|
||||
if (verified) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
timer?.cancel();
|
||||
socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual LAN-Only Scanner (Never falls back to Cloud Tunnel) ───────────
|
||||
|
||||
/// Scans specifically for local network endpoints. Bypasses SharedPreferences
|
||||
/// and NEVER returns Cloud Tunnel. Used when user explicitly clicks "Scan Local Network".
|
||||
static Future<String?> scanLanOnly({
|
||||
void Function(String message)? onProgress,
|
||||
}) async {
|
||||
// 1. Fast UDP Discovery Beacon (<400ms)
|
||||
onProgress?.call('Broadcasting LAN discovery beacon…');
|
||||
final udpFound = await quickUdpBeacon(timeout: const Duration(milliseconds: 400));
|
||||
if (udpFound != null) {
|
||||
await save(udpFound);
|
||||
ApiConstants.setBaseUrl(udpFound);
|
||||
return udpFound;
|
||||
}
|
||||
|
||||
// 2. Physical Subnet Probing (Fallback if Wi-Fi router blocks UDP broadcast)
|
||||
onProgress?.call('Scanning local network IP range…');
|
||||
final prefixes = await getSubnetPrefixes();
|
||||
if (prefixes.isNotEmpty) {
|
||||
final subnetFound = await discover(
|
||||
onProgress: (subnet, scanned, total) {
|
||||
onProgress?.call('Scanning LAN ${subnet}x … ($scanned/$total)');
|
||||
},
|
||||
);
|
||||
if (subnetFound != null) {
|
||||
await save(subnetFound);
|
||||
ApiConstants.setBaseUrl(subnetFound);
|
||||
return subnetFound;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Master Resolution: LAN First, Then Seamless Tunnel Fallback ──────────
|
||||
|
||||
/// Resolves the optimal server endpoint:
|
||||
/// 1. Fast Localhost check (for single-machine setups).
|
||||
/// 2. Fast check of previously working saved LAN URL.
|
||||
/// 3. Fast physical LAN discovery (bounded ~2.5s scan).
|
||||
/// 4. Previously saved Tunnel URL or Cloud Tunnel fallback (https://tis-rms.cc.cd/api).
|
||||
/// 5. VPS & Localhost fallbacks.
|
||||
/// 2. Instant UDP LAN discovery beacon (instant <50ms auto-connect on Wi-Fi).
|
||||
/// 3. Fast check of previously working saved LAN server.
|
||||
/// 4. Subnet port scan fallback.
|
||||
/// 5. Cloud Tunnel fallback (https://tis-rms.cc.cd/api).
|
||||
/// 6. VPS fallback.
|
||||
static Future<String?> resolveServerWithFallback({
|
||||
void Function(String message)? onProgress,
|
||||
}) async {
|
||||
// 1. Fast Localhost Check (< 400ms)
|
||||
// 1. Fast Localhost Check (< 350ms)
|
||||
onProgress?.call('Checking local host (127.0.0.1)…');
|
||||
final localhostAlive = await ping(
|
||||
ApiConstants.localhostUrl,
|
||||
@@ -227,19 +356,29 @@ class ServerDiscoveryService {
|
||||
return ApiConstants.localhostUrl;
|
||||
}
|
||||
|
||||
// 2. Fast check of previously working saved server (instant reconnect)
|
||||
// 2. Instant UDP LAN Discovery Beacon (< 350ms)
|
||||
// Instantly finds the local Server Manager backend on school Wi-Fi without manual scanning!
|
||||
onProgress?.call('Probing local network (LAN)…');
|
||||
final udpFound = await quickUdpBeacon(timeout: const Duration(milliseconds: 350));
|
||||
if (udpFound != null) {
|
||||
await save(udpFound);
|
||||
ApiConstants.setBaseUrl(udpFound);
|
||||
return udpFound;
|
||||
}
|
||||
|
||||
// 3. Fast check of previously working saved server (if it's a LAN address)
|
||||
final saved = await getSaved();
|
||||
if (saved != null && saved.isNotEmpty) {
|
||||
onProgress?.call('Checking saved server connection…');
|
||||
final alive = await ping(saved, timeout: const Duration(seconds: 1));
|
||||
if (saved != null && saved.isNotEmpty && _isLanUrl(saved)) {
|
||||
onProgress?.call('Checking saved LAN server…');
|
||||
final alive = await ping(saved, timeout: const Duration(milliseconds: 700));
|
||||
if (alive) {
|
||||
ApiConstants.setBaseUrl(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan physical Local Network (LAN)
|
||||
onProgress?.call('Scanning local network (LAN)…');
|
||||
// 4. Physical Subnet Scan (if UDP was blocked by router)
|
||||
onProgress?.call('Scanning local network subnets…');
|
||||
final prefixes = await getSubnetPrefixes();
|
||||
if (prefixes.isNotEmpty) {
|
||||
final found = await discover(
|
||||
@@ -254,7 +393,7 @@ class ServerDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cloudflare Tunnel Domain Fallback (https://tis-rms.cc.cd/api)
|
||||
// 5. Cloudflare Tunnel Domain Fallback (https://tis-rms.cc.cd/api)
|
||||
onProgress?.call('Connecting to Cloud Tunnel…');
|
||||
final tunnelAlive = await ping(
|
||||
ApiConstants.tunnelUrl,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// Helper utility to determine or resolve location for IP addresses.
|
||||
class IpLocationHelper {
|
||||
// In-memory cache to avoid repeated network calls for the same public IP
|
||||
static final Map<String, String> _cache = {};
|
||||
|
||||
/// Fast synchronous resolution for local/private network addresses
|
||||
static String? getFastLocation(String? ip) {
|
||||
if (ip == null || ip.trim().isEmpty) return null;
|
||||
final cleanIp = ip.trim().replaceAll('::ffff:', '');
|
||||
|
||||
if (cleanIp == '127.0.0.1' || cleanIp == '::1' || cleanIp.toLowerCase() == 'localhost') {
|
||||
return 'Local Machine';
|
||||
}
|
||||
|
||||
final lowerIp = cleanIp.toLowerCase();
|
||||
|
||||
// Check private RFC1918, RFC4193, & link-local ranges (both IPv4 and IPv6)
|
||||
if (lowerIp.startsWith('192.168.') ||
|
||||
lowerIp.startsWith('10.') ||
|
||||
lowerIp.startsWith('169.254.') ||
|
||||
lowerIp.startsWith('fe80:') ||
|
||||
lowerIp.startsWith('fc') ||
|
||||
lowerIp.startsWith('fd')) {
|
||||
return 'Local Network (LAN)';
|
||||
}
|
||||
|
||||
if (lowerIp.startsWith('172.')) {
|
||||
final parts = lowerIp.split('.');
|
||||
if (parts.length >= 2) {
|
||||
final second = int.tryParse(parts[1]);
|
||||
if (second != null && second >= 16 && second <= 31) {
|
||||
return 'Local Network (LAN)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _cache[cleanIp];
|
||||
}
|
||||
|
||||
/// Resolve location asynchronously. If private/local, returns immediately.
|
||||
/// If public IP, queries free ip-api.com and caches the result.
|
||||
static Future<String> resolveLocation(String? ip) async {
|
||||
if (ip == null || ip.trim().isEmpty) return 'Unknown Location';
|
||||
final cleanIp = ip.trim().replaceAll('::ffff:', '');
|
||||
|
||||
final fast = getFastLocation(cleanIp);
|
||||
if (fast != null) return fast;
|
||||
|
||||
if (_cache.containsKey(cleanIp)) {
|
||||
return _cache[cleanIp]!;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 3);
|
||||
final request = await client.getUrl(
|
||||
Uri.parse('http://ip-api.com/json/$cleanIp?fields=status,city,regionName,country'),
|
||||
);
|
||||
final response = await request.close().timeout(const Duration(seconds: 3));
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
if (data['status'] == 'success') {
|
||||
final city = data['city']?.toString() ?? '';
|
||||
final country = data['country']?.toString() ?? '';
|
||||
final parts = [city, country].where((s) => s.isNotEmpty).toList();
|
||||
if (parts.isNotEmpty) {
|
||||
final loc = parts.join(', ');
|
||||
_cache[cleanIp] = loc;
|
||||
return loc;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline, network failure or timeout
|
||||
}
|
||||
|
||||
_cache[cleanIp] = 'Public Network';
|
||||
return 'Public Network';
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,7 @@ class StatusDistributionEntry {
|
||||
|
||||
class StudentDocCount {
|
||||
final int id;
|
||||
final String? lrn;
|
||||
final String name;
|
||||
final int docCount;
|
||||
final int uploadedCount;
|
||||
@@ -242,8 +243,12 @@ class StudentDocCount {
|
||||
final int? gradeLevel;
|
||||
final String category;
|
||||
|
||||
String get listDisplayName => name;
|
||||
String get firstName => name.trim().split(' ').first;
|
||||
|
||||
const StudentDocCount({
|
||||
required this.id,
|
||||
this.lrn,
|
||||
required this.name,
|
||||
required this.docCount,
|
||||
this.uploadedCount = 0,
|
||||
@@ -271,6 +276,7 @@ class StudentDocCount {
|
||||
|
||||
return StudentDocCount(
|
||||
id: (j['id'] as num?)?.toInt() ?? 0,
|
||||
lrn: j['lrn'] as String?,
|
||||
name: j['name'] as String? ?? '',
|
||||
docCount: uploaded,
|
||||
uploadedCount: uploaded,
|
||||
@@ -284,6 +290,55 @@ class StudentDocCount {
|
||||
}
|
||||
}
|
||||
|
||||
class MissingRequirementGroup {
|
||||
final String name;
|
||||
final String category;
|
||||
final int count;
|
||||
final List<StudentDocCount> students;
|
||||
|
||||
const MissingRequirementGroup({
|
||||
required this.name,
|
||||
this.category = 'JHS',
|
||||
required this.count,
|
||||
this.students = const [],
|
||||
});
|
||||
|
||||
factory MissingRequirementGroup.fromJson(Map<String, dynamic> j) {
|
||||
return MissingRequirementGroup(
|
||||
name: j['name'] as String? ?? '',
|
||||
category: j['category'] as String? ?? 'JHS',
|
||||
count: (j['count'] as num?)?.toInt() ?? 0,
|
||||
students: (j['students'] as List<dynamic>? ?? [])
|
||||
.map((e) => StudentDocCount.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NeedsAttentionData {
|
||||
final int totalAlerts;
|
||||
final List<MissingRequirementGroup> missingRequirements;
|
||||
final List<StudentDocCount> urgentStudents;
|
||||
|
||||
const NeedsAttentionData({
|
||||
required this.totalAlerts,
|
||||
this.missingRequirements = const [],
|
||||
this.urgentStudents = const [],
|
||||
});
|
||||
|
||||
factory NeedsAttentionData.fromJson(Map<String, dynamic> j) {
|
||||
return NeedsAttentionData(
|
||||
totalAlerts: (j['totalAlerts'] as num?)?.toInt() ?? 0,
|
||||
missingRequirements: (j['missingRequirements'] as List<dynamic>? ?? [])
|
||||
.map((e) => MissingRequirementGroup.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
urgentStudents: (j['urgentStudents'] as List<dynamic>? ?? [])
|
||||
.map((e) => StudentDocCount.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocTypeEntry {
|
||||
final String name;
|
||||
final int count;
|
||||
@@ -376,6 +431,7 @@ class DashboardKpis {
|
||||
final List<StatusDistributionEntry> statusDistribution;
|
||||
final List<StudentDocCount> topStudents;
|
||||
final List<StudentDocCount> bottomStudents;
|
||||
final NeedsAttentionData? needsAttention;
|
||||
final List<DocTypeEntry> docTypeBreakdown;
|
||||
final Map<String, List<DocTypeGradeEntry>> docTypeByGrade;
|
||||
final List<UploadTrendEntry> uploadTrend;
|
||||
@@ -388,6 +444,7 @@ class DashboardKpis {
|
||||
required this.statusDistribution,
|
||||
required this.topStudents,
|
||||
required this.bottomStudents,
|
||||
this.needsAttention,
|
||||
required this.docTypeBreakdown,
|
||||
this.docTypeByGrade = const {},
|
||||
required this.uploadTrend,
|
||||
@@ -423,6 +480,9 @@ class DashboardKpis {
|
||||
bottomStudents: (j['bottomStudents'] as List<dynamic>? ?? [])
|
||||
.map((e) => StudentDocCount.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
needsAttention: j['needsAttention'] != null
|
||||
? NeedsAttentionData.fromJson(j['needsAttention'] as Map<String, dynamic>)
|
||||
: null,
|
||||
docTypeBreakdown: (j['docTypeBreakdown'] as List<dynamic>? ?? [])
|
||||
.map((e) => DocTypeEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
|
||||
@@ -24,6 +24,7 @@ class AuthRepository {
|
||||
'password': password,
|
||||
'platform': ApiConstants.clientPlatform,
|
||||
'device_name': ApiConstants.clientDeviceName,
|
||||
'remember_me': rememberMe,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -66,4 +66,15 @@ class DashboardRepository {
|
||||
throw Exception(e.response?.data['message'] ?? 'Failed to fetch KPIs.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<NeedsAttentionData> getNeedsAttention() async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
final response = await _dio.get('/dashboard/needs-attention', options: options);
|
||||
return NeedsAttentionData.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.response?.data['message'] ?? 'Failed to fetch Needs Attention data.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ class _AndroidBottomNavLayoutState extends ConsumerState<AndroidBottomNavLayout>
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
break;
|
||||
case 'Students':
|
||||
ref.read(studentViewTabProvider.notifier).state = StudentViewTab.enrolled;
|
||||
ref.read(studentQueryProvider.notifier).reset();
|
||||
ref.invalidate(studentPageProvider);
|
||||
break;
|
||||
|
||||
@@ -97,6 +97,7 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
break;
|
||||
case 'Students':
|
||||
ref.read(studentViewTabProvider.notifier).state = StudentViewTab.enrolled;
|
||||
ref.read(studentQueryProvider.notifier).reset();
|
||||
ref.invalidate(studentPageProvider);
|
||||
break;
|
||||
@@ -161,15 +162,15 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isFirstItem) const SizedBox(height: 8),
|
||||
if (!isFirstItem) const SizedBox(height: 6),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
bottom: 8,
|
||||
top: 8,
|
||||
bottom: 6,
|
||||
top: 4,
|
||||
),
|
||||
child: Text(
|
||||
category.toUpperCase(),
|
||||
@@ -177,7 +178,7 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
.withValues(alpha: 0.6),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.0,
|
||||
@@ -189,17 +190,22 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Tooltip(
|
||||
message: _isMinimized ? tab['label'] as String : '',
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen.withOpacity(0.12)
|
||||
? AppColors.primaryGreen.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.05),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isTabLoading = true;
|
||||
@@ -222,59 +228,84 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
.setTab(tab['label'] as String);
|
||||
_reloadTabContent(tab['label'] as String);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
width: 236,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
(isSelected
|
||||
? tab['activeIcon'] ?? tab['icon']
|
||||
: tab['icon'])
|
||||
as IconData,
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
child: Text(
|
||||
tab['label'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.8),
|
||||
child: SizedBox(
|
||||
height: 42,
|
||||
child: _isMinimized
|
||||
? Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 3.5,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Center(
|
||||
child: Icon(
|
||||
(isSelected
|
||||
? tab['activeIcon'] ?? tab['icon']
|
||||
: tab['icon'])
|
||||
as IconData,
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.65),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14.0,
|
||||
vertical: 9.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
(isSelected
|
||||
? tab['activeIcon'] ?? tab['icon']
|
||||
: tab['icon'])
|
||||
as IconData,
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.65),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tab['label'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: isSelected
|
||||
? AppColors.primaryGreen
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -364,10 +395,7 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
|
||||
_visitedIndices.add(currentIndex);
|
||||
|
||||
final overviewTabs = tabs
|
||||
.where((t) => t['category'] == 'MAIN')
|
||||
.toList();
|
||||
final accountTabs = tabs.where((t) => t['category'] == 'ADMINISTRATION').toList();
|
||||
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
@@ -448,9 +476,9 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
// WINDOWS SIDEBAR (Fixed Width: 260px)
|
||||
// ==========================================
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
width: _isMinimized ? 80 : 260,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOutCubic,
|
||||
width: _isMinimized ? 72 : 260,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
@@ -462,169 +490,182 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
24,
|
||||
32,
|
||||
16,
|
||||
20,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_isMinimized)
|
||||
setState(() => _isMinimized = false);
|
||||
},
|
||||
onLongPressStart: (_) {
|
||||
_holdTimer = Timer(
|
||||
const Duration(seconds: 3),
|
||||
() {
|
||||
_showCapstoneMembers(context);
|
||||
// ==========================================
|
||||
// SIDEBAR HEADER: EXPAND BUTTON (MINIMIZED) OR LOGO + TITLE + COLLAPSE
|
||||
// ==========================================
|
||||
_isMinimized
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14.0,
|
||||
),
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: 'Expand Sidebar',
|
||||
waitDuration:
|
||||
const Duration(milliseconds: 300),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.08),
|
||||
onTap: () {
|
||||
setState(() => _isMinimized = false);
|
||||
},
|
||||
);
|
||||
},
|
||||
onLongPressEnd: (_) => _holdTimer?.cancel(),
|
||||
onLongPressCancel: () =>
|
||||
_holdTimer?.cancel(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(
|
||||
0.05,
|
||||
),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.view_sidebar_outlined,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.webp',
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(
|
||||
milliseconds: 200,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
18,
|
||||
14,
|
||||
12,
|
||||
12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) {
|
||||
_holdTimer = Timer(
|
||||
const Duration(seconds: 3),
|
||||
() {
|
||||
_showCapstoneMembers(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
onLongPressEnd: (_) =>
|
||||
_holdTimer?.cancel(),
|
||||
onLongPressCancel: () =>
|
||||
_holdTimer?.cancel(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.05,
|
||||
),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.webp',
|
||||
width: 32,
|
||||
height: 32,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'TIS RMS',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
letterSpacing: 1.2,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.menu,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
Tooltip(
|
||||
message: 'Collapse Sidebar',
|
||||
waitDuration:
|
||||
const Duration(milliseconds: 300),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
hoverColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.08),
|
||||
onTap: () {
|
||||
setState(() => _isMinimized = true);
|
||||
},
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.view_sidebar_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.65),
|
||||
size: 21,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
if (!_isMinimized)
|
||||
setState(() => _isMinimized = true);
|
||||
},
|
||||
tooltip: 'Minimize Sidebar',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Navigation Items
|
||||
// Navigation Items: Single Unified Scrollable List
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
itemCount: overviewTabs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tab = overviewTabs[index];
|
||||
final isSelected =
|
||||
tab['label'] ==
|
||||
tabs[currentIndex]['label'];
|
||||
final isCategoryHeader =
|
||||
index == 0 ||
|
||||
overviewTabs[index - 1]['category'] !=
|
||||
tab['category'];
|
||||
return _buildNavItem(
|
||||
tab,
|
||||
isSelected,
|
||||
isCategoryHeader,
|
||||
isFirstItem: index == 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
child: Column(
|
||||
children: accountTabs.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final index = entry.key;
|
||||
final tab = entry.value;
|
||||
final isSelected =
|
||||
tab['label'] ==
|
||||
tabs[currentIndex]['label'];
|
||||
final isCategoryHeader =
|
||||
index == 0 ||
|
||||
accountTabs[index - 1]['category'] !=
|
||||
tab['category'];
|
||||
return _buildNavItem(
|
||||
tab,
|
||||
isSelected,
|
||||
isCategoryHeader,
|
||||
isFirstItem: false,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: _isMinimized ? 12 : 12,
|
||||
vertical: 4,
|
||||
),
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final isSelected =
|
||||
tab['label'] ==
|
||||
tabs[currentIndex]['label'];
|
||||
final isCategoryHeader =
|
||||
index == 0 ||
|
||||
tabs[index - 1]['category'] !=
|
||||
tab['category'];
|
||||
return _buildNavItem(
|
||||
tab,
|
||||
isSelected,
|
||||
isCategoryHeader,
|
||||
isFirstItem: index == 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Persistent Server Connection Status Pill
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: _isMinimized ? 12 : 20,
|
||||
horizontal: _isMinimized ? 8 : 20,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Center(
|
||||
@@ -637,37 +678,31 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20.0,
|
||||
vertical: 16.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_isMinimized)
|
||||
setState(() => _isMinimized = false);
|
||||
},
|
||||
_isMinimized
|
||||
? Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: ref
|
||||
.watch(authProvider)
|
||||
.value
|
||||
?.fullName ??
|
||||
'User Profile',
|
||||
waitDuration:
|
||||
const Duration(milliseconds: 300),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final user = ref
|
||||
.watch(authProvider)
|
||||
.value;
|
||||
final initials =
|
||||
user != null &&
|
||||
final user =
|
||||
ref.watch(authProvider).value;
|
||||
final initials = user != null &&
|
||||
user.firstName.isNotEmpty &&
|
||||
user.lastName.isNotEmpty
|
||||
? '${user.firstName[0]}${user.lastName[0]}'
|
||||
.toUpperCase()
|
||||
.toUpperCase()
|
||||
: 'SA';
|
||||
return CircleAvatar(
|
||||
radius: 20,
|
||||
radius: 18,
|
||||
backgroundColor:
|
||||
AppColors.primaryGreen,
|
||||
child: Text(
|
||||
@@ -675,23 +710,53 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(
|
||||
milliseconds: 200,
|
||||
),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14.0,
|
||||
vertical: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final user =
|
||||
ref.watch(authProvider).value;
|
||||
final initials = user != null &&
|
||||
user.firstName.isNotEmpty &&
|
||||
user.lastName.isNotEmpty
|
||||
? '${user.firstName[0]}${user.lastName[0]}'
|
||||
.toUpperCase()
|
||||
: 'SA';
|
||||
return CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor:
|
||||
AppColors.primaryGreen,
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ref
|
||||
@@ -701,7 +766,7 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
'Unknown User',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
@@ -712,59 +777,49 @@ class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
|
||||
Text(
|
||||
widget.userRole.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
child: IconButton(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.menu_book_outlined,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
size: 19,
|
||||
),
|
||||
tooltip: 'User Guide & Documentation',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
if (!_isMinimized) {
|
||||
DocumentationScreen.open(context);
|
||||
}
|
||||
DocumentationScreen.open(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _isMinimized ? 0.0 : 1.0,
|
||||
child: IconButton(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.logout,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
.withValues(alpha: 0.6),
|
||||
size: 19,
|
||||
),
|
||||
tooltip: 'Logout',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
if (!_isMinimized)
|
||||
showLogoutConfirmationDialog(context);
|
||||
showLogoutConfirmationDialog(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -881,8 +936,8 @@ class _PageSkeletonLoaderState extends State<_PageSkeletonLoader>
|
||||
child: ListView.separated(
|
||||
itemCount: 5,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||
itemBuilder: (_, __) => Container(
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||
itemBuilder: (_, _) => Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: secondaryColor,
|
||||
|
||||
@@ -101,6 +101,8 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
|
||||
ref.invalidate(yearlyComparisonSelectedStatusesProvider);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Future<void> refreshUser() async {
|
||||
try {
|
||||
final repository = ref.read(authRepositoryProvider);
|
||||
|
||||
@@ -21,11 +21,14 @@ class DashboardData {
|
||||
});
|
||||
}
|
||||
|
||||
final dashboardDataProvider = FutureProvider<DashboardData>((ref) async {
|
||||
final repository = ref.read(dashboardRepositoryProvider);
|
||||
final dashboardDataProvider = FutureProvider.autoDispose<DashboardData>((ref) async {
|
||||
final user = ref.watch(authProvider).value;
|
||||
if (user == null) {
|
||||
throw Exception('User not authenticated');
|
||||
}
|
||||
final repository = ref.watch(dashboardRepositoryProvider);
|
||||
final activityRepo = ActivityRepository();
|
||||
final user = ref.read(authProvider).value;
|
||||
final isTeacher = user?.role == 'teacher';
|
||||
final isTeacher = user.role == 'teacher';
|
||||
|
||||
final stats = await repository.getStats();
|
||||
final activities = await repository.getRecentActivities(
|
||||
@@ -47,11 +50,27 @@ final dashboardDataProvider = FutureProvider<DashboardData>((ref) async {
|
||||
});
|
||||
|
||||
// ── KPI chart data (all 7 chart datasets) ──────────────────────────────────
|
||||
final dashboardKpisProvider = FutureProvider<DashboardKpis>((ref) async {
|
||||
final repository = ref.read(dashboardRepositoryProvider);
|
||||
final dashboardKpisProvider = FutureProvider.autoDispose<DashboardKpis>((ref) async {
|
||||
final user = ref.watch(authProvider).value;
|
||||
if (user == null) {
|
||||
throw Exception('User not authenticated');
|
||||
}
|
||||
final repository = ref.watch(dashboardRepositoryProvider);
|
||||
return repository.getKpis();
|
||||
});
|
||||
|
||||
// ── Needs Attention dedicated provider ─────────────────────────────────────
|
||||
final dashboardNeedsAttentionProvider =
|
||||
FutureProvider.autoDispose<NeedsAttentionData>((ref) async {
|
||||
final user = ref.watch(authProvider).value;
|
||||
if (user == null) {
|
||||
throw Exception('User not authenticated');
|
||||
}
|
||||
final repository = ref.watch(dashboardRepositoryProvider);
|
||||
return repository.getNeedsAttention();
|
||||
});
|
||||
|
||||
|
||||
// ── Full paginated activities (used by RecentActivitiesScreen) ─────────────
|
||||
class ActivityQueryParams {
|
||||
final int page;
|
||||
|
||||
@@ -100,7 +100,7 @@ class ShowOnlyMissingDocsNotifier extends Notifier<bool> {
|
||||
|
||||
// ── Filter Panel Visibility ───────────────────────────────────────────────────
|
||||
final filterPanelExpandedProvider = NotifierProvider<_BoolNotifier, bool>(
|
||||
() => _BoolNotifier(initial: true),
|
||||
() => _BoolNotifier(initial: false),
|
||||
);
|
||||
|
||||
// ── Missing Docs Filter Visibility ───────────────────────────────────────────
|
||||
|
||||
@@ -5,7 +5,9 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../core/network/api_constants.dart';
|
||||
import '../../domain/repositories/student_repository.dart';
|
||||
import '../../domain/entities/student_model.dart';
|
||||
import '../../domain/entities/setup_models.dart';
|
||||
import '../../domain/entities/dashboard_models.dart' show RecentActivity;
|
||||
import 'setup_provider.dart';
|
||||
|
||||
// ============================================================
|
||||
// Academic Years Model
|
||||
@@ -207,7 +209,21 @@ class StudentQueryNotifier extends Notifier<StudentQueryParams> {
|
||||
);
|
||||
}
|
||||
|
||||
void reset() => state = const StudentQueryParams();
|
||||
void reset({String? schoolYear}) {
|
||||
String sy = schoolYear ?? '';
|
||||
if (sy.isEmpty) {
|
||||
final tab = ref.read(studentViewTabProvider);
|
||||
if (tab == StudentViewTab.enrolled) {
|
||||
final years = ref.read(academicYearsListProvider).asData?.value ?? [];
|
||||
try {
|
||||
sy = years.firstWhere((y) => y.status.toLowerCase() == 'active').yearRange;
|
||||
} catch (_) {
|
||||
if (years.isNotEmpty) sy = years.first.yearRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
state = StudentQueryParams(schoolYear: sy);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -229,10 +245,11 @@ final studentActiveFilterCountProvider = Provider<int>((ref) {
|
||||
final tab = ref.watch(studentViewTabProvider);
|
||||
|
||||
if (tab == StudentViewTab.enrolled) {
|
||||
// In enrolled tab, status and schoolYear are inherent to the tab, not user overrides
|
||||
// In enrolled tab, status is a user filter; schoolYear is inherent to the tab
|
||||
return [
|
||||
query.gradeLevel.isNotEmpty,
|
||||
query.section.isNotEmpty,
|
||||
query.status.isNotEmpty,
|
||||
query.is4Ps.isNotEmpty,
|
||||
query.sortBy.isNotEmpty,
|
||||
query.limit != 20,
|
||||
@@ -257,6 +274,7 @@ final studentPageProvider = FutureProvider.autoDispose<StudentPage>((
|
||||
ref,
|
||||
) async {
|
||||
final query = ref.watch(studentQueryProvider);
|
||||
final tab = ref.watch(studentViewTabProvider);
|
||||
final repo = ref.read(studentRepositoryProvider);
|
||||
|
||||
final sub = repo.onStudentChanged.listen((_) {
|
||||
@@ -265,6 +283,25 @@ final studentPageProvider = FutureProvider.autoDispose<StudentPage>((
|
||||
|
||||
ref.onDispose(() => sub.cancel());
|
||||
|
||||
String effectiveSchoolYear = query.schoolYear;
|
||||
if (tab == StudentViewTab.enrolled &&
|
||||
effectiveSchoolYear.isEmpty &&
|
||||
query.search.isEmpty) {
|
||||
List<AcademicYearModel> years =
|
||||
ref.watch(academicYearsListProvider).asData?.value ?? [];
|
||||
if (years.isEmpty) {
|
||||
try {
|
||||
years = await ref.watch(academicYearsListProvider.future);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
effectiveSchoolYear =
|
||||
years.firstWhere((y) => y.status.toLowerCase() == 'active').yearRange;
|
||||
} catch (_) {
|
||||
if (years.isNotEmpty) effectiveSchoolYear = years.first.yearRange;
|
||||
}
|
||||
}
|
||||
|
||||
return repo.getStudents(
|
||||
search: query.search,
|
||||
page: query.page,
|
||||
@@ -272,7 +309,7 @@ final studentPageProvider = FutureProvider.autoDispose<StudentPage>((
|
||||
gradeLevel: query.gradeLevel,
|
||||
status: query.status,
|
||||
section: query.section,
|
||||
schoolYear: query.schoolYear,
|
||||
schoolYear: effectiveSchoolYear,
|
||||
is4ps: query.is4Ps,
|
||||
lrn: query.lrn,
|
||||
sortBy: query.sortBy,
|
||||
|
||||
@@ -25,7 +25,6 @@ import '../documents/widgets/styled_folder_icon.dart';
|
||||
import '../documents/widgets/print_queue_modal.dart';
|
||||
import '../documents/widgets/student_profile_modal.dart';
|
||||
import '../documents/widgets/download_guide_dialog.dart';
|
||||
import '../documents/widgets/recycle_bin_modal.dart';
|
||||
import '../../shared/dialogs/document_properties_dialog.dart';
|
||||
import '../../../core/utils/download_service.dart';
|
||||
import '../../../core/network/api_constants.dart';
|
||||
@@ -260,62 +259,9 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
ref.read(archiveDocumentQueryProvider.notifier).reset();
|
||||
}
|
||||
|
||||
// ── Restore archived student (admin) ────────────────────────────
|
||||
void _handleRestoreStudent(int studentId, String studentName) async {
|
||||
if (!_isAdmin) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.restore, color: AppColors.primaryGreen),
|
||||
SizedBox(width: 8),
|
||||
Text('Restore Student'),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Restore $studentName to Active (Enrolled) status? Their documents will also be set back to Completed.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('RESTORE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref
|
||||
.read(archiveMutationProvider.notifier)
|
||||
.restoreArchive(studentId);
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: '$studentName has been restored to Enrolled.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Restore Failed',
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview document ────────────────────────────────────────────
|
||||
void _handlePreview(DocumentModel doc) {
|
||||
showDocumentPreview(context: context, document: doc);
|
||||
showDocumentPreview(context: context, document: doc, isArchive: true);
|
||||
}
|
||||
|
||||
// ── Document file menu (print, download, preview) ──────────────
|
||||
@@ -337,16 +283,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
}
|
||||
if (!mounted) return;
|
||||
PrintQueueModal.show(context);
|
||||
} else if (action == 'copy') {
|
||||
try {
|
||||
await ref.read(documentMutationProvider.notifier).copyDocument(doc.id);
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(context, message: 'Document copied successfully.');
|
||||
ref.invalidate(archiveDocumentPageProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, 'Copy Failed', e.toString());
|
||||
}
|
||||
} else if (action == 'download') {
|
||||
try {
|
||||
final token =
|
||||
@@ -378,108 +314,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
userRole: widget.userRole,
|
||||
hideEnrollmentActions: true,
|
||||
);
|
||||
} else if (action == 'restore') {
|
||||
_confirmRestoreDocument(doc);
|
||||
} else if (action == 'delete') {
|
||||
_handleDeleteDocument(doc);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmRestoreDocument(DocumentModel doc) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.unarchive_outlined, color: AppColors.primaryGreen),
|
||||
SizedBox(width: 8),
|
||||
Text('Restore Document'),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to restore "${doc.fileName}" to active documents?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
try {
|
||||
await ref
|
||||
.read(documentMutationProvider.notifier)
|
||||
.updateStatus(doc.id, 'Completed');
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: 'Document restored to active documents.',
|
||||
);
|
||||
ref.invalidate(archiveDocumentPageProvider);
|
||||
ref.invalidate(archiveStudentFoldersProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, 'Restore Failed', e.toString());
|
||||
}
|
||||
},
|
||||
child: const Text('RESTORE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDeleteDocument(DocumentModel doc) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppColors.error),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete Document', style: TextStyle(color: AppColors.error)),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Are you sure you want to permanently delete this document?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.error,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('DELETE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref
|
||||
.read(documentMutationProvider.notifier)
|
||||
.deleteDocument(doc.id);
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: 'Document deleted.',
|
||||
);
|
||||
ref.invalidate(archiveDocumentPageProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, 'Delete Failed', e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,31 +341,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBatchCopy() async {
|
||||
try {
|
||||
await ref
|
||||
.read(documentMutationProvider.notifier)
|
||||
.bulkCopy(_selectedDocumentIds.toList());
|
||||
setState(() {
|
||||
_selectedDocumentIds.clear();
|
||||
_isMultiSelectMode = false;
|
||||
});
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: 'Selected documents copied successfully.',
|
||||
);
|
||||
ref.invalidate(archiveDocumentPageProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Copy Failed',
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBatchDownload() async {
|
||||
try {
|
||||
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
||||
@@ -597,67 +406,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleBatchDelete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppColors.error),
|
||||
SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Delete Selected Documents',
|
||||
style: TextStyle(color: AppColors.error, fontSize: 17),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Are you sure you want to permanently delete these ${_selectedDocumentIds.length} documents?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.error,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('DELETE'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref
|
||||
.read(documentMutationProvider.notifier)
|
||||
.bulkDelete(_selectedDocumentIds.toList());
|
||||
setState(() {
|
||||
_selectedDocumentIds.clear();
|
||||
_isMultiSelectMode = false;
|
||||
});
|
||||
if (!mounted) return;
|
||||
showSuccessDialog(
|
||||
context,
|
||||
message: 'Selected documents deleted successfully.',
|
||||
);
|
||||
ref.invalidate(archiveDocumentPageProvider);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Delete Failed',
|
||||
e.toString().replaceFirst('Exception: ', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInlineMultiSelectHeader() {
|
||||
final pageDocs = ref.watch(archiveDocumentPageProvider).value?.documents ?? [];
|
||||
final pageIds = pageDocs.map((d) => d.id).toSet();
|
||||
@@ -683,12 +431,9 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
});
|
||||
},
|
||||
onBatchPrint: _handleBatchPrint,
|
||||
onBatchCopy: _handleBatchCopy,
|
||||
onBatchDownload: _handleBatchDownload,
|
||||
onBatchStatus: _handleBatchStatus,
|
||||
onBatchArchive: () => _handleBatchStatus('Archived'),
|
||||
onBatchRestore: () => _handleBatchStatus('Completed'),
|
||||
onBatchDelete: _handleBatchDelete,
|
||||
isArchiveScreen: true,
|
||||
);
|
||||
}
|
||||
@@ -1416,11 +1161,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
});
|
||||
} else if (value == 'grid_list') {
|
||||
setState(() => _isGridView = !_isGridView);
|
||||
} else if (value == 'recycle_bin') {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => const RecycleBinModal(),
|
||||
);
|
||||
} else if (value == 'download_guide') {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -1476,23 +1216,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: 'recycle_bin',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_sweep,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Recycle Bin', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isMobile) ...[
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
@@ -1916,19 +1639,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isAdmin) ...[
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'restore',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.restore, size: 18, color: AppColors.primaryGreen),
|
||||
SizedBox(width: 12),
|
||||
Text('Restore to Active', style: TextStyle(fontSize: 14, color: AppColors.primaryGreen)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1948,8 +1658,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
userRole: widget.userRole,
|
||||
hideEnrollmentActions: true,
|
||||
);
|
||||
} else if (value == 'restore') {
|
||||
_handleRestoreStudent(folder.studentId!, studentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1966,8 +1674,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
userRole: widget.userRole,
|
||||
hideEnrollmentActions: true,
|
||||
);
|
||||
} else if (val == 'restore') {
|
||||
_handleRestoreStudent(studentId, studentName);
|
||||
}
|
||||
},
|
||||
itemBuilder: (ctx) => [
|
||||
@@ -1984,20 +1690,6 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'restore',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.restore, color: AppColors.primaryGreen, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Restore to Active',
|
||||
style: TextStyle(color: AppColors.primaryGreen),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../providers/dashboard_provider.dart';
|
||||
import '../../../domain/entities/dashboard_models.dart';
|
||||
import '../../providers/activity_provider.dart';
|
||||
import '../../providers/login_sessions_provider.dart';
|
||||
import '../../../core/utils/ip_location_helper.dart';
|
||||
import '../../shared/modals/view_activity_modal.dart';
|
||||
import '../../shared/widgets/app_pagination.dart';
|
||||
import '../../shared/inputs/app_search_bar.dart';
|
||||
@@ -65,6 +66,8 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
_shortcutFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_clearFilters();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -80,18 +83,15 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fromDate = null;
|
||||
_toDate = null;
|
||||
_searchController.clear();
|
||||
});
|
||||
if (_selectedTab == AuditTab.activities) {
|
||||
ref.read(activityQueryProvider.notifier).reset();
|
||||
} else if (_selectedTab == AuditTab.users) {
|
||||
ref.read(userHistoryQueryProvider.notifier).reset();
|
||||
} else {
|
||||
ref.read(loginSessionsQueryProvider.notifier).reset();
|
||||
}
|
||||
ref.read(activityQueryProvider.notifier).reset();
|
||||
ref.read(userHistoryQueryProvider.notifier).reset();
|
||||
ref.read(loginSessionsQueryProvider.notifier).reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -832,15 +832,19 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _clearFilters,
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
foregroundColor: Colors.redAccent,
|
||||
if (_fromDate != null ||
|
||||
_toDate != null ||
|
||||
(currentAction.isNotEmpty && currentAction != 'All') ||
|
||||
(currentRole.isNotEmpty && currentRole != 'All'))
|
||||
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)),
|
||||
),
|
||||
child: const Text('Reset', style: TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -993,24 +997,17 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
|
||||
// Search Icon
|
||||
Tooltip(
|
||||
message: currentSearch.isNotEmpty ? 'Clear Search' : 'Search',
|
||||
message: 'Search',
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
currentSearch.isNotEmpty ? Icons.close : Icons.search,
|
||||
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);
|
||||
}
|
||||
},
|
||||
onPressed: () => _showSearchDialog(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -1070,27 +1067,16 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
|
||||
// Search Icon (same as other screens)
|
||||
Tooltip(
|
||||
message: currentSearch.isNotEmpty
|
||||
? 'Clear Search'
|
||||
: 'Search (Ctrl+F)',
|
||||
message: 'Search (Ctrl+F)',
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
currentSearch.isNotEmpty
|
||||
? Icons.close
|
||||
: Icons.search,
|
||||
Icons.search,
|
||||
size: 22,
|
||||
color: isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: Colors.black87,
|
||||
),
|
||||
onPressed: () {
|
||||
if (currentSearch.isNotEmpty) {
|
||||
_searchController.clear();
|
||||
_applySearch('');
|
||||
} else {
|
||||
_showSearchDialog(context);
|
||||
}
|
||||
},
|
||||
onPressed: () => _showSearchDialog(context),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1164,8 +1150,8 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
if (_selectedTab != tab) {
|
||||
setState(() {
|
||||
_selectedTab = tab;
|
||||
_searchController.clear();
|
||||
});
|
||||
_clearFilters();
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
@@ -2040,12 +2026,20 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
String? ipLoc = IpLocationHelper.getFastLocation(log.ipAddress);
|
||||
if (ipLoc == null && log.ipAddress != null && log.ipAddress!.isNotEmpty) {
|
||||
ipLoc = await IpLocationHelper.resolveLocation(log.ipAddress);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final ipDesc = log.ipAddress != null
|
||||
? (ipLoc != null ? '${log.ipAddress} ($ipLoc)' : log.ipAddress!)
|
||||
: 'Unknown IP';
|
||||
ViewActivityModal.show(
|
||||
context: context,
|
||||
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'
|
||||
'User @${log.username} (${log.fullName.isNotEmpty ? log.fullName : 'No Name'}) logged in via $platformDisplay ($ipDesc).\n\n'
|
||||
'Login: ${pht.formatDateTime12H(log.loginAt)}\n'
|
||||
'${log.logoutAt != null ? 'Logout: ${pht.formatDateTime12H(log.logoutAt!)}' : 'Session is currently active.'}',
|
||||
date: pht.formatDateTime12H(log.loginAt),
|
||||
@@ -2205,7 +2199,7 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 4,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -2231,31 +2225,85 @@ class _AuditTrailScreenState extends ConsumerState<AuditTrailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (log.ipAddress != null && log.ipAddress!.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lan_outlined,
|
||||
size: 13,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
log.ipAddress!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
if (log.ipAddress != null && log.ipAddress!.isNotEmpty) ...[
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lan_outlined,
|
||||
size: 13,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: log.ipAddress!,
|
||||
child: Text(
|
||||
log.ipAddress!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: IpLocationHelper.resolveLocation(log.ipAddress!),
|
||||
initialData: IpLocationHelper.getFastLocation(log.ipAddress!),
|
||||
builder: (context, snapshot) {
|
||||
final loc = snapshot.data;
|
||||
if (loc == null || loc.isEmpty) return const SizedBox.shrink();
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 12,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: loc,
|
||||
child: Text(
|
||||
loc,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (log.logoutAt != null)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -92,7 +91,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
ref.read(notificationsProvider.notifier).refreshNotifications();
|
||||
|
||||
if (ref.read(activeTabProvider) == 'Dashboard') {
|
||||
@@ -141,6 +139,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
ref.read(activeTabProvider) == 'Dashboard') {
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
ref.invalidate(dashboardKpisProvider);
|
||||
ref.invalidate(dashboardNeedsAttentionProvider);
|
||||
ref.read(notificationsProvider.notifier).refreshNotifications();
|
||||
}
|
||||
});
|
||||
@@ -154,12 +153,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dashboardDataProvider);
|
||||
ref.invalidate(dashboardKpisProvider);
|
||||
ref.invalidate(dashboardNeedsAttentionProvider);
|
||||
if (!mounted) return;
|
||||
ref.read(notificationsProvider.notifier).refreshNotifications();
|
||||
if (!mounted) return;
|
||||
await ref.read(dashboardDataProvider.future);
|
||||
}
|
||||
|
||||
|
||||
Color _getNotificationColor(String title) {
|
||||
final t = title.toLowerCase();
|
||||
if (t.contains('student')) return Colors.blue;
|
||||
@@ -325,6 +326,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
final user = ref.watch(authProvider).value;
|
||||
final isAdmin = user?.role == 'admin';
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
final unreadCount =
|
||||
notificationsAsync.value?.where((n) => !n.isRead).length ?? 0;
|
||||
|
||||
final isMobileOrAndroid = MediaQuery.of(context).size.width < 800 ||
|
||||
defaultTargetPlatform == TargetPlatform.android;
|
||||
@@ -361,22 +365,82 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
isMobileOrAndroid ? 16 : 24,
|
||||
isMobileOrAndroid ? 16 : 90,
|
||||
isMobileOrAndroid ? 16 : 20,
|
||||
isMobileOrAndroid ? 16 : 24,
|
||||
isMobileOrAndroid ? 24 : 76,
|
||||
isMobileOrAndroid ? 24 : 32,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dashboard Overview',
|
||||
style: TextStyle(
|
||||
fontSize:
|
||||
MediaQuery.of(context).size.width > 600
|
||||
? 28
|
||||
: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Dashboard Overview',
|
||||
style: TextStyle(
|
||||
fontSize:
|
||||
MediaQuery.of(context).size.width > 600
|
||||
? 28
|
||||
: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (!isMobileOrAndroid)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Tooltip(
|
||||
richMessage: const TextSpan(
|
||||
text: 'Search Students ',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '(Ctrl+F)',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.search, size: 26),
|
||||
onPressed: () => _showSearchDialog(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (ctx) => Stack(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined, size: 26),
|
||||
onPressed: () => _showNotifications(ctx),
|
||||
),
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
right: 6,
|
||||
top: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.redAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
unreadCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
ProfileDropdownMenu(user: user, onRefresh: _handleRefresh),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isAdmin && data.stats.totalStudents == 0 && !_setupBannerDismissed) ...[
|
||||
@@ -393,79 +457,19 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildStatGrid(data.stats, user),
|
||||
const SizedBox(height: 28),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 900) {
|
||||
return const IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 6, child: NeedsAttentionCard()),
|
||||
SizedBox(width: 20),
|
||||
Expanded(flex: 5, child: RecentActivityFeed()),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NeedsAttentionCard(),
|
||||
SizedBox(height: 20),
|
||||
RecentActivityFeed(),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isAdmin || data.stats.hasAssignedSections) ...[
|
||||
const NeedsAttentionCard(),
|
||||
if (isAdmin) ...[
|
||||
const SizedBox(height: 32),
|
||||
const DashboardKpisSection(),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
const RecentActivityFeed(),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Top Fade Overlay (revealed when content scrolls underneath)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: isMobileOrAndroid ? 24 : 40,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showTopFade ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: isDark
|
||||
? [
|
||||
AppColors.darkPageBackground.withValues(alpha: 0.9),
|
||||
AppColors.darkPageBackground.withValues(alpha: 0.0),
|
||||
]
|
||||
: [
|
||||
Colors.white.withValues(alpha: 0.9),
|
||||
Colors.white.withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Sticky Blur Top Bar (Desktop only, as Android Top AppBar handles actions)
|
||||
if (!isMobileOrAndroid)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildTopBar(context, user),
|
||||
),
|
||||
// Bottom Bar Fade Overlay (revealed when more content is scrollable below)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
@@ -832,108 +836,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TOP BAR ──────────────────────────────────────────────────────────────
|
||||
Widget _buildTopBar(BuildContext context, UserModel? user) {
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
final unreadCount =
|
||||
notificationsAsync.value?.where((n) => !n.isRead).length ?? 0;
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return ShaderMask(
|
||||
shaderCallback: (rect) {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black, Colors.transparent],
|
||||
stops: [0.6, 1.0],
|
||||
).createShader(rect);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: isDark
|
||||
? [
|
||||
AppColors.darkPageBackground.withValues(alpha: 0.85),
|
||||
AppColors.darkPageBackground.withValues(alpha: 0.15),
|
||||
]
|
||||
: [
|
||||
Colors.white.withValues(alpha: 0.85),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
richMessage: const TextSpan(
|
||||
text: 'Search Students ',
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '(Ctrl+F)',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.search, size: 32),
|
||||
onPressed: () => _showSearchDialog(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (ctx) => Stack(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications, size: 32),
|
||||
onPressed: () => _showNotifications(ctx),
|
||||
),
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.redAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
unreadCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ProfileDropdownMenu(user: user, onRefresh: _handleRefresh),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── STAT GRID ─────────────────────────────────────────────────────────────
|
||||
Widget _buildStatGrid(DashboardStats stats, UserModel? user) {
|
||||
final isAdmin = user?.role == 'admin';
|
||||
|
||||
@@ -58,66 +58,7 @@ class _KpisContent extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isTeacher) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isWide)
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
_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(height: 16),
|
||||
_StudentDocCard(
|
||||
title: 'Needs Attention',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
iconColor: Colors.orange,
|
||||
students: kpis.bottomStudents,
|
||||
allTopStudents: kpis.topStudents,
|
||||
allBottomStudents: kpis.bottomStudents,
|
||||
isTop: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
@@ -191,65 +132,6 @@ class _KpisContent extends StatelessWidget {
|
||||
_StorageCard(analytics: kpis.storageAnalytics),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bento Block 4: Top Students & Needs Attention
|
||||
if (isWide)
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
_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(height: 16),
|
||||
_StudentDocCard(
|
||||
title: 'Needs Attention',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
iconColor: Colors.orange,
|
||||
students: kpis.bottomStudents,
|
||||
allTopStudents: kpis.topStudents,
|
||||
allBottomStudents: kpis.bottomStudents,
|
||||
isTop: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -264,7 +146,6 @@ class _ChartCard extends StatelessWidget {
|
||||
final Color iconColor;
|
||||
final Widget child;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
|
||||
const _ChartCard({
|
||||
required this.title,
|
||||
@@ -272,7 +153,6 @@ class _ChartCard extends StatelessWidget {
|
||||
required this.child,
|
||||
this.iconColor = AppColors.primaryGreen,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -330,7 +210,6 @@ class _ChartCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -1434,739 +1313,7 @@ class _UploadTrendCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 6. STUDENT DOC COUNT (horizontal bars) + RANKING MODAL
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
enum _RankingTab { top, needsAttention }
|
||||
|
||||
class _StudentDocCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final List<StudentDocCount> students;
|
||||
final List<StudentDocCount> allTopStudents;
|
||||
final List<StudentDocCount> allBottomStudents;
|
||||
final bool isTop;
|
||||
|
||||
const _StudentDocCard({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.students,
|
||||
required this.allTopStudents,
|
||||
required this.allBottomStudents,
|
||||
required this.isTop,
|
||||
});
|
||||
|
||||
void _openModal(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _StudentDocRankingModal(
|
||||
allTopStudents: allTopStudents,
|
||||
allBottomStudents: allBottomStudents,
|
||||
initialTab: isTop ? _RankingTab.top : _RankingTab.needsAttention,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final displayList = students.take(5).toList();
|
||||
|
||||
return _ChartCard(
|
||||
title: title,
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
subtitle: isTop
|
||||
? 'Most required document types completed'
|
||||
: 'Most missing required document types',
|
||||
trailing: TextButton.icon(
|
||||
onPressed: () => _openModal(context),
|
||||
icon: const Icon(Icons.arrow_forward_rounded, size: 14),
|
||||
label: const Text('View All'),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
foregroundColor: isTop ? AppColors.primaryGreen : Colors.orange.shade700,
|
||||
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
child: students.isEmpty
|
||||
? Text(
|
||||
'No data yet.',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 13),
|
||||
)
|
||||
: Column(
|
||||
children: displayList.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final s = entry.value;
|
||||
final pct = s.percent.clamp(0.0, 1.0);
|
||||
final color = isTop
|
||||
? Color.lerp(
|
||||
AppColors.primaryGreen, Colors.blue.shade700, i / 5)!
|
||||
: Colors.orange.shade400;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (isTop)
|
||||
Text(
|
||||
'${i + 1}.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.54),
|
||||
),
|
||||
),
|
||||
if (isTop) const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 11, color: Colors.white, height: 1.4),
|
||||
message: s.missingCount == 0
|
||||
? '[${s.category}] All ${s.totalRequired} required documents submitted! ✅'
|
||||
: '[${s.category} Missing Requirements (${s.missingCount})]:\n• ${s.missingRequirements.join('\n• ')}',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${s.uploadedCount} / ${s.totalRequired} (${s.missingCount} missing)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: s.missingCount == 0
|
||||
? (isDark ? const Color(0xFF66BB6A) : AppColors.primaryGreen)
|
||||
: (isTop
|
||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)
|
||||
: Colors.orange.shade700),
|
||||
),
|
||||
),
|
||||
if (s.missingCount > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 12,
|
||||
color: s.missingCount == 0
|
||||
? AppColors.primaryGreen
|
||||
: Colors.orange.shade700,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
backgroundColor: isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StudentDocRankingModal extends StatefulWidget {
|
||||
final List<StudentDocCount> allTopStudents;
|
||||
final List<StudentDocCount> allBottomStudents;
|
||||
final _RankingTab initialTab;
|
||||
|
||||
const _StudentDocRankingModal({
|
||||
required this.allTopStudents,
|
||||
required this.allBottomStudents,
|
||||
this.initialTab = _RankingTab.top,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_StudentDocRankingModal> createState() => _StudentDocRankingModalState();
|
||||
}
|
||||
|
||||
class _StudentDocRankingModalState extends State<_StudentDocRankingModal> {
|
||||
late _RankingTab _activeTab;
|
||||
String _searchQuery = '';
|
||||
String _selectedGradeFilter = 'All';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeTab = widget.initialTab;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isMobile = screenWidth < 600;
|
||||
|
||||
final baseList = _activeTab == _RankingTab.top
|
||||
? widget.allTopStudents
|
||||
: widget.allBottomStudents;
|
||||
|
||||
final filtered = baseList.where((s) {
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final q = _searchQuery.toLowerCase();
|
||||
if (!s.name.toLowerCase().contains(q)) return false;
|
||||
}
|
||||
if (_selectedGradeFilter == 'All') return true;
|
||||
if (_selectedGradeFilter == 'JHS') return s.category == 'JHS';
|
||||
if (_selectedGradeFilter == 'SHS') return s.category == 'SHS';
|
||||
if (_selectedGradeFilter.startsWith('Grade ')) {
|
||||
final g = int.tryParse(_selectedGradeFilter.replaceAll('Grade ', ''));
|
||||
if (g != null && s.gradeLevel != g) return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: isMobile ? 12 : 24,
|
||||
vertical: isMobile ? 16 : 24,
|
||||
),
|
||||
child: Container(
|
||||
width: isMobile ? double.infinity : 680,
|
||||
height: isMobile ? MediaQuery.of(context).size.height * 0.85 : 640,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.leaderboard_rounded,
|
||||
color: AppColors.primaryGreen,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Student Document Status',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Required document submission overview',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded, size: 20),
|
||||
splashRadius: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Tab Filter Buttons (Top vs Attention)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
label: 'Top',
|
||||
count: widget.allTopStudents.length,
|
||||
isSelected: _activeTab == _RankingTab.top,
|
||||
activeColor: AppColors.primaryGreen,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_activeTab = _RankingTab.top;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
label: 'Attention',
|
||||
count: widget.allBottomStudents.length,
|
||||
isSelected: _activeTab == _RankingTab.needsAttention,
|
||||
activeColor: Colors.orange.shade700,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_activeTab = _RankingTab.needsAttention;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Search and Grade Filter row
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search student name...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search_rounded, size: 18),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear_rounded, size: 16),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onChanged: (v) => setState(() => _searchQuery = v.trim()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedGradeFilter,
|
||||
icon: const Icon(Icons.arrow_drop_down_rounded, size: 20),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'All', child: Text('All Levels')),
|
||||
DropdownMenuItem(value: 'JHS', child: Text('JHS')),
|
||||
DropdownMenuItem(value: 'SHS', child: Text('SHS')),
|
||||
DropdownMenuItem(value: 'Grade 7', child: Text('Grade 7')),
|
||||
DropdownMenuItem(value: 'Grade 8', child: Text('Grade 8')),
|
||||
DropdownMenuItem(value: 'Grade 9', child: Text('Grade 9')),
|
||||
DropdownMenuItem(value: 'Grade 10', child: Text('Grade 10')),
|
||||
DropdownMenuItem(value: 'Grade 11', child: Text('Grade 11')),
|
||||
DropdownMenuItem(value: 'Grade 12', child: Text('Grade 12')),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _selectedGradeFilter = val);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Students List
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_rounded,
|
||||
size: 42,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'No students matching "$_searchQuery"'
|
||||
: 'No students found.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.54),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
||||
itemBuilder: (context, idx) {
|
||||
final s = filtered[idx];
|
||||
final pct = s.percent.clamp(0.0, 1.0);
|
||||
final isComplete = s.missingCount == 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Rank number
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(
|
||||
'#${idx + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Student info & progress bar
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (s.gradeLevel != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: (s.category == 'JHS'
|
||||
? AppColors.primaryGreen
|
||||
: Colors.blue)
|
||||
.withValues(alpha: isDark ? 0.25 : 0.12),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'${s.category} • Gr. ${s.gradeLevel}',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: s.category == 'JHS'
|
||||
? (isDark ? const Color(0xFF66BB6A) : AppColors.primaryGreen)
|
||||
: (isDark ? Colors.blue.shade300 : Colors.blue.shade700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
backgroundColor: isDark ? AppColors.darkSurfaceCard : Colors.grey.shade200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
isComplete
|
||||
? AppColors.primaryGreen
|
||||
: (_activeTab == _RankingTab.top
|
||||
? Colors.blue.shade600
|
||||
: Colors.orange.shade600),
|
||||
),
|
||||
minHeight: 5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${s.uploadedCount}/${s.totalRequired}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Status Pill with hover tooltip showing missing requirements separated by category
|
||||
Tooltip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E293B) : const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 11, color: Colors.white, height: 1.4),
|
||||
message: isComplete
|
||||
? '[${s.category}] All ${s.totalRequired} required documents submitted! ✅'
|
||||
: '[${s.category} Missing Requirements (${s.missingCount})]:\n• ${s.missingRequirements.join('\n• ')}',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: isComplete
|
||||
? AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.12)
|
||||
: Colors.orange.withValues(alpha: isDark ? 0.25 : 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isComplete ? 'Complete' : '${s.missingCount} Missing',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isComplete
|
||||
? (isDark ? const Color(0xFF66BB6A) : AppColors.primaryGreen)
|
||||
: (isDark ? Colors.orange.shade300 : Colors.orange.shade800),
|
||||
),
|
||||
),
|
||||
if (!isComplete) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 11,
|
||||
color: isDark ? Colors.orange.shade300 : Colors.orange.shade800,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Footer
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${filtered.length} students',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabButton extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
final bool isSelected;
|
||||
final Color activeColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TabButton({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.isSelected,
|
||||
required this.activeColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark ? AppColors.darkSurfaceCard : Colors.white)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.w600,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? activeColor.withValues(alpha: isDark ? 0.25 : 0.15)
|
||||
: (isDark ? AppColors.darkSurfaceCard : Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? activeColor
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 7. STORAGE ANALYTICS
|
||||
// 6. STORAGE ANALYTICS
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
class _StorageCard extends StatefulWidget {
|
||||
final StorageAnalytics analytics;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../providers/dashboard_provider.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../../providers/navigation_provider.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../../../providers/dashboard_provider.dart';
|
||||
import '../../documents/widgets/student_profile_modal.dart';
|
||||
import '../../documents/widgets/upload_ocr_modal.dart';
|
||||
import '../../../../domain/entities/student_model.dart';
|
||||
import '../../../../domain/entities/dashboard_models.dart';
|
||||
|
||||
/// "Needs Attention" operational card for Dashboard (Priority 4).
|
||||
/// Shows missing SF9, missing SF10, documents requiring verification,
|
||||
/// and immediate action links directly to student targets.
|
||||
/// "Needs Attention" operational card for Dashboard.
|
||||
/// Dynamically monitors missing mandatory document requirements from the backend
|
||||
/// and provides immediate action links directly to student targets.
|
||||
class NeedsAttentionCard extends ConsumerWidget {
|
||||
const NeedsAttentionCard({super.key});
|
||||
|
||||
@@ -21,51 +20,77 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
final user = ref.watch(authProvider).value;
|
||||
final userRole = user?.role ?? 'teacher';
|
||||
|
||||
final kpisAsync = ref.watch(dashboardKpisProvider);
|
||||
final studentsAsync = ref.watch(enrolledStudentsForRequirementsProvider);
|
||||
final needsAttentionAsync = ref.watch(dashboardNeedsAttentionProvider);
|
||||
|
||||
final enrolledStudents = studentsAsync.asData?.value ?? [];
|
||||
final kpis = kpisAsync.asData?.value;
|
||||
|
||||
// 1. Calculate missing SF9 count
|
||||
final missingSf9Students = enrolledStudents.where((s) {
|
||||
return s.missingDocuments.any(
|
||||
(d) => d.toUpperCase().contains('SF9') || d.toUpperCase().contains('FORM 9'),
|
||||
if (needsAttentionAsync.isLoading && !needsAttentionAsync.hasValue) {
|
||||
return Container(
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
final sf9Count = missingSf9Students.isNotEmpty
|
||||
? missingSf9Students.length
|
||||
: (kpis?.bottomStudents
|
||||
.where((s) => s.missingRequirements.any((d) => d.toUpperCase().contains('SF9')))
|
||||
.length ??
|
||||
0);
|
||||
}
|
||||
|
||||
// 2. Calculate missing SF10 count
|
||||
final missingSf10Students = enrolledStudents.where((s) {
|
||||
return s.missingDocuments.any(
|
||||
(d) => d.toUpperCase().contains('SF10') || d.toUpperCase().contains('FORM 10'),
|
||||
if (needsAttentionAsync.hasError && !needsAttentionAsync.hasValue) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 28, color: Colors.orange.shade800),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unable to load operational alerts',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white70 : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(dashboardNeedsAttentionProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
final sf10Count = missingSf10Students.isNotEmpty
|
||||
? missingSf10Students.length
|
||||
: (kpis?.bottomStudents
|
||||
.where((s) => s.missingRequirements.any((d) => d.toUpperCase().contains('SF10')))
|
||||
.length ??
|
||||
0);
|
||||
}
|
||||
|
||||
final totalAlerts = sf9Count + sf10Count;
|
||||
final needsAttention = needsAttentionAsync.value;
|
||||
|
||||
// Top students needing immediate attention (most missing docs)
|
||||
final urgentStudents = enrolledStudents
|
||||
.where((s) => s.missingDocumentsCount > 0 || s.missingDocuments.isNotEmpty)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final countA = a.missingDocumentsCount > 0 ? a.missingDocumentsCount : a.missingDocuments.length;
|
||||
final countB = b.missingDocumentsCount > 0 ? b.missingDocumentsCount : b.missingDocuments.length;
|
||||
return countB.compareTo(countA);
|
||||
});
|
||||
// missing requirements from backend needsAttention
|
||||
final List<MissingRequirementGroup> missingGroups = needsAttention?.missingRequirements ?? [];
|
||||
final totalAlerts = needsAttention?.totalAlerts ??
|
||||
missingGroups.fold<int>(0, (sum, g) => sum + g.count);
|
||||
|
||||
// Urgent students needing immediate attention
|
||||
final List<StudentDocCount> urgentStudents = needsAttention?.urgentStudents ?? [];
|
||||
final displayedStudents = urgentStudents.take(3).toList();
|
||||
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
@@ -134,6 +159,8 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
),
|
||||
child: Text(
|
||||
'$totalAlerts items',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -149,6 +176,8 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
totalAlerts > 0
|
||||
? 'Operational items requiring action'
|
||||
: 'All document requirements compliant',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
@@ -184,49 +213,33 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Operational Alert Rows ──
|
||||
// ── Operational Alert Requirement Chips (Approach A) ──
|
||||
if (totalAlerts > 0) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
// SF9 alert
|
||||
_buildAlertRow(
|
||||
context: context,
|
||||
icon: Icons.assignment_late_outlined,
|
||||
iconColor: Colors.orange.shade800,
|
||||
label: '$sf9Count missing SF9',
|
||||
sublabel: 'Learner Progress Report Card missing for enrolled students',
|
||||
actionLabel: 'View',
|
||||
isDark: isDark,
|
||||
onAction: () => _openMissingDocSheet(
|
||||
context,
|
||||
title: 'Students Missing SF9',
|
||||
students: missingSf9Students,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// SF10 alert
|
||||
_buildAlertRow(
|
||||
context: context,
|
||||
icon: Icons.description_outlined,
|
||||
iconColor: Colors.amber.shade800,
|
||||
label: '$sf10Count missing SF10',
|
||||
sublabel: 'Learner Permanent Academic Record missing',
|
||||
actionLabel: 'View',
|
||||
isDark: isDark,
|
||||
onAction: () => _openMissingDocSheet(
|
||||
context,
|
||||
title: 'Students Missing SF10',
|
||||
students: missingSf10Students,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final item in missingGroups)
|
||||
_buildRequirementChip(
|
||||
context: context,
|
||||
item: item,
|
||||
isDark: isDark,
|
||||
maxWidth: constraints.maxWidth,
|
||||
onTap: () => _openMissingDocSheet(
|
||||
context,
|
||||
title: 'Students Missing ${item.name}',
|
||||
students: item.students,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -289,7 +302,7 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'No missing SF9/SF10 or pending verification queues detected.',
|
||||
'No missing mandatory documents detected.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
@@ -305,85 +318,86 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertRow({
|
||||
Widget _buildRequirementChip({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String label,
|
||||
required String sublabel,
|
||||
required String actionLabel,
|
||||
required MissingRequirementGroup item,
|
||||
required bool isDark,
|
||||
required VoidCallback onAction,
|
||||
required VoidCallback onTap,
|
||||
double? maxWidth,
|
||||
}) {
|
||||
final countLabel = item.count <= 1
|
||||
? '1 student missing ${item.name}'
|
||||
: '${item.count} students missing ${item.name}';
|
||||
final accentColor = isDark ? Colors.amber.shade400 : const Color(0xFFB45309);
|
||||
final bgColor = isDark
|
||||
? AppColors.darkSurface2
|
||||
: const Color(0xFFFFFBEB);
|
||||
final borderColor = isDark
|
||||
? AppColors.darkBorder
|
||||
: const Color(0xFFFDE68A);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onAction,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: isDark ? 0.12 : 0.06),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: iconColor.withValues(alpha: isDark ? 0.25 : 0.18),
|
||||
width: 0.8,
|
||||
child: Tooltip(
|
||||
message: 'Click to view ${item.count} ${item.count <= 1 ? "student" : "students"} missing ${item.name}',
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
constraints: maxWidth != null ? BoxConstraints(maxWidth: maxWidth) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: iconColor),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.description_outlined, size: 16, color: accentColor),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
countLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
Text(
|
||||
sublabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.amber.shade900.withValues(alpha: 0.35)
|
||||
: const Color(0xFFFEF3C7),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'View',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 3),
|
||||
Icon(Icons.arrow_forward_rounded, size: 11, color: accentColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
actionLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.arrow_forward, size: 11, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -392,12 +406,15 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
|
||||
Widget _buildStudentTile({
|
||||
required BuildContext context,
|
||||
required StudentModel student,
|
||||
required StudentDocCount student,
|
||||
required String userRole,
|
||||
required bool isDark,
|
||||
}) {
|
||||
final missingList = student.missingDocuments.take(2).toList();
|
||||
final extraCount = student.missingDocuments.length - missingList.length;
|
||||
final missingList = student.missingRequirements.take(2).toList();
|
||||
final extraCount = student.missingRequirements.length - missingList.length;
|
||||
final initial = student.name.trim().isNotEmpty
|
||||
? student.name.trim()[0].toUpperCase()
|
||||
: 'S';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
@@ -415,7 +432,7 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
radius: 14,
|
||||
backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
student.firstName.isNotEmpty ? student.firstName[0].toUpperCase() : 'S',
|
||||
initial,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -429,7 +446,7 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
student.listDisplayName,
|
||||
student.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -446,19 +463,29 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
children: [
|
||||
for (final doc in missingList)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.12),
|
||||
color: isDark
|
||||
? Colors.amber.shade900.withValues(alpha: 0.25)
|
||||
: const Color(0xFFFEF3C7),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.amber.shade800.withValues(alpha: 0.3)
|
||||
: const Color(0xFFFDE68A),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'! ${_formatDocTag(doc)}',
|
||||
_formatDocTag(doc),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? Colors.amber.shade300
|
||||
: const Color(0xFF92400E),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -510,18 +537,19 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
}
|
||||
|
||||
String _formatDocTag(String doc) {
|
||||
final upper = doc.toUpperCase();
|
||||
final clean = doc.replaceAll(RegExp(r'^\[.*?\]\s*'), '').trim();
|
||||
final upper = clean.toUpperCase();
|
||||
if (upper.contains('SF9') || upper.contains('FORM 9')) return 'SF9';
|
||||
if (upper.contains('SF10') || upper.contains('FORM 10')) return 'SF10';
|
||||
if (upper.contains('SF10') || upper.contains('FORM 10') || upper.contains('FORM 137')) return 'SF10';
|
||||
if (upper.contains('PSA') || upper.contains('BIRTH')) return 'PSA';
|
||||
if (upper.contains('GOOD MORAL')) return 'Good Moral';
|
||||
return doc.length > 15 ? '${doc.substring(0, 14)}…' : doc;
|
||||
return clean.length > 15 ? '${clean.substring(0, 14)}…' : clean;
|
||||
}
|
||||
|
||||
void _openMissingDocSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<StudentModel> students,
|
||||
required List<StudentDocCount> students,
|
||||
required String userRole,
|
||||
required bool isDark,
|
||||
}) {
|
||||
@@ -574,6 +602,8 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -581,7 +611,7 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${students.length} students currently missing this record',
|
||||
'${students.length} ${students.length <= 1 ? "student" : "students"} currently missing this record',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
@@ -618,7 +648,7 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
title: Text(
|
||||
s.listDisplayName,
|
||||
s.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -626,7 +656,7 @@ class NeedsAttentionCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'LRN: ${s.lrn}',
|
||||
'LRN: ${s.lrn != null && s.lrn!.isNotEmpty ? s.lrn : 'N/A'}${s.gradeLevel != null ? ' • Grade ${s.gradeLevel}' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
|
||||
@@ -10,7 +10,7 @@ class BulkOperationsBar extends StatelessWidget {
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onToggleSelectAll;
|
||||
final VoidCallback onBatchPrint;
|
||||
final VoidCallback onBatchCopy;
|
||||
final VoidCallback? onBatchCopy;
|
||||
final VoidCallback onBatchDownload;
|
||||
final ValueChanged<String> onBatchStatus;
|
||||
final VoidCallback onBatchArchive;
|
||||
@@ -26,7 +26,7 @@ class BulkOperationsBar extends StatelessWidget {
|
||||
required this.onCancel,
|
||||
required this.onToggleSelectAll,
|
||||
required this.onBatchPrint,
|
||||
required this.onBatchCopy,
|
||||
this.onBatchCopy,
|
||||
required this.onBatchDownload,
|
||||
required this.onBatchStatus,
|
||||
required this.onBatchArchive,
|
||||
@@ -98,34 +98,30 @@ class BulkOperationsBar extends StatelessWidget {
|
||||
tooltip: 'Print',
|
||||
onPressed: onBatchPrint,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded, color: Colors.white),
|
||||
tooltip: 'Copy',
|
||||
onPressed: onBatchCopy,
|
||||
),
|
||||
if (!isArchiveScreen && onBatchCopy != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded, color: Colors.white),
|
||||
tooltip: 'Copy',
|
||||
onPressed: onBatchCopy,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download_rounded, color: Colors.white),
|
||||
tooltip: 'Download',
|
||||
onPressed: onBatchDownload,
|
||||
),
|
||||
if (isArchiveScreen)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.unarchive_rounded, color: Colors.white),
|
||||
tooltip: 'Restore',
|
||||
onPressed: onBatchRestore ?? () => onBatchStatus('Completed'),
|
||||
)
|
||||
else
|
||||
if (!isArchiveScreen) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.archive_rounded, color: Colors.white),
|
||||
tooltip: 'Archive',
|
||||
onPressed: onBatchArchive,
|
||||
),
|
||||
if (isAdmin && onBatchDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded, color: Colors.white),
|
||||
tooltip: 'Delete',
|
||||
onPressed: onBatchDelete,
|
||||
),
|
||||
if (isAdmin && onBatchDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded, color: Colors.white),
|
||||
tooltip: 'Delete',
|
||||
onPressed: onBatchDelete,
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download_rounded, color: Colors.white),
|
||||
@@ -143,7 +139,7 @@ class BulkOperationsBar extends StatelessWidget {
|
||||
onSelected: (val) {
|
||||
switch (val) {
|
||||
case 'copy':
|
||||
onBatchCopy();
|
||||
onBatchCopy?.call();
|
||||
break;
|
||||
case 'archive':
|
||||
onBatchArchive();
|
||||
@@ -161,28 +157,18 @@ class BulkOperationsBar extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'copy',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy_rounded, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Text('Copy'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isArchiveScreen)
|
||||
if (!isArchiveScreen && onBatchCopy != null)
|
||||
const PopupMenuItem(
|
||||
value: 'restore',
|
||||
value: 'copy',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.unarchive_rounded, size: 20),
|
||||
Icon(Icons.copy_rounded, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Text('Restore'),
|
||||
Text('Copy'),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
),
|
||||
if (!isArchiveScreen) ...[
|
||||
const PopupMenuItem(
|
||||
value: 'archive',
|
||||
child: Row(
|
||||
@@ -193,17 +179,18 @@ class BulkOperationsBar extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAdmin && onBatchDelete != null)
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline_rounded, size: 20, color: Colors.redAccent),
|
||||
SizedBox(width: 10),
|
||||
Text('Delete', style: TextStyle(color: Colors.redAccent)),
|
||||
],
|
||||
if (isAdmin && onBatchDelete != null)
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline_rounded, size: 20, color: Colors.redAccent),
|
||||
SizedBox(width: 10),
|
||||
Text('Delete', style: TextStyle(color: Colors.redAccent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -30,6 +30,7 @@ void showDocumentPreview({
|
||||
DocumentModel? document,
|
||||
File? localFile,
|
||||
String? localFileName,
|
||||
bool isArchive = false,
|
||||
}) {
|
||||
assert(document != null || (localFile != null && localFileName != null));
|
||||
showDialog(
|
||||
@@ -39,6 +40,7 @@ void showDocumentPreview({
|
||||
document: document,
|
||||
localFile: localFile,
|
||||
localFileName: localFileName,
|
||||
isArchive: isArchive,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -50,11 +52,13 @@ class _DocumentPreviewDialog extends ConsumerStatefulWidget {
|
||||
final DocumentModel? document;
|
||||
final File? localFile;
|
||||
final String? localFileName;
|
||||
final bool isArchive;
|
||||
|
||||
const _DocumentPreviewDialog({
|
||||
this.document,
|
||||
this.localFile,
|
||||
this.localFileName,
|
||||
this.isArchive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -501,8 +505,9 @@ class _DocumentPreviewDialogState
|
||||
}
|
||||
}
|
||||
if (widget.document != null) {
|
||||
final isArchivedDoc = widget.isArchive || widget.document?.status == 'Archived';
|
||||
// Edit action for editable file types
|
||||
if (_isExcel || _isOffice) {
|
||||
if (!isArchivedDoc && (_isExcel || _isOffice)) {
|
||||
directActions.add(_PreviewActionItem(
|
||||
id: 'edit',
|
||||
label: 'Edit',
|
||||
@@ -512,15 +517,17 @@ class _DocumentPreviewDialogState
|
||||
color: Colors.orange.shade700,
|
||||
));
|
||||
}
|
||||
// Upload New Version
|
||||
directActions.add(_PreviewActionItem(
|
||||
id: 'upload_version',
|
||||
label: 'Upload New Version',
|
||||
iconData: Icons.upload_file_rounded,
|
||||
onTap: _isUploadingVersion ? null : _uploadNewVersion,
|
||||
isLoading: _isUploadingVersion,
|
||||
color: AppColors.primaryGreen,
|
||||
));
|
||||
// Upload New Version (hidden for archived documents)
|
||||
if (!isArchivedDoc) {
|
||||
directActions.add(_PreviewActionItem(
|
||||
id: 'upload_version',
|
||||
label: 'Upload New Version',
|
||||
iconData: Icons.upload_file_rounded,
|
||||
onTap: _isUploadingVersion ? null : _uploadNewVersion,
|
||||
isLoading: _isUploadingVersion,
|
||||
color: AppColors.primaryGreen,
|
||||
));
|
||||
}
|
||||
directActions.add(_PreviewActionItem(
|
||||
id: 'print_list',
|
||||
label: 'Add to Print List',
|
||||
@@ -554,14 +561,16 @@ class _DocumentPreviewDialogState
|
||||
));
|
||||
}
|
||||
|
||||
// Always inside More menu: Copy and Delete
|
||||
menuActions.add(_PreviewActionItem(
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
iconData: Icons.copy_rounded,
|
||||
onTap: _copyDocument,
|
||||
));
|
||||
if (ref.watch(authProvider).value?.role != 'teacher') {
|
||||
// More menu: Copy and Delete (both hidden for archived documents)
|
||||
if (!isArchivedDoc) {
|
||||
menuActions.add(_PreviewActionItem(
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
iconData: Icons.copy_rounded,
|
||||
onTap: _copyDocument,
|
||||
));
|
||||
}
|
||||
if (!isArchivedDoc && ref.watch(authProvider).value?.role != 'teacher') {
|
||||
menuActions.add(_PreviewActionItem(
|
||||
id: 'delete',
|
||||
label: 'Delete',
|
||||
|
||||
@@ -106,16 +106,17 @@ class _FileFolderCardState extends State<FileFolderCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'copy',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy, size: 18),
|
||||
SizedBox(width: 12),
|
||||
Text('Copy', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
if (!widget.isArchiveScreen)
|
||||
const PopupMenuItem(
|
||||
value: 'copy',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy, size: 18),
|
||||
SizedBox(width: 12),
|
||||
Text('Copy', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'download',
|
||||
child: Row(
|
||||
@@ -136,7 +137,7 @@ class _FileFolderCardState extends State<FileFolderCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isExcel)
|
||||
if (_isExcel && !widget.isArchiveScreen)
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
@@ -147,16 +148,17 @@ class _FileFolderCardState extends State<FileFolderCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'upload_version',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.upload_file_rounded, size: 18, color: AppColors.primaryGreen),
|
||||
SizedBox(width: 12),
|
||||
Text('Upload New Version', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
if (!widget.isArchiveScreen)
|
||||
const PopupMenuItem(
|
||||
value: 'upload_version',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.upload_file_rounded, size: 18, color: AppColors.primaryGreen),
|
||||
SizedBox(width: 12),
|
||||
Text('Upload New Version', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'version_history',
|
||||
child: Row(
|
||||
@@ -194,43 +196,24 @@ class _FileFolderCardState extends State<FileFolderCard> {
|
||||
),
|
||||
];
|
||||
|
||||
if (widget.userRole != 'teacher') {
|
||||
if (widget.userRole != 'teacher' && !widget.isArchiveScreen) {
|
||||
items.add(const PopupMenuDivider());
|
||||
if (widget.isArchiveScreen) {
|
||||
items.add(
|
||||
const PopupMenuItem(
|
||||
value: 'restore',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.unarchive_outlined,
|
||||
size: 18,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('Restore', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
items.add(
|
||||
const PopupMenuItem(
|
||||
value: 'archive',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.archive_outlined,
|
||||
size: 18,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('Archive', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
items.add(
|
||||
const PopupMenuItem(
|
||||
value: 'archive',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.archive_outlined,
|
||||
size: 18,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('Archive', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
items.add(
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
|
||||
@@ -12,6 +12,8 @@ import '../../../../domain/repositories/document_repository.dart'
|
||||
import '../../../../domain/entities/document_requirement_model.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../../providers/document_provider.dart';
|
||||
import '../../../providers/navigation_provider.dart';
|
||||
import '../../../providers/archives_provider.dart';
|
||||
import 'document_preview_modal.dart';
|
||||
import 'upload_ocr_modal.dart';
|
||||
import 'print_queue_modal.dart';
|
||||
@@ -368,6 +370,51 @@ class StudentProfileModalBody extends ConsumerStatefulWidget {
|
||||
class _StudentProfileModalBodyState
|
||||
extends ConsumerState<StudentProfileModalBody> {
|
||||
_StudentProfileTab _selectedTab = _StudentProfileTab.overview;
|
||||
int _historyLimit = 10;
|
||||
|
||||
void _navigateToDocumentsFolder(BuildContext context, StudentModel student) {
|
||||
Navigator.of(context).pop();
|
||||
if (student.status == 'Enrolled') {
|
||||
ref.read(activeTabProvider.notifier).setTab('Documents');
|
||||
Future.microtask(() {
|
||||
ref.read(openedFolderProvider.notifier).setFolder(
|
||||
OpenedFolderData(
|
||||
id: student.id,
|
||||
name: student.listDisplayName,
|
||||
lrn: student.lrn,
|
||||
),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
if (widget.userRole.toLowerCase() == 'teacher') {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Inactive Record'),
|
||||
content: Text(
|
||||
'${student.listDisplayName} is currently marked as ${student.status}. Archived records are only accessible by Administrators.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ref.read(activeTabProvider.notifier).setTab('Archives');
|
||||
Future.microtask(() {
|
||||
ref.read(openedArchiveFolderProvider.notifier).setFolder(
|
||||
OpenedArchiveFolderData(
|
||||
id: student.id,
|
||||
name: student.listDisplayName,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAddToPrintQueue(DocumentModel doc) async {
|
||||
try {
|
||||
@@ -513,7 +560,10 @@ class _StudentProfileModalBodyState
|
||||
icon: Icons.person_outline_rounded,
|
||||
isSelected: _selectedTab == _StudentProfileTab.overview,
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _selectedTab = _StudentProfileTab.overview),
|
||||
onTap: () => setState(() {
|
||||
_selectedTab = _StudentProfileTab.overview;
|
||||
_historyLimit = 10;
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -524,7 +574,10 @@ class _StudentProfileModalBodyState
|
||||
count: docCount,
|
||||
isSelected: _selectedTab == _StudentProfileTab.documents,
|
||||
isDark: isDark,
|
||||
onTap: () => setState(() => _selectedTab = _StudentProfileTab.documents),
|
||||
onTap: () => setState(() {
|
||||
_selectedTab = _StudentProfileTab.documents;
|
||||
_historyLimit = 10;
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -698,9 +751,13 @@ class _StudentProfileModalBodyState
|
||||
if (student.enrollments != null && student.enrollments!.isNotEmpty) ...[
|
||||
...(() {
|
||||
final sorted = List.from(student.enrollments!);
|
||||
sorted.sort(
|
||||
(a, b) => (b.gradeLevel ?? 0).compareTo(a.gradeLevel ?? 0),
|
||||
);
|
||||
sorted.sort((a, b) {
|
||||
final ayA = (a.yearRange ?? '').toString();
|
||||
final ayB = (b.yearRange ?? '').toString();
|
||||
final cmpYear = ayA.compareTo(ayB);
|
||||
if (cmpYear != 0) return cmpYear;
|
||||
return (a.gradeLevel ?? 0).compareTo(b.gradeLevel ?? 0);
|
||||
});
|
||||
|
||||
final seen = <String>{};
|
||||
final uniqueEnrollments = [];
|
||||
@@ -791,11 +848,13 @@ class _StudentProfileModalBodyState
|
||||
_buildRequirementsStatus(
|
||||
context,
|
||||
missing,
|
||||
onUploadRequirement: (req) => UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
),
|
||||
onUploadRequirement: student.status == 'Enrolled'
|
||||
? (req) => UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
else
|
||||
Container(
|
||||
@@ -846,6 +905,21 @@ class _StudentProfileModalBodyState
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _navigateToDocumentsFolder(context, student),
|
||||
icon: const Icon(Icons.folder_open_rounded, size: 14),
|
||||
label: Text(
|
||||
student.status == 'Enrolled' ? 'Open Folder' : 'Open Archive',
|
||||
style: const TextStyle(fontSize: 11.5),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
side: const BorderSide(color: AppColors.primaryGreen),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => PrintQueueModal.show(context),
|
||||
icon: const Icon(Icons.print_outlined, size: 14),
|
||||
@@ -864,23 +938,24 @@ class _StudentProfileModalBodyState
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
if (student.status == 'Enrolled')
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
),
|
||||
icon: const Icon(Icons.upload_file_rounded, size: 14),
|
||||
label:
|
||||
const Text('Upload File', style: TextStyle(fontSize: 11.5)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.upload_file_rounded, size: 14),
|
||||
label:
|
||||
const Text('Upload File', style: TextStyle(fontSize: 11.5)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -962,11 +1037,13 @@ class _StudentProfileModalBodyState
|
||||
_buildRequirementsStatus(
|
||||
context,
|
||||
missing,
|
||||
onUploadRequirement: (req) => UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
),
|
||||
onUploadRequirement: student.status == 'Enrolled'
|
||||
? (req) => UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
else
|
||||
const Center(
|
||||
@@ -1159,11 +1236,6 @@ class _StudentProfileModalBodyState
|
||||
) {
|
||||
final bool isMobile = MediaQuery.of(context).size.width < 500;
|
||||
final isArchived = doc.status.toLowerCase() == 'archived';
|
||||
final statusColor = isArchived ? Colors.grey : AppColors.success;
|
||||
final statusIcon = isArchived
|
||||
? Icons.archive_outlined
|
||||
: Icons.check_circle_rounded;
|
||||
final statusLabel = isArchived ? 'Archived' : 'Verified';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -1208,32 +1280,34 @@ class _StudentProfileModalBodyState
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border:
|
||||
Border.all(color: statusColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(statusIcon, size: 9, color: statusColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
statusLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
if (isArchived) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border:
|
||||
Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.archive_outlined, size: 9, color: Colors.grey),
|
||||
SizedBox(width: 3),
|
||||
Text(
|
||||
'Archived',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
@@ -1472,10 +1546,59 @@ class _StudentProfileModalBodyState
|
||||
);
|
||||
}
|
||||
|
||||
final displayed = activities.take(_historyLimit).toList();
|
||||
final hasMore = activities.length > _historyLimit;
|
||||
final canLoadLess = _historyLimit > 10;
|
||||
|
||||
return Column(
|
||||
children: activities
|
||||
.map((a) => _buildActivityItemTile(context, a, isDark))
|
||||
.toList(),
|
||||
children: [
|
||||
...displayed
|
||||
.map((a) => _buildActivityItemTile(context, a, isDark)),
|
||||
if (hasMore || canLoadLess) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (hasMore)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_historyLimit += 10;
|
||||
}),
|
||||
icon: const Icon(Icons.expand_more_rounded, size: 16),
|
||||
label: Text(
|
||||
'Load More (${activities.length - _historyLimit} left)',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
side: const BorderSide(color: AppColors.primaryGreen),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
if (hasMore && canLoadLess) const SizedBox(width: 10),
|
||||
if (canLoadLess)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_historyLimit = 10;
|
||||
}),
|
||||
icon: const Icon(Icons.expand_less_rounded, size: 16),
|
||||
label: const Text('Load Less',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1701,46 +1824,84 @@ class _StudentProfileModalBodyState
|
||||
_buildStatusBadge(student.status),
|
||||
],
|
||||
),
|
||||
if (widget.onEditDetails != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: InkWell(
|
||||
onTap: widget.onEditDetails,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen
|
||||
.withValues(alpha: isDark ? 0.20 : 0.10),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
if (widget.onEditDetails != null)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: widget.onEditDetails,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryGreen
|
||||
.withValues(alpha: isDark ? 0.45 : 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.edit_rounded,
|
||||
size: 13, color: AppColors.primaryGreen),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'Edit Details',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryGreen,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen
|
||||
.withValues(alpha: isDark ? 0.20 : 0.10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryGreen
|
||||
.withValues(alpha: isDark ? 0.45 : 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.edit_rounded,
|
||||
size: 13, color: AppColors.primaryGreen),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Edit Details',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryGreen,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.onEditDetails != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToDocumentsFolder(context, student),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF1F5F9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.folder_open_rounded,
|
||||
size: 14,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
student.status == 'Enrolled' ? 'View Folder' : 'View Archive',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Row(
|
||||
children: [
|
||||
@@ -1839,6 +2000,45 @@ class _StudentProfileModalBodyState
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Tooltip(
|
||||
message: student.status == 'Enrolled'
|
||||
? 'Open Folder in Documents'
|
||||
: 'Open Folder in Archives',
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToDocumentsFolder(context, student),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF1F5F9),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.folder_open_rounded,
|
||||
size: 12,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
student.status == 'Enrolled' ? 'View Folder' : 'View Archive',
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1137,7 +1137,7 @@ class _NetworkScanDialogState extends State<_NetworkScanDialog> {
|
||||
}
|
||||
|
||||
Future<void> _scan() async {
|
||||
final found = await ServerDiscoveryService.resolveServerWithFallback(
|
||||
final found = await ServerDiscoveryService.scanLanOnly(
|
||||
onProgress: (msg) {
|
||||
if (mounted) setState(() => _status = msg);
|
||||
},
|
||||
@@ -1145,21 +1145,19 @@ class _NetworkScanDialogState extends State<_NetworkScanDialog> {
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
if (found != null) {
|
||||
final isLan = found.contains('192.168.') ||
|
||||
found.contains('10.') ||
|
||||
found.contains('172.');
|
||||
showSuccessDialog(
|
||||
context,
|
||||
title: isLan ? 'LAN Server Connected' : 'Tunnel Connected',
|
||||
message: isLan
|
||||
? 'Connected to local server: $found'
|
||||
: 'Local server not found. Connected to Cloud Tunnel: $found',
|
||||
title: 'LAN Server Connected',
|
||||
message: 'Successfully discovered and connected to local server: $found',
|
||||
);
|
||||
} else {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Server Not Found',
|
||||
'No TIS RMS server found on your Local Network (LAN) and the Cloud Tunnel domain (${ApiConstants.tunnelUrl}) is currently unreachable.',
|
||||
'LAN Server Not Found',
|
||||
'No TIS RMS server was found on your local network (LAN).\n\n'
|
||||
'• Ensure the TIS RMS Server Manager is running on the host computer.\n'
|
||||
'• Ensure this device is connected to the same Wi-Fi / network.\n\n'
|
||||
'Your connection remains active on Cloud Tunnel (${ApiConstants.tunnelUrl}).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
int _currentPage = 0;
|
||||
int _rowsPerPage = 10;
|
||||
int _selectedViewMode = 0; // 0: DepEd Transparency Board, 1: Compliance & Analytics, 2: Combined
|
||||
int _selectedViewMode = 0; // 0: DepEd Transparency Board, 1: Document Compliance
|
||||
|
||||
// Table search & sort state
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
@@ -273,19 +273,22 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.all(isAndroid ? 14 : AppSizes.p24),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isAndroid ? 14 : 16,
|
||||
vertical: isAndroid ? 10 : 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildViewModeToggle(),
|
||||
const SizedBox(height: AppSizes.p20),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (_selectedViewMode == 0 || _selectedViewMode == 2) ...[
|
||||
if (_selectedViewMode == 0) ...[
|
||||
const TransparencyBoardSection(),
|
||||
const SizedBox(height: AppSizes.p32),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
if (_selectedViewMode == 1 || _selectedViewMode == 2) ...[
|
||||
if (_selectedViewMode == 1) ...[
|
||||
statsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => const Center(
|
||||
@@ -307,17 +310,17 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildComplianceHeader(context, data),
|
||||
const SizedBox(height: AppSizes.p20),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 1. Filter Panel (collapsible) - hidden on Android
|
||||
if (!isAndroid) ...[
|
||||
_buildFilterPanel(context),
|
||||
const SizedBox(height: AppSizes.p20),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
|
||||
// 2. Focused Compliance KPI Cards (Bento on Windows, normal responsive grid on mobile)
|
||||
_buildMetricsGrid(data, isWindowsApp: isWindowsApp),
|
||||
const SizedBox(height: AppSizes.p24),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 3. Analytics Bento Block 1: Grade Compliance & Missing Requirements Breakdown
|
||||
if (isWindowsApp)
|
||||
@@ -355,7 +358,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSizes.p24),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 4. Analytics Bento Block 2: Status Breakdown & Historical Trends
|
||||
if (isWindowsApp)
|
||||
@@ -369,7 +372,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
isDesktop: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSizes.p20),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: _buildYearlyComparisonChart(
|
||||
@@ -385,11 +388,11 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
data.studentCounts,
|
||||
isDesktop: false,
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
const SizedBox(height: 12),
|
||||
_buildYearlyComparisonChart(isDesktop: false),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSizes.p24),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 5. Interactive Student Compliance Table (Full Width)
|
||||
_buildComplianceTable(data),
|
||||
@@ -429,14 +432,6 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
icon: Icons.fact_check_rounded,
|
||||
accentColor: const Color(0xFF0284C7),
|
||||
),
|
||||
(
|
||||
index: 2,
|
||||
title: 'Combined View',
|
||||
shortTitle: 'Combined',
|
||||
subtitle: 'Full DepEd & Compliance',
|
||||
icon: Icons.view_agenda_rounded,
|
||||
accentColor: const Color(0xFF7C3AED),
|
||||
),
|
||||
];
|
||||
|
||||
void onSelectTab(int index) {
|
||||
@@ -485,7 +480,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
curve: Curves.easeInOut,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 14,
|
||||
vertical: isCompact ? 10 : 10,
|
||||
vertical: isCompact ? 7 : 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? tab.accentColor : Colors.transparent,
|
||||
@@ -1983,8 +1978,8 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: isDesktop ? 480 : null,
|
||||
padding: const EdgeInsets.all(AppSizes.p24),
|
||||
height: isDesktop ? 300 : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: _cardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -3003,7 +2998,10 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSizes.p24),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -3054,65 +3052,75 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── Search Field ───────────────────────────────────────────
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (val) => setState(() {
|
||||
_searchQuery = val;
|
||||
_currentPage = 0;
|
||||
}),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppColors.darkTextPrimary : null,
|
||||
// ── Search Field (High-visibility for all themes) ───────────
|
||||
Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? AppColors.darkBorder
|
||||
: const Color(0xFFCBD5E1),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(
|
||||
alpha: isDark ? 0.25 : 0.05,
|
||||
),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by student name or LRN...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade400,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (val) => setState(() {
|
||||
_searchQuery = val;
|
||||
_currentPage = 0;
|
||||
}),
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
size: 18,
|
||||
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade400,
|
||||
),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade500,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
_currentPage = 0;
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: isDark ? AppColors.darkSurface2 : Colors.grey.shade50,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by student name or LRN...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||
),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
borderSide: BorderSide(
|
||||
prefixIcon: const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 20,
|
||||
color: AppColors.primaryGreen,
|
||||
width: 1.5,
|
||||
),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
splashRadius: 16,
|
||||
onPressed: () => setState(() {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
_currentPage = 0;
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -3177,6 +3185,10 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
width: isWideTable ? tableConstraints.maxWidth : null,
|
||||
child: DataTable(
|
||||
showCheckboxColumn: false,
|
||||
headingRowHeight: 40,
|
||||
dataRowMinHeight: 38,
|
||||
dataRowMaxHeight: 42,
|
||||
horizontalMargin: 14,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
AppColors.primaryGreen.withValues(alpha: 0.04),
|
||||
),
|
||||
@@ -3679,8 +3691,8 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: isDesktop ? 480 : null,
|
||||
padding: const EdgeInsets.all(AppSizes.p24),
|
||||
height: isDesktop ? 360 : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: _cardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -3769,7 +3781,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
final Widget chartWidget = SizedBox(
|
||||
width: chartWidth,
|
||||
height: isDesktop ? 260 : 300,
|
||||
height: isDesktop ? 175 : 200,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
maxY: 140,
|
||||
@@ -3911,8 +3923,8 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: isDesktop ? 480 : null,
|
||||
padding: const EdgeInsets.all(AppSizes.p24),
|
||||
height: isDesktop ? 300 : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: _cardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -4080,13 +4092,12 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: isDesktop ? 480 : null,
|
||||
padding: const EdgeInsets.all(AppSizes.p24),
|
||||
height: isDesktop ? 360 : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: _cardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Chart Title & Filter Row ──────────────────────────────────────
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -4254,14 +4265,15 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
|
||||
final chartContent = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final bool hasScroll = constraints.maxWidth < (data.length * 150.0);
|
||||
final double minYearWidth = isDesktop ? 100.0 : 130.0;
|
||||
final double requiredWidth = data.length * minYearWidth;
|
||||
final bool hasScroll = constraints.maxWidth < requiredWidth;
|
||||
final double chartWidth = hasScroll
|
||||
? (data.length * 150.0)
|
||||
? requiredWidth
|
||||
: constraints.maxWidth;
|
||||
final double scrollExtras = hasScroll ? 36.0 : 0.0;
|
||||
final double chartHeight = isDesktop
|
||||
? (hasScroll
|
||||
? (constraints.maxHeight - 48).clamp(140.0, 220.0)
|
||||
: constraints.maxHeight.clamp(160.0, 220.0))
|
||||
? (constraints.maxHeight - scrollExtras).clamp(80.0, 220.0)
|
||||
: 250;
|
||||
final Widget chartWidget = SizedBox(
|
||||
width: chartWidth,
|
||||
@@ -4405,15 +4417,20 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
return Center(child: chartWidget);
|
||||
}
|
||||
|
||||
final scrollableChart = SingleChildScrollView(
|
||||
controller: _chartHorizontalScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: chartWidget,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _chartHorizontalScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: chartWidget,
|
||||
),
|
||||
if (isDesktop)
|
||||
Expanded(child: scrollableChart)
|
||||
else
|
||||
scrollableChart,
|
||||
const SizedBox(height: 4),
|
||||
_CustomHorizontalScrollBar(
|
||||
controller: _chartHorizontalScrollController,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,6 +32,7 @@ import '../../../core/services/app_update_service.dart';
|
||||
import '../capstone_members/capstone_members_screen.dart';
|
||||
import '../documentation/documentation_screen.dart';
|
||||
import '../setup/setup_wizard_screen.dart';
|
||||
import 'widgets/report_issue_modal.dart';
|
||||
class TitleCaseTextInputFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
@@ -1870,38 +1871,31 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
|
||||
// Light / Dark Theme selector (disabled/inactive when System is toggled on)
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: isSystem ? 0.38 : 1.0,
|
||||
child: IgnorePointer(
|
||||
ignoring: isSystem,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildThemeOptionCard(
|
||||
label: 'Light',
|
||||
icon: Icons.light_mode_rounded,
|
||||
value: 'light',
|
||||
currentValue: currentKey,
|
||||
isActive: !isSystem,
|
||||
onSelect: () => themeNotifier.setThemeMode('light'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildThemeOptionCard(
|
||||
label: 'Dark',
|
||||
icon: Icons.dark_mode_rounded,
|
||||
value: 'dark',
|
||||
currentValue: currentKey,
|
||||
isActive: !isSystem,
|
||||
onSelect: () => themeNotifier.setThemeMode('dark'),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Light / Dark Theme selector
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildThemeOptionCard(
|
||||
label: 'Light',
|
||||
icon: Icons.light_mode_rounded,
|
||||
value: 'light',
|
||||
currentValue: isSystem ? null : currentKey,
|
||||
isActive: !isSystem,
|
||||
onSelect: () => themeNotifier.setThemeMode('light'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildThemeOptionCard(
|
||||
label: 'Dark',
|
||||
icon: Icons.dark_mode_rounded,
|
||||
value: 'dark',
|
||||
currentValue: isSystem ? null : currentKey,
|
||||
isActive: !isSystem,
|
||||
onSelect: () => themeNotifier.setThemeMode('dark'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -2379,28 +2373,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
const url = 'https://github.com/ssbg04/TIS_RMS/issues';
|
||||
final uri = Uri.parse(url);
|
||||
bool launched = false;
|
||||
try {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched) {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!launched) {
|
||||
await Clipboard.setData(const ClipboardData(text: url));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Issue tracker link copied to clipboard: https://github.com/ssbg04/TIS_RMS/issues'),
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => ReportIssueModal.show(context),
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 15),
|
||||
label: const Text('Report an Issue'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -2411,39 +2384,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
const url = 'https://github.com/ssbg04/TIS_RMS';
|
||||
final uri = Uri.parse(url);
|
||||
bool launched = false;
|
||||
try {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched) {
|
||||
launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!launched) {
|
||||
await Clipboard.setData(const ClipboardData(text: url));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('GitHub repository link copied to clipboard: https://github.com/ssbg04/TIS_RMS'),
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.code_rounded, size: 15),
|
||||
label: const Text('GitHub Repository'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: isDark ? AppColors.darkTextPrimary : Colors.black87,
|
||||
side: BorderSide(color: isDark ? AppColors.darkBorder : Colors.grey.shade300),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
textStyle: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
@@ -2999,7 +2939,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required String value,
|
||||
required String currentValue,
|
||||
required String? currentValue,
|
||||
required bool isActive,
|
||||
required VoidCallback onSelect,
|
||||
}) {
|
||||
@@ -3007,7 +2947,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
final isSelected = isActive && (value == currentValue);
|
||||
|
||||
return InkWell(
|
||||
onTap: isActive ? onSelect : null,
|
||||
onTap: onSelect,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -3430,6 +3370,19 @@ class _EditScheduleDatesDialogState
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_startDate != null)
|
||||
InkWell(
|
||||
onTap: () => setState(() => _startDate = null),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -3489,6 +3442,19 @@ class _EditScheduleDatesDialogState
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_endDate != null)
|
||||
InkWell(
|
||||
onTap: () => setState(() => _endDate = null),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 16,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -3708,6 +3674,7 @@ class _GracePeriodSettingsSection extends ConsumerStatefulWidget {
|
||||
|
||||
class _GracePeriodSettingsSectionState extends ConsumerState<_GracePeriodSettingsSection> {
|
||||
DateTime? _selectedCutoffDate;
|
||||
DateTime? _initialCutoffDate;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
@@ -3731,6 +3698,7 @@ class _GracePeriodSettingsSectionState extends ConsumerState<_GracePeriodSetting
|
||||
final parsed = DateTime.tryParse(dtStr);
|
||||
if (parsed != null) {
|
||||
_selectedCutoffDate = parsed;
|
||||
_initialCutoffDate = parsed;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3746,10 +3714,12 @@ class _GracePeriodSettingsSectionState extends ConsumerState<_GracePeriodSetting
|
||||
final start = DateTime.tryParse(active!.startDate!);
|
||||
if (start != null) {
|
||||
_selectedCutoffDate = start.add(Duration(days: graceDays));
|
||||
_initialCutoffDate = _selectedCutoffDate;
|
||||
return;
|
||||
}
|
||||
}
|
||||
_selectedCutoffDate = DateTime.now().add(Duration(days: graceDays));
|
||||
_initialCutoffDate = _selectedCutoffDate;
|
||||
}
|
||||
|
||||
String _formatDisplayDate(DateTime? dt) {
|
||||
@@ -3817,6 +3787,9 @@ class _GracePeriodSettingsSectionState extends ConsumerState<_GracePeriodSetting
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_initialCutoffDate = _selectedCutoffDate;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@@ -3864,6 +3837,12 @@ class _GracePeriodSettingsSectionState extends ConsumerState<_GracePeriodSetting
|
||||
}
|
||||
}
|
||||
|
||||
final bool isCutoffChanged = _selectedCutoffDate != null &&
|
||||
(_initialCutoffDate == null ||
|
||||
_selectedCutoffDate!.year != _initialCutoffDate!.year ||
|
||||
_selectedCutoffDate!.month != _initialCutoffDate!.month ||
|
||||
_selectedCutoffDate!.day != _initialCutoffDate!.day);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -4075,36 +4054,38 @@ class _GracePeriodSettingsSectionState extends ConsumerState<_GracePeriodSetting
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isCutoffChanged) ...[
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Save Button
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : () => _saveCutoffDate(activeYear),
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.save_outlined, size: 16),
|
||||
label: Text(
|
||||
_isSaving ? 'Saving...' : 'Save Cutoff Date',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
elevation: 0,
|
||||
// Save Button
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : () => _saveCutoffDate(activeYear),
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.save_outlined, size: 16),
|
||||
label: Text(
|
||||
_isSaving ? 'Saving...' : 'Save Cutoff Date',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/network/api_constants.dart';
|
||||
import '../../../../core/services/haptic_service.dart';
|
||||
import '../../../shared/modals/custom_modal.dart';
|
||||
|
||||
class ReportIssueModal extends StatefulWidget {
|
||||
const ReportIssueModal({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||
builder: (_) => const ReportIssueModal(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ReportIssueModal> createState() => _ReportIssueModalState();
|
||||
}
|
||||
|
||||
class _ReportIssueModalState extends State<ReportIssueModal> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
String _category = 'bug'; // 'bug', 'feature', 'question'
|
||||
String _urgency = 'medium'; // 'high', 'medium', 'low'
|
||||
final List<PlatformFile> _attachments = [];
|
||||
bool _isSending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickAttachments() async {
|
||||
HapticService.light();
|
||||
try {
|
||||
final result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['png', 'jpg', 'jpeg', 'pdf', 'log', 'txt'],
|
||||
);
|
||||
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
setState(() {
|
||||
for (final f in result.files) {
|
||||
if (!_attachments.any((a) => a.path == f.path && a.name == f.name)) {
|
||||
_attachments.add(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to pick attachments: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _removeAttachment(int index) {
|
||||
HapticService.light();
|
||||
setState(() {
|
||||
_attachments.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSend() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
HapticService.medium();
|
||||
setState(() => _isSending = true);
|
||||
|
||||
const leadEmail = 'crischarlesgarcia345@gmail.com';
|
||||
final title = _titleController.text.trim();
|
||||
final description = _descriptionController.text.trim();
|
||||
|
||||
final categoryLabel = _category.toUpperCase();
|
||||
final urgencyLabel = _urgency.toUpperCase();
|
||||
final subject = '[TIS RMS] [$categoryLabel] [$urgencyLabel] $title';
|
||||
|
||||
final attachmentNames = _attachments.map((a) => '- ${a.name} (${(a.size / 1024).toStringAsFixed(1)} KB)').join('\n');
|
||||
final attachmentNotice = _attachments.isNotEmpty
|
||||
? '\n\nAttachments to include manually if not auto-attached:\n$attachmentNames\n'
|
||||
: '';
|
||||
|
||||
final body = '''Category: $categoryLabel
|
||||
Urgency: $urgencyLabel
|
||||
Title: $title
|
||||
|
||||
Description:
|
||||
$description
|
||||
$attachmentNotice
|
||||
---
|
||||
System Info:
|
||||
Platform: ${ApiConstants.clientPlatform} (${ApiConstants.clientDeviceName})
|
||||
Reported At: ${DateTime.now().toLocal().toString()}
|
||||
Lead Developer: $leadEmail
|
||||
''';
|
||||
|
||||
final mailtoUri = Uri(
|
||||
scheme: 'mailto',
|
||||
path: leadEmail,
|
||||
queryParameters: {
|
||||
'subject': subject,
|
||||
'body': body,
|
||||
},
|
||||
);
|
||||
|
||||
bool launched = false;
|
||||
try {
|
||||
launched = await launchUrl(mailtoUri, mode: LaunchMode.externalApplication);
|
||||
if (!launched) {
|
||||
launched = await launchUrl(mailtoUri, mode: LaunchMode.platformDefault);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isSending = false);
|
||||
Navigator.of(context).pop();
|
||||
|
||||
if (launched) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.mark_email_read_outlined, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Email client opened! Ready to send to $leadEmail.',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Clipboard.setData(ClipboardData(text: 'To: $leadEmail\nSubject: $subject\n\n$body'));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Could not launch email client directly. Issue report copied to clipboard! Send to: $leadEmail',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
backgroundColor: AppColors.warning,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isNarrow = MediaQuery.of(context).size.width < 500;
|
||||
|
||||
return CustomModal(
|
||||
title: 'Report an Issue / Feedback',
|
||||
icon: Icons.bug_report_rounded,
|
||||
maxWidth: 580,
|
||||
content: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(isNarrow ? 16 : AppSizes.p24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Notice banner
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.primaryGreen.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.alternate_email_rounded, size: 18, color: AppColors.primaryGreen),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Direct to Lead Developer (crischarlesgarcia345@gmail.com). Clicking Send prepares an email ready in your mail client.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade800,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category Selector
|
||||
Text(
|
||||
'CATEGORY',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildChoiceChip(
|
||||
label: 'Bug Report',
|
||||
icon: Icons.bug_report_outlined,
|
||||
value: 'bug',
|
||||
selected: _category == 'bug',
|
||||
selectedColor: AppColors.error,
|
||||
onSelected: (val) => setState(() => _category = 'bug'),
|
||||
),
|
||||
_buildChoiceChip(
|
||||
label: 'Feature Request',
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
value: 'feature',
|
||||
selected: _category == 'feature',
|
||||
selectedColor: AppColors.primaryGreen,
|
||||
onSelected: (val) => setState(() => _category = 'feature'),
|
||||
),
|
||||
_buildChoiceChip(
|
||||
label: 'Question / Support',
|
||||
icon: Icons.help_outline_rounded,
|
||||
value: 'question',
|
||||
selected: _category == 'question',
|
||||
selectedColor: Colors.blueAccent,
|
||||
onSelected: (val) => setState(() => _category = 'question'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Urgency Selector
|
||||
Text(
|
||||
'URGENCY LEVEL',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildChoiceChip(
|
||||
label: 'Low',
|
||||
icon: Icons.low_priority_rounded,
|
||||
value: 'low',
|
||||
selected: _urgency == 'low',
|
||||
selectedColor: Colors.teal,
|
||||
onSelected: (val) => setState(() => _urgency = 'low'),
|
||||
),
|
||||
_buildChoiceChip(
|
||||
label: 'Medium',
|
||||
icon: Icons.drag_handle_rounded,
|
||||
value: 'medium',
|
||||
selected: _urgency == 'medium',
|
||||
selectedColor: Colors.orange,
|
||||
onSelected: (val) => setState(() => _urgency = 'medium'),
|
||||
),
|
||||
_buildChoiceChip(
|
||||
label: 'High (Urgent)',
|
||||
icon: Icons.priority_high_rounded,
|
||||
value: 'high',
|
||||
selected: _urgency == 'high',
|
||||
selectedColor: AppColors.error,
|
||||
onSelected: (val) => setState(() => _urgency = 'high'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Issue Title
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Title / Subject *',
|
||||
hintText: 'Brief summary of the issue or feedback',
|
||||
prefixIcon: const Icon(Icons.title_rounded, size: 20),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) {
|
||||
return 'Please enter a title';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description *',
|
||||
hintText: 'Describe what happened, steps to reproduce, or details...',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) {
|
||||
return 'Please describe the issue or feedback';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Screenshots / Attachments
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'SCREENSHOTS / ATTACHMENTS (OPTIONAL)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _pickAttachments,
|
||||
icon: const Icon(Icons.attach_file_rounded, size: 16),
|
||||
label: const Text('Add Files', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_attachments.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: List.generate(_attachments.length, (index) {
|
||||
final item = _attachments[index];
|
||||
return Chip(
|
||||
avatar: const Icon(Icons.insert_drive_file_outlined, size: 16),
|
||||
label: Text(
|
||||
item.name,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
deleteIcon: const Icon(Icons.close, size: 16),
|
||||
onDeleted: () => _removeAttachment(index),
|
||||
backgroundColor: isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSending ? null : () => Navigator.of(context).pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSending ? null : _handleSend,
|
||||
icon: _isSending
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.send_rounded, size: 18),
|
||||
label: Text(_isSending ? 'Preparing...' : 'Send via Email'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChoiceChip({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required String value,
|
||||
required bool selected,
|
||||
required Color selectedColor,
|
||||
required ValueChanged<bool> onSelected,
|
||||
}) {
|
||||
return ChoiceChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: selected ? Colors.white : selectedColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(color: selected ? Colors.white : null, fontSize: 12.5)),
|
||||
],
|
||||
),
|
||||
selected: selected,
|
||||
selectedColor: selectedColor,
|
||||
onSelected: onSelected,
|
||||
showCheckmark: false,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/utils/ip_location_helper.dart';
|
||||
import '../../../../domain/entities/user_model.dart';
|
||||
import '../../../providers/sessions_provider.dart';
|
||||
import 'change_password_modal.dart';
|
||||
@@ -28,6 +29,9 @@ class SecuritySection extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SecuritySectionState extends ConsumerState<SecuritySection> {
|
||||
bool _isDevicesExpanded = false;
|
||||
static const int _devicesDisplayLimit = 3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -206,8 +210,9 @@ class _SecuritySectionState extends ConsumerState<SecuritySection> {
|
||||
final sessionsAsync = ref.watch(sessionsProvider);
|
||||
return sessionsAsync.maybeWhen(
|
||||
data: (sessions) {
|
||||
if (sessions.isEmpty) return const SizedBox.shrink();
|
||||
final count = sessions.length;
|
||||
final rememberedSessions = sessions.where((s) => s['remember_me'] != false).toList();
|
||||
if (rememberedSessions.isEmpty) return const SizedBox.shrink();
|
||||
final count = rememberedSessions.length;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
@@ -234,17 +239,49 @@ class _SecuritySectionState extends ConsumerState<SecuritySection> {
|
||||
|
||||
return sessionsAsync.when(
|
||||
data: (sessions) {
|
||||
if (sessions.isEmpty) {
|
||||
// Only show devices that have enabled "remember me"
|
||||
final rememberedSessions = sessions.where((s) => s['remember_me'] != false).toList();
|
||||
if (rememberedSessions.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text('No active sessions found.', style: TextStyle(color: Colors.grey)),
|
||||
child: Text('No active devices found with "Remember Me" enabled.', style: TextStyle(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
|
||||
final hasMore = rememberedSessions.length > _devicesDisplayLimit;
|
||||
final visibleSessions = (_isDevicesExpanded || !hasMore)
|
||||
? rememberedSessions
|
||||
: rememberedSessions.take(_devicesDisplayLimit).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...sessions.map((session) => _buildSessionItem(context, ref, session)),
|
||||
if (sessions.length > 1) ...[
|
||||
...visibleSessions.map((session) => _buildSessionItem(context, ref, session)),
|
||||
if (hasMore) ...[
|
||||
const SizedBox(height: 6),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() => _isDevicesExpanded = !_isDevicesExpanded);
|
||||
},
|
||||
icon: Icon(
|
||||
_isDevicesExpanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
_isDevicesExpanded
|
||||
? 'Load less'
|
||||
: 'Load more devices (${rememberedSessions.length - _devicesDisplayLimit} more)',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (rememberedSessions.length > 1) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: isNarrow ? Alignment.centerLeft : Alignment.centerRight,
|
||||
@@ -433,6 +470,33 @@ class _SecuritySectionState extends ConsumerState<SecuritySection> {
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: IpLocationHelper.resolveLocation(ipAddress),
|
||||
initialData: IpLocationHelper.getFastLocation(ipAddress),
|
||||
builder: (context, snapshot) {
|
||||
final loc = snapshot.data;
|
||||
if (loc == null || loc.isEmpty) return const SizedBox.shrink();
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
loc,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'•',
|
||||
|
||||
@@ -194,9 +194,9 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
(y) => y.status.toLowerCase() == 'active',
|
||||
orElse: () => years.isNotEmpty ? years.first : AcademicYearModel(id: 0, yearRange: '', status: ''),
|
||||
);
|
||||
// Apply enrolled status + current school year
|
||||
// Apply active school year without hardcoding status so all statuses in this SY stay visible
|
||||
ref.read(studentQueryProvider.notifier).setFilters(
|
||||
status: 'Enrolled',
|
||||
status: '',
|
||||
schoolYear: active.yearRange.isNotEmpty ? active.yearRange : '',
|
||||
gradeLevel: '',
|
||||
section: '',
|
||||
@@ -217,7 +217,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
|
||||
void _onSearchSubmitted(String query) {
|
||||
_debounce?.cancel();
|
||||
if (widget.userRole != 'teacher' && query.trim().isNotEmpty && _viewTab != StudentViewTab.all) {
|
||||
if (query.trim().isNotEmpty && _viewTab != StudentViewTab.all) {
|
||||
if (mounted) setState(() => _viewTab = StudentViewTab.all);
|
||||
ref.read(studentViewTabProvider.notifier).state = StudentViewTab.all;
|
||||
ref.read(studentQueryProvider.notifier).setFilters(
|
||||
@@ -1053,6 +1053,18 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen<StudentViewTab>(studentViewTabProvider, (previous, next) {
|
||||
if (mounted && _viewTab != next) {
|
||||
setState(() => _viewTab = next);
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen<StudentQueryParams>(studentQueryProvider, (previous, next) {
|
||||
if (next.search.isEmpty && _searchController.text.isNotEmpty) {
|
||||
_searchController.clear();
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen<AsyncValue<List<AcademicYearModel>>>(academicYearsListProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
final years = next.asData?.value;
|
||||
@@ -1065,7 +1077,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
);
|
||||
if (active.yearRange.isNotEmpty && currentQuery.schoolYear != active.yearRange) {
|
||||
ref.read(studentQueryProvider.notifier).setFilters(
|
||||
status: 'Enrolled',
|
||||
status: currentQuery.status,
|
||||
schoolYear: active.yearRange,
|
||||
);
|
||||
}
|
||||
@@ -1549,7 +1561,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
));
|
||||
}
|
||||
|
||||
if (_viewTab == StudentViewTab.all && query.status.isNotEmpty) {
|
||||
if (query.status.isNotEmpty) {
|
||||
chips.add(_buildFilterChip(
|
||||
label: 'Status: ${query.status}',
|
||||
onDeleted: () => ref.read(studentQueryProvider.notifier).setStatus(''),
|
||||
@@ -1676,7 +1688,9 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
} catch (_) {
|
||||
activeYear = years.isNotEmpty ? years.first : null;
|
||||
}
|
||||
final yearLabel = activeYear != null ? ' · ${activeYear.yearRange}' : '';
|
||||
final tabLabel = activeYear != null && activeYear.yearRange.isNotEmpty
|
||||
? (isMobile ? activeYear.yearRange : 'S.Y. ${activeYear.yearRange}')
|
||||
: (isMobile ? 'Active S.Y.' : 'Active School Year');
|
||||
|
||||
final isEnrolled = _viewTab == StudentViewTab.enrolled;
|
||||
|
||||
@@ -1697,26 +1711,26 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
_buildSegmentedTabItem(
|
||||
icon: Icons.school_outlined,
|
||||
activeIcon: Icons.school_rounded,
|
||||
label: isMobile ? 'Enrolled' : 'Enrolled$yearLabel',
|
||||
isSelected: widget.userRole == 'teacher' ? true : isEnrolled,
|
||||
label: tabLabel,
|
||||
isSelected: isEnrolled,
|
||||
isDark: isDark,
|
||||
isMobile: isMobile,
|
||||
isMobileOrAndroid: isMobileOrAndroid,
|
||||
onTap: widget.userRole == 'teacher' ? () {} : () => _applyViewTab(StudentViewTab.enrolled),
|
||||
onTap: () => _applyViewTab(StudentViewTab.enrolled),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
_buildSegmentedTabItem(
|
||||
icon: Icons.people_outline_rounded,
|
||||
activeIcon: Icons.people_alt_rounded,
|
||||
label: widget.userRole == 'teacher'
|
||||
? (isMobile ? 'Assigned' : 'All Assigned')
|
||||
: (isMobile ? 'All' : 'All Students'),
|
||||
isSelected: !isEnrolled,
|
||||
isDark: isDark,
|
||||
isMobile: isMobile,
|
||||
isMobileOrAndroid: isMobileOrAndroid,
|
||||
onTap: () => _applyViewTab(StudentViewTab.all),
|
||||
),
|
||||
if (widget.userRole != 'teacher') ...[
|
||||
const SizedBox(width: 2),
|
||||
_buildSegmentedTabItem(
|
||||
icon: Icons.people_outline_rounded,
|
||||
activeIcon: Icons.people_alt_rounded,
|
||||
label: isMobile ? 'All' : 'All Students',
|
||||
isSelected: !isEnrolled,
|
||||
isDark: isDark,
|
||||
isMobile: isMobile,
|
||||
isMobileOrAndroid: isMobileOrAndroid,
|
||||
onTap: () => _applyViewTab(StudentViewTab.all),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1885,6 +1899,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildDesktopCard(
|
||||
BuildContext context,
|
||||
StudentModel s,
|
||||
@@ -2016,9 +2031,8 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// Name & LRN (flex 3)
|
||||
// Name & LRN
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -2050,11 +2064,11 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Grade & Section (flex 2)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
// Grade & Section (aligned fixed-width column)
|
||||
SizedBox(
|
||||
width: 170,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -2066,7 +2080,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.gradeSection,
|
||||
maxLines: 1,
|
||||
@@ -2083,11 +2097,11 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Document completion counter (flex 2)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
// Document completion counter (aligned fixed-width column)
|
||||
SizedBox(
|
||||
width: 195,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -2186,64 +2200,70 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// 4Ps & Status
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (s.is4ps) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark
|
||||
? const Color(0xFF8B8ED8)
|
||||
: AppColors.fourPs)
|
||||
.withValues(alpha: isDark ? 0.2 : 0.08),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
// 4Ps & Status (aligned fixed-width column)
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (s.is4ps) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark
|
||||
? const Color(0xFF8B8ED8)
|
||||
: AppColors.fourPs)
|
||||
.withValues(alpha: isDark ? 0.6 : 0.35),
|
||||
.withValues(alpha: isDark ? 0.2 : 0.08),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
color: (isDark
|
||||
? const Color(0xFF8B8ED8)
|
||||
: AppColors.fourPs)
|
||||
.withValues(alpha: isDark ? 0.6 : 0.35),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'4Ps',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? const Color(0xFF8B8ED8)
|
||||
: AppColors.fourPs,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'4Ps',
|
||||
style: TextStyle(
|
||||
color: isDark
|
||||
? const Color(0xFF8B8ED8)
|
||||
: AppColors.fourPs,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
_StatusChip(status: s.status),
|
||||
],
|
||||
_StatusChip(status: s.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Open Profile chevron / more options
|
||||
Tooltip(
|
||||
message: 'View Profile',
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 18,
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Tooltip(
|
||||
message: 'View Profile',
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 18,
|
||||
),
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
onPressed: () => _viewProfile(s),
|
||||
),
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.textSecondary,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
onPressed: () => _viewProfile(s),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2949,6 +2969,97 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
|
||||
);
|
||||
}
|
||||
final query = ref.watch(studentQueryProvider);
|
||||
final isEnrolledTab = _viewTab == StudentViewTab.enrolled;
|
||||
|
||||
if (isEnrolledTab) {
|
||||
final years = ref.watch(academicYearsListProvider).asData?.value ?? [];
|
||||
AcademicYearModel? activeYear;
|
||||
try {
|
||||
activeYear = years.firstWhere((y) => y.status.toLowerCase() == 'active');
|
||||
} catch (_) {
|
||||
activeYear = years.isNotEmpty ? years.first : null;
|
||||
}
|
||||
final syTitle = activeYear != null && activeYear.yearRange.isNotEmpty
|
||||
? 'S.Y. ${activeYear.yearRange}'
|
||||
: 'Active School Year';
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF1F5F9),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.school_outlined,
|
||||
size: 48,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No Students in $syTitle',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
query.status.isNotEmpty || query.gradeLevel.isNotEmpty || query.section.isNotEmpty || query.search.isNotEmpty
|
||||
? 'No students match your selected filters in $syTitle.'
|
||||
: 'There are no students enrolled in $syTitle yet.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (widget.userRole != 'teacher') ...[
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openModal(),
|
||||
icon: const Icon(Icons.person_add_rounded, size: 18),
|
||||
label: const Text('Add Student'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
side: const BorderSide(color: AppColors.primaryGreen),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _applyViewTab(StudentViewTab.all),
|
||||
icon: const Icon(Icons.people_alt_rounded, size: 18),
|
||||
label: const Text('Enroll Existing Student (All Students)'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final hasActiveFilter = query.search.isNotEmpty ||
|
||||
query.gradeLevel.isNotEmpty ||
|
||||
query.section.isNotEmpty ||
|
||||
|
||||
@@ -73,7 +73,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedGradeLevel = 7;
|
||||
_lrnController = TextEditingController(text: '308035');
|
||||
_lrnController = TextEditingController();
|
||||
_firstNameController = TextEditingController();
|
||||
_middleNameController = TextEditingController();
|
||||
_lastNameController = TextEditingController();
|
||||
@@ -99,8 +99,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
}
|
||||
|
||||
bool get _hasAnyData {
|
||||
return (_lrnController.text.isNotEmpty &&
|
||||
_lrnController.text != '308035') ||
|
||||
return _lrnController.text.isNotEmpty ||
|
||||
_firstNameController.text.isNotEmpty ||
|
||||
_lastNameController.text.isNotEmpty;
|
||||
}
|
||||
@@ -758,7 +757,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
|
||||
_errorMessage = null;
|
||||
_ocrScannedFile = null;
|
||||
_ocrSavedEnrollments.clear();
|
||||
_lrnController.text = '308035';
|
||||
_lrnController.clear();
|
||||
_firstNameController.clear();
|
||||
_middleNameController.clear();
|
||||
_lastNameController.clear();
|
||||
|
||||
@@ -77,7 +77,7 @@ class _OcrItem {
|
||||
required this.filePath,
|
||||
required this.fileName,
|
||||
this.status = _FileStatus.pending,
|
||||
this.lrn = '308035',
|
||||
this.lrn = '',
|
||||
this.firstName = '',
|
||||
this.middleName = '',
|
||||
this.lastName = '',
|
||||
@@ -3882,7 +3882,7 @@ class _BulkOcrImportDialogState extends ConsumerState<BulkOcrImportDialog> {
|
||||
return SizedBox(
|
||||
width: 160,
|
||||
child: TextFormField(
|
||||
initialValue: item.lrn.isNotEmpty ? item.lrn : '308035',
|
||||
initialValue: item.lrn,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
|
||||
@@ -373,7 +373,13 @@ class _EditEnrollmentModalState extends ConsumerState<EditEnrollmentModal> {
|
||||
final enrollments =
|
||||
studentDetail?.enrollments ?? <EnrollmentModel>[];
|
||||
final sortedEnrollments = List<EnrollmentModel>.from(enrollments)
|
||||
..sort((a, b) => b.gradeLevel.compareTo(a.gradeLevel));
|
||||
..sort((a, b) {
|
||||
final ayA = (a.yearRange ?? '').toString();
|
||||
final ayB = (b.yearRange ?? '').toString();
|
||||
final cmpYear = ayA.compareTo(ayB);
|
||||
if (cmpYear != 0) return cmpYear;
|
||||
return a.gradeLevel.compareTo(b.gradeLevel);
|
||||
});
|
||||
|
||||
// 1. Determine active/effective Academic Year
|
||||
final activeYears =
|
||||
|
||||
@@ -770,9 +770,13 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
|
||||
_loadedEnrollments = enrollments;
|
||||
|
||||
final sorted = List.from(enrollments);
|
||||
sorted.sort(
|
||||
(a, b) => (b.gradeLevel ?? 0).compareTo(a.gradeLevel ?? 0),
|
||||
);
|
||||
sorted.sort((a, b) {
|
||||
final ayA = (a.yearRange ?? '').toString();
|
||||
final ayB = (b.yearRange ?? '').toString();
|
||||
final cmpYear = ayA.compareTo(ayB);
|
||||
if (cmpYear != 0) return cmpYear;
|
||||
return (a.gradeLevel ?? 0).compareTo(b.gradeLevel ?? 0);
|
||||
});
|
||||
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ class OcrEnrollmentValidationModal extends ConsumerStatefulWidget {
|
||||
final _ValidationMode _mode;
|
||||
|
||||
const OcrEnrollmentValidationModal._({
|
||||
super.key,
|
||||
this.studentId,
|
||||
required this.records,
|
||||
required _ValidationMode mode,
|
||||
@@ -113,7 +112,14 @@ class _OcrEnrollmentValidationModalState
|
||||
? gradeNum
|
||||
: 7,
|
||||
);
|
||||
}).toList();
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
final syA = a.record.schoolYear.trim();
|
||||
final syB = b.record.schoolYear.trim();
|
||||
final cmp = syA.compareTo(syB);
|
||||
if (cmp != 0) return cmp;
|
||||
return a.gradeLevel.compareTo(b.gradeLevel);
|
||||
});
|
||||
_initDefaults();
|
||||
}
|
||||
|
||||
|
||||
@@ -87,12 +87,13 @@ class _StudentFilterDialogState extends ConsumerState<StudentFilterDialog> {
|
||||
'Completed',
|
||||
'Missing',
|
||||
];
|
||||
static const _allTabStatusItems = [
|
||||
static const _statusItems = [
|
||||
'All Status',
|
||||
'Graduated',
|
||||
'Transferred',
|
||||
'Dropped',
|
||||
'Enrolled',
|
||||
'Inactive',
|
||||
'Dropped',
|
||||
'Transferred',
|
||||
'Graduated',
|
||||
];
|
||||
static const _fourPsItems = ['All', 'Yes', 'No'];
|
||||
static const _pageSizes = [10, 15, 20, 50, 100];
|
||||
@@ -106,9 +107,6 @@ class _StudentFilterDialogState extends ConsumerState<StudentFilterDialog> {
|
||||
_pendingGradeLevel = q.gradeLevel.isEmpty ? 'All Grades' : q.gradeLevel;
|
||||
_pendingSection = q.section.isEmpty ? 'All Sections' : q.section;
|
||||
_pendingStatus = q.status.isEmpty ? 'All Status' : q.status;
|
||||
if (!widget.isEnrolledTab && _pendingStatus == 'Enrolled') {
|
||||
_pendingStatus = 'All Status';
|
||||
}
|
||||
_pending4Ps =
|
||||
q.is4Ps.isEmpty ? 'All' : (q.is4Ps == 'true' ? 'Yes' : 'No');
|
||||
_pendingSortBy = q.sortBy;
|
||||
@@ -398,22 +396,20 @@ class _StudentFilterDialogState extends ConsumerState<StudentFilterDialog> {
|
||||
|
||||
_buildDivider(isDark),
|
||||
|
||||
// 4. Status Filter - only shown for All Students, with 'Enrolled' removed
|
||||
if (!widget.isEnrolledTab) ...[
|
||||
_buildFilterSection(
|
||||
label: 'Status',
|
||||
hasActiveFilter: _pendingStatus != 'All Status',
|
||||
onReset: () => setState(() {
|
||||
_pendingStatus = 'All Status';
|
||||
}),
|
||||
child: _buildFilterChipGroup(
|
||||
items: _allTabStatusItems,
|
||||
selectedValue: _pendingStatus,
|
||||
onSelected: (v) => setState(() => _pendingStatus = v),
|
||||
),
|
||||
// 4. Status Filter
|
||||
_buildFilterSection(
|
||||
label: 'Status',
|
||||
hasActiveFilter: _pendingStatus != 'All Status',
|
||||
onReset: () => setState(() {
|
||||
_pendingStatus = 'All Status';
|
||||
}),
|
||||
child: _buildFilterChipGroup(
|
||||
items: _statusItems,
|
||||
selectedValue: _pendingStatus,
|
||||
onSelected: (v) => setState(() => _pendingStatus = v),
|
||||
),
|
||||
_buildDivider(isDark),
|
||||
],
|
||||
),
|
||||
_buildDivider(isDark),
|
||||
|
||||
// 5. 4Ps Beneficiary
|
||||
_buildFilterSection(
|
||||
@@ -524,8 +520,7 @@ class _StudentFilterDialogState extends ConsumerState<StudentFilterDialog> {
|
||||
: 'All School Years';
|
||||
_pendingGradeLevel = 'All Grades';
|
||||
_pendingSection = 'All Sections';
|
||||
_pendingStatus =
|
||||
widget.isEnrolledTab ? 'Enrolled' : 'All Status';
|
||||
_pendingStatus = 'All Status';
|
||||
_pending4Ps = 'All';
|
||||
_pendingSortBy = '';
|
||||
_pendingSortOrder = '';
|
||||
@@ -572,11 +567,9 @@ class _StudentFilterDialogState extends ConsumerState<StudentFilterDialog> {
|
||||
section: _pendingSection == 'All Sections'
|
||||
? ''
|
||||
: _pendingSection,
|
||||
status: widget.isEnrolledTab
|
||||
? 'Enrolled'
|
||||
: (_pendingStatus == 'All Status'
|
||||
? ''
|
||||
: _pendingStatus),
|
||||
status: _pendingStatus == 'All Status'
|
||||
? ''
|
||||
: _pendingStatus,
|
||||
is4Ps: is4psVal,
|
||||
sortBy: _pendingSortBy,
|
||||
sortOrder: _pendingSortOrder,
|
||||
|
||||
@@ -541,7 +541,6 @@ class _AppSearchBarState extends ConsumerState<AppSearchBar> {
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
widget.onChanged?.call('');
|
||||
widget.onSubmitted?.call('');
|
||||
setState(() {
|
||||
_hasText = false;
|
||||
});
|
||||
|
||||
@@ -74,20 +74,32 @@ class AppPagination extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// "Previous" text button
|
||||
InkWell(
|
||||
onTap: canPrev ? () => onPageChanged(currentPage - 1) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text(
|
||||
'Previous',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: canPrev
|
||||
? (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary)
|
||||
: (isDark ? Colors.white24 : Colors.grey.shade400),
|
||||
// "Previous" text button with background like numbers
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: canPrev ? () => onPageChanged(currentPage - 1) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Previous',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: canPrev
|
||||
? (isDark ? Colors.white : Colors.black)
|
||||
: (isDark ? Colors.white24 : Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -99,20 +111,32 @@ class AppPagination extends StatelessWidget {
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// "Next" text button
|
||||
InkWell(
|
||||
onTap: canNext ? () => onPageChanged(currentPage + 1) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text(
|
||||
'Next',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: canNext
|
||||
? AppColors.primaryGreen
|
||||
: (isDark ? Colors.white24 : Colors.grey.shade400),
|
||||
// "Next" text button with background like numbers
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: canNext ? () => onPageChanged(currentPage + 1) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Next',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: canNext
|
||||
? (isDark ? Colors.white : Colors.black)
|
||||
: (isDark ? Colors.white24 : Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -278,8 +302,8 @@ class AppPagination extends StatelessWidget {
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: (isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: AppColors.textPrimary),
|
||||
? Colors.white
|
||||
: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -386,8 +410,8 @@ class AppPagination extends StatelessWidget {
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark
|
||||
? AppColors.darkTextPrimary
|
||||
: AppColors.textPrimary,
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.2.1+1
|
||||
version: 1.4.3+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.5
|
||||
|
||||
@@ -7,6 +7,29 @@
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
// Prevent multiple instances from running at the same time.
|
||||
HANDLE single_instance_mutex =
|
||||
::CreateMutex(nullptr, TRUE, L"TIS_RMS_SingleInstance_Mutex");
|
||||
if (::GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
// Bring the already-running instance to the foreground.
|
||||
HWND existing_window = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW",
|
||||
L"TIS Record Management System");
|
||||
if (!existing_window) {
|
||||
existing_window =
|
||||
::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", nullptr);
|
||||
}
|
||||
if (existing_window) {
|
||||
if (::IsIconic(existing_window)) {
|
||||
::ShowWindow(existing_window, SW_RESTORE);
|
||||
}
|
||||
::SetForegroundWindow(existing_window);
|
||||
}
|
||||
if (single_instance_mutex) {
|
||||
::CloseHandle(single_instance_mutex);
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
// Attach to console when present (e.g., 'flutter run') or create a
|
||||
// new console when running with a debugger.
|
||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||
@@ -28,6 +51,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"TIS Record Management System", origin, size)) {
|
||||
if (single_instance_mutex) {
|
||||
::ReleaseMutex(single_instance_mutex);
|
||||
::CloseHandle(single_instance_mutex);
|
||||
}
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
@@ -39,5 +66,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
}
|
||||
|
||||
::CoUninitialize();
|
||||
if (single_instance_mutex) {
|
||||
::ReleaseMutex(single_instance_mutex);
|
||||
::CloseHandle(single_instance_mutex);
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Reference in new issue
Block a user