From bce010fcb838c260a5363a955106dd23b1027c23 Mon Sep 17 00:00:00 2001 From: vidhya Date: Tue, 13 Oct 2026 21:00:09 +0530 Subject: [PATCH] feat(education_announcements): integrate student notices and notifications --- .../controllers/student.py | 110 +++- .../education_portal/models/announcement.py | 150 +++++- .../security/education_portal_security.xml | 2 +- addons/education_portal/tests/__init__.py | 1 + .../tests/test_announcement.py | 238 +++++++++ .../views/announcement_views.xml | 58 +- flutter_app/lib/core/api/api_endpoints.dart | 2 + flutter_app/lib/core/router/app_router.dart | 5 + .../announcements/announcements_screen.dart | 496 ++++++++++++++++++ .../features/dashboard/dashboard_screen.dart | 53 +- flutter_app/lib/models/announcement.dart | 87 +++ .../lib/providers/announcement_provider.dart | 22 + .../repositories/announcement_repository.dart | 63 +++ .../lib/services/notification_service.dart | 87 +++ .../test/announcements_widget_test.dart | 163 ++++++ .../test/live_odoo_integration_test.dart | 21 +- flutter_app/test/models_and_parsing_test.dart | 71 +++ 17 files changed, 1588 insertions(+), 41 deletions(-) create mode 100644 addons/education_portal/tests/test_announcement.py create mode 100644 flutter_app/lib/features/announcements/announcements_screen.dart create mode 100644 flutter_app/lib/models/announcement.dart create mode 100644 flutter_app/lib/providers/announcement_provider.dart create mode 100644 flutter_app/lib/repositories/announcement_repository.dart create mode 100644 flutter_app/lib/services/notification_service.dart create mode 100644 flutter_app/test/announcements_widget_test.dart diff --git a/addons/education_mobile_api/controllers/student.py b/addons/education_mobile_api/controllers/student.py index 9af65fc..3b4c250 100644 --- a/addons/education_mobile_api/controllers/student.py +++ b/addons/education_mobile_api/controllers/student.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import json import logging +import re from datetime import datetime, date # pyrefly: ignore [missing-import] from odoo import http @@ -59,6 +60,32 @@ def format_float_time(float_time): class MobileStudent(http.Controller): + def _strip_html(self, text): + if not text: + return "" + clean = re.sub(r'<[^>]+>', ' ', str(text)) + return " ".join(clean.split()).strip() + + def _format_announcement(self, a): + title = a.title or getattr(a, 'name', '') or 'Announcement' + raw_content = a.content or '' + summary = self._strip_html(raw_content)[:140] + return { + "id": a.id, + "title": title, + "name": title, + "date": str(a.date_published or ''), + "date_published": str(a.date_published or ''), + "expiry_date": str(a.expiry_date) if a.expiry_date else None, + "summary": summary, + "content": raw_content, + "type": a.announcement_type or 'notice', + "priority": a.priority or '0', + "author": a.author_id.name if a.author_id else None, + "has_attachment": bool(a.circular_file), + "circular_filename": a.circular_filename or None, + } + def _get_rel(self, record): if record: return {"id": record.id, "name": record.name} @@ -189,23 +216,9 @@ class MobileStudent(http.Controller): ('academic_year_id', '=', student.academic_year_id.id), ], fields=['id', 'name', 'state', 'start_date'], order='start_date asc', limit=5) - # Announcements - ann_records = request.env['education.announcement'].sudo().search( - [('active', '=', True)], - order='id desc', - limit=5 - ) - announcements = [] - for a in ann_records: - ann_title = getattr(a, 'title', None) or getattr(a, 'name', 'Announcement') - ann_date = getattr(a, 'date_published', None) or getattr(a, 'date', None) or '' - ann_content = getattr(a, 'content', None) or getattr(a, 'summary', None) or '' - announcements.append({ - 'id': a.id, - 'name': ann_title, - 'date': str(ann_date), - 'summary': str(ann_content)[:100] - }) + # Announcements (filtered by audience, state, and publication window) + ann_records, _ = request.env['education.announcement'].get_announcements_for_student(student, limit=5) + announcements = [self._format_announcement(a) for a in ann_records] # Recent Results results = request.env['education.exam.result'].sudo().search([ @@ -384,3 +397,66 @@ class MobileStudent(http.Controller): return json_response(True, data={"results": results_data}) except Exception as e: return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500) + + @http.route('/api/mobile/v1/student/announcements', type='http', auth='public', methods=['GET'], cors='*') + @require_student + def get_announcements(self, student, **kwargs): + try: + page = int(kwargs.get('page', 1) or 1) + limit = int(kwargs.get('limit', 20) or 20) + if page < 1: + page = 1 + if limit < 1 or limit > 100: + limit = 20 + offset = (page - 1) * limit + + ann_type = kwargs.get('type') + if ann_type and ann_type not in ['notice', 'circular', 'event']: + ann_type = None + + records, total_count = request.env['education.announcement'].get_announcements_for_student( + student, + limit=limit, + offset=offset, + announcement_type=ann_type + ) + + announcements_data = [self._format_announcement(a) for a in records] + + return json_response(True, data={ + "announcements": announcements_data, + "total_count": total_count, + "page": page, + "limit": limit + }) + except Exception as e: + return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500) + + @http.route('/api/mobile/v1/student/announcements/', type='http', auth='public', methods=['GET'], cors='*') + @require_student + def get_announcement_detail(self, student, announcement_id, **kwargs): + try: + ann = request.env['education.announcement'].sudo().browse(announcement_id) + if not ann.exists() or not ann.active or ann.state != 'published': + return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404) + + # Date window validation + today = date.today() + if ann.date_published > today: + return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404) + if ann.expiry_date and ann.expiry_date < today: + return json_response(False, error_code="NOT_FOUND", error_message="Announcement has expired", status=404) + + # Multi-company isolation + if student.company_id and ann.company_id and ann.company_id.id != student.company_id.id: + return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403) + + # Audience and cohort targeting + if not ann.check_student_audience(student): + return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403) + + return json_response(True, data={ + "announcement": self._format_announcement(ann) + }) + except Exception as e: + return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500) diff --git a/addons/education_portal/models/announcement.py b/addons/education_portal/models/announcement.py index 88bc4fc..def4b2f 100644 --- a/addons/education_portal/models/announcement.py +++ b/addons/education_portal/models/announcement.py @@ -1,11 +1,15 @@ # pyrefly: ignore [missing-import] +import logging from odoo import api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) class EducationAnnouncement(models.Model): _name = 'education.announcement' _description = 'Education Announcement' - _order = 'date_published desc, id desc' + _order = 'priority desc, date_published desc, id desc' title = fields.Char(string='Title', required=True, translate=True) content = fields.Html(string='Content', required=True) @@ -16,7 +20,23 @@ class EducationAnnouncement(models.Model): ('circular', 'Circular'), ('event', 'Event') ], string='Type', required=True, default='notice') + priority = fields.Selection([ + ('0', 'Normal'), + ('1', 'Important'), + ('2', 'Urgent') + ], string='Priority', default='0', index=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('published', 'Published'), + ('archived', 'Archived') + ], string='Status', default='published', required=True) active = fields.Boolean(default=True) + author_id = fields.Many2one( + 'res.users', + string='Author', + default=lambda self: self.env.user, + readonly=True + ) company_id = fields.Many2one( 'res.company', string='Company', @@ -26,3 +46,131 @@ class EducationAnnouncement(models.Model): ) circular_file = fields.Binary(string='Circular File', attachment=True) circular_filename = fields.Char(string='Circular Filename') + + # Audience & Cohort Targeting + audience_type = fields.Selection([ + ('all', 'All Students'), + ('cohort', 'Specific Academic Cohort') + ], string='Target Audience', default='all', required=True) + program_id = fields.Many2one('education.program', string='Target Program', index=True) + academic_year_id = fields.Many2one('education.academic.year', string='Target Academic Year', index=True) + academic_term_id = fields.Many2one('education.academic.term', string='Target Academic Term', index=True) + batch_id = fields.Many2one('education.batch', string='Target Batch', index=True) + semester_id = fields.Many2one('education.semester', string='Target Semester', index=True) + section_id = fields.Many2one('education.section', string='Target Section', index=True) + + # Push Notification Audit + notification_sent = fields.Boolean(string='Notification Dispatched', default=False, copy=False) + notification_sent_date = fields.Datetime(string='Dispatched At', readonly=True, copy=False) + notification_recipient_count = fields.Integer(string='Recipients Count', readonly=True, copy=False) + + @api.constrains('date_published', 'expiry_date') + def _check_dates(self): + for rec in self: + if rec.expiry_date and rec.expiry_date < rec.date_published: + raise ValidationError("Expiry date must be on or after the publication date.") + + def action_publish(self): + for rec in self: + if rec.expiry_date and rec.expiry_date < rec.date_published: + raise ValidationError("Expiry date must be on or after the publication date.") + rec.state = 'published' + rec._dispatch_notifications() + + def action_archive(self): + for rec in self: + rec.state = 'archived' + + def action_draft(self): + for rec in self: + rec.state = 'draft' + + def action_resend_notification(self): + for rec in self: + rec._dispatch_notifications(force=True) + + def check_student_audience(self, student): + """Verify whether an announcement applies to a given student.""" + self.ensure_one() + if self.audience_type == 'all': + return True + if self.program_id and self.program_id.id != student.program_id.id: + return False + if self.academic_year_id and self.academic_year_id.id != student.academic_year_id.id: + return False + if self.academic_term_id and self.academic_term_id.id != student.academic_term_id.id: + return False + if self.batch_id and self.batch_id.id != student.batch_id.id: + return False + if self.semester_id and self.semester_id.id != student.semester_id.id: + return False + if self.section_id and self.section_id.id != student.section_id.id: + return False + return True + + @api.model + def get_announcements_for_student(self, student, limit=None, offset=0, announcement_type=None): + """Retrieve active published announcements valid for the student within the effective date window.""" + today = fields.Date.context_today(self) + domain = [ + ('active', '=', True), + ('state', '=', 'published'), + ('date_published', '<=', today), + '|', ('expiry_date', '=', False), ('expiry_date', '>=', today), + ] + if student.company_id: + domain.append(('company_id', '=', student.company_id.id)) + if announcement_type: + domain.append(('announcement_type', '=', announcement_type)) + + records = self.sudo().search(domain, order="priority desc, date_published desc, id desc") + filtered = records.filtered(lambda a: a.check_student_audience(student)) + total_count = len(filtered) + if offset: + filtered = filtered[offset:] + if limit: + filtered = filtered[:limit] + return filtered, total_count + + def _dispatch_notifications(self, force=False): + """Provider-independent notification dispatcher with in-app logging and duplicate prevention.""" + for rec in self: + if rec.state != 'published': + continue + if rec.notification_sent and not force: + _logger.info("Notification already dispatched for announcement #%s ('%s'). Skipping duplicate.", rec.id, rec.title) + continue + + # Resolve targeted student recipients + domain = [] + if rec.company_id: + domain.append(('company_id', '=', rec.company_id.id)) + if rec.audience_type == 'cohort': + if rec.program_id: + domain.append(('program_id', '=', rec.program_id.id)) + if rec.academic_year_id: + domain.append(('academic_year_id', '=', rec.academic_year_id.id)) + if rec.academic_term_id: + domain.append(('academic_term_id', '=', rec.academic_term_id.id)) + if rec.batch_id: + domain.append(('batch_id', '=', rec.batch_id.id)) + if rec.semester_id: + domain.append(('semester_id', '=', rec.semester_id.id)) + if rec.section_id: + domain.append(('section_id', '=', rec.section_id.id)) + + students = self.env['education.student'].sudo().search(domain) + recipient_count = len(students) + + # In-App / Decoupled Notification Dispatch Log + _logger.info( + "DISPATCH NOTIFICATION: Announcement #%s ('%s', Priority: %s, Audience: %s) dispatched to %s students.", + rec.id, rec.title, rec.priority, rec.audience_type, recipient_count + ) + + rec.write({ + 'notification_sent': True, + 'notification_sent_date': fields.Datetime.now(), + 'notification_recipient_count': recipient_count, + }) + diff --git a/addons/education_portal/security/education_portal_security.xml b/addons/education_portal/security/education_portal_security.xml index a21e7d3..69dc9e9 100644 --- a/addons/education_portal/security/education_portal_security.xml +++ b/addons/education_portal/security/education_portal_security.xml @@ -4,7 +4,7 @@ Announcement Multi-Company Rule - [('company_id', '=', user.company_id.id)] + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] diff --git a/addons/education_portal/tests/__init__.py b/addons/education_portal/tests/__init__.py index cc52aad..5c80f48 100644 --- a/addons/education_portal/tests/__init__.py +++ b/addons/education_portal/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_portal from . import test_payment_gateway +from . import test_announcement diff --git a/addons/education_portal/tests/test_announcement.py b/addons/education_portal/tests/test_announcement.py new file mode 100644 index 0000000..5c996a5 --- /dev/null +++ b/addons/education_portal/tests/test_announcement.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError, AccessError + + +class TestEducationAnnouncement(TransactionCase): + + def setUp(self): + super(TestEducationAnnouncement, self).setUp() + + self.portal_group = self.env.ref('base.group_portal') + + # Academic infrastructure + self.campus = self.env['education.campus'].create({'name': 'Ann Campus', 'code': 'AC'}) + self.department = self.env['education.department'].create({'name': 'Ann Dept', 'code': 'AD', 'campus_id': self.campus.id}) + self.level = self.env['education.academic.level'].create({'name': 'Ann Level', 'code': 'AL'}) + self.program_a = self.env['education.program'].create({'name': 'Program A', 'code': 'PA', 'department_id': self.department.id, 'level_id': self.level.id}) + self.program_b = self.env['education.program'].create({'name': 'Program B', 'code': 'PB', 'department_id': self.department.id, 'level_id': self.level.id}) + self.year = self.env['education.academic.year'].create({'name': 'Ann Year', 'code': 'AY', 'start_date': '2026-01-01', 'end_date': '2026-12-31'}) + self.term = self.env['education.academic.term'].create({'name': 'Ann Term', 'code': 'AT', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30'}) + self.batch = self.env['education.batch'].create({'name': 'Ann Batch', 'code': 'AB'}) + self.semester = self.env['education.semester'].create({'name': 'Ann Sem', 'code': 'AS'}) + self.section_1 = self.env['education.section'].create({'name': 'Section 1', 'code': 'SEC1'}) + self.section_2 = self.env['education.section'].create({'name': 'Section 2', 'code': 'SEC2'}) + + # Student User + self.user_student = self.env['res.users'].create({ + 'name': 'Ann Student', + 'login': 'ann.student@test.com', + 'email': 'ann.student@test.com', + 'group_ids': [(6, 0, [self.portal_group.id])], + }) + + self.student = self.env['education.student'].create({ + 'first_name': 'Ann', + 'last_name': 'Student', + 'student_id': 'ANN-STU-01', + 'admission_number': 'ANN-ADM-01', + 'roll_number': 'ANN-RL-01', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program_a.id, + 'level_id': self.level.id, + 'academic_year_id': self.year.id, + 'academic_term_id': self.term.id, + 'batch_id': self.batch.id, + 'semester_id': self.semester.id, + 'section_id': self.section_1.id, + 'status': 'active', + 'email': 'ann.student@test.com', + 'portal_user_id': self.user_student.id, + }) + + def test_announcement_workflow_and_constraints(self): + """Test draft, publish, archive states and date validation.""" + today = date.today() + # Invalid expiry date constraint + with self.assertRaises(ValidationError): + self.env['education.announcement'].create({ + 'title': 'Invalid Date Announcement', + 'content': '

Testing invalid date

', + 'date_published': today, + 'expiry_date': today - timedelta(days=2), + 'state': 'draft', + }) + + # Valid creation + ann = self.env['education.announcement'].create({ + 'title': 'Orientation Notice', + 'content': '

Welcome all new students!

', + 'date_published': today, + 'expiry_date': today + timedelta(days=10), + 'announcement_type': 'notice', + 'priority': '1', + 'state': 'draft', + }) + self.assertEqual(ann.state, 'draft') + self.assertFalse(ann.notification_sent) + + # Publish + ann.action_publish() + self.assertEqual(ann.state, 'published') + self.assertTrue(ann.notification_sent) + self.assertIsNotNone(ann.notification_sent_date) + + # Archive + ann.action_archive() + self.assertEqual(ann.state, 'archived') + + # Reset to draft + ann.action_draft() + self.assertEqual(ann.state, 'draft') + + def test_audience_filtering_all_vs_cohort(self): + """Test that all-student and cohort-specific announcements filter accurately.""" + today = date.today() + # Announcement for All Students + ann_all = self.env['education.announcement'].create({ + 'title': 'General Holiday Notice', + 'content': '

Campus closed tomorrow

', + 'date_published': today, + 'audience_type': 'all', + 'state': 'published', + }) + + # Cohort Announcement matching Student (Program A) + ann_prog_a = self.env['education.announcement'].create({ + 'title': 'Program A Seminar', + 'content': '

Mandatory for Program A

', + 'date_published': today, + 'audience_type': 'cohort', + 'program_id': self.program_a.id, + 'state': 'published', + }) + + # Cohort Announcement for Program B (different program) + ann_prog_b = self.env['education.announcement'].create({ + 'title': 'Program B Workshop', + 'content': '

Exclusive for Program B

', + 'date_published': today, + 'audience_type': 'cohort', + 'program_id': self.program_b.id, + 'state': 'published', + }) + + # Cohort Announcement for Section 2 (student is in Section 1) + ann_sec_2 = self.env['education.announcement'].create({ + 'title': 'Section 2 Lab Reschedule', + 'content': '

Section 2 only

', + 'date_published': today, + 'audience_type': 'cohort', + 'section_id': self.section_2.id, + 'state': 'published', + }) + + # Check individual audience checks + self.assertTrue(ann_all.check_student_audience(self.student)) + self.assertTrue(ann_prog_a.check_student_audience(self.student)) + self.assertFalse(ann_prog_b.check_student_audience(self.student)) + self.assertFalse(ann_sec_2.check_student_audience(self.student)) + + # Check get_announcements_for_student + records, total_count = self.env['education.announcement'].get_announcements_for_student(self.student) + record_ids = records.ids + self.assertIn(ann_all.id, record_ids) + self.assertIn(ann_prog_a.id, record_ids) + self.assertNotIn(ann_prog_b.id, record_ids) + self.assertNotIn(ann_sec_2.id, record_ids) + + def test_publication_window_filtering(self): + """Test that future-published and expired announcements are excluded.""" + today = date.today() + + # Currently active + ann_active = self.env['education.announcement'].create({ + 'title': 'Active Notice', + 'content': '

Active

', + 'date_published': today - timedelta(days=1), + 'expiry_date': today + timedelta(days=5), + 'state': 'published', + }) + + # Future notice (scheduled) + ann_future = self.env['education.announcement'].create({ + 'title': 'Future Notice', + 'content': '

Not yet active

', + 'date_published': today + timedelta(days=2), + 'state': 'published', + }) + + # Expired notice + ann_expired = self.env['education.announcement'].create({ + 'title': 'Expired Notice', + 'content': '

Expired yesterday

', + 'date_published': today - timedelta(days=10), + 'expiry_date': today - timedelta(days=1), + 'state': 'published', + }) + + records, _ = self.env['education.announcement'].get_announcements_for_student(self.student) + record_ids = records.ids + self.assertIn(ann_active.id, record_ids) + self.assertNotIn(ann_future.id, record_ids) + self.assertNotIn(ann_expired.id, record_ids) + + def test_notification_dispatch_and_duplicate_prevention(self): + """Test that publishing sends notifications once and avoids duplicates unless forced.""" + ann = self.env['education.announcement'].create({ + 'title': 'Annual Fest Announcement', + 'content': '

Registrations open

', + 'date_published': date.today(), + 'audience_type': 'cohort', + 'program_id': self.program_a.id, + 'state': 'draft', + }) + self.assertFalse(ann.notification_sent) + + # First publish sends notification + ann.action_publish() + self.assertTrue(ann.notification_sent) + first_dispatch_time = ann.notification_sent_date + self.assertIsNotNone(first_dispatch_time) + self.assertGreaterEqual(ann.notification_recipient_count, 1) + + # Calling _dispatch_notifications directly should NOT re-dispatch + ann._dispatch_notifications(force=False) + self.assertEqual(ann.notification_sent_date, first_dispatch_time) + + # Forced resend should re-dispatch + ann.action_resend_notification() + self.assertTrue(ann.notification_sent) + + def test_student_security_access_control(self): + """Verify that portal student users cannot create, modify, or delete announcements.""" + today = date.today() + ann = self.env['education.announcement'].create({ + 'title': 'Admin Announcement', + 'content': '

Administrative text

', + 'date_published': today, + 'state': 'published', + }) + + # Student user attempt to create + with self.assertRaises(AccessError): + self.env['education.announcement'].with_user(self.user_student).create({ + 'title': 'Student Hack Announcement', + 'content': '

Hacked

', + 'date_published': today, + }) + + # Student user attempt to write + with self.assertRaises(AccessError): + ann.with_user(self.user_student).write({'title': 'Defaced Title'}) + + # Student user attempt to unlink + with self.assertRaises(AccessError): + ann.with_user(self.user_student).unlink() diff --git a/addons/education_portal/views/announcement_views.xml b/addons/education_portal/views/announcement_views.xml index d603937..77faf57 100644 --- a/addons/education_portal/views/announcement_views.xml +++ b/addons/education_portal/views/announcement_views.xml @@ -5,12 +5,16 @@ education.announcement + + - + + + @@ -20,20 +24,30 @@ education.announcement
+
+
- + + - + + + @@ -42,6 +56,29 @@ + + + + + + + + + + + + + + + + + + + + + + +
@@ -55,10 +92,23 @@ + + + + + + + + + + - + + + + diff --git a/flutter_app/lib/core/api/api_endpoints.dart b/flutter_app/lib/core/api/api_endpoints.dart index 60fc6ed..98070ff 100644 --- a/flutter_app/lib/core/api/api_endpoints.dart +++ b/flutter_app/lib/core/api/api_endpoints.dart @@ -13,4 +13,6 @@ class ApiEndpoints { static const String timetable = '/api/mobile/v1/student/timetable'; static const String exams = '/api/mobile/v1/student/exams'; static const String results = '/api/mobile/v1/student/results'; + static const String announcements = '/api/mobile/v1/student/announcements'; + static String announcementDetail(int id) => '/api/mobile/v1/student/announcements/$id'; } diff --git a/flutter_app/lib/core/router/app_router.dart b/flutter_app/lib/core/router/app_router.dart index 6f2059a..a01713d 100644 --- a/flutter_app/lib/core/router/app_router.dart +++ b/flutter_app/lib/core/router/app_router.dart @@ -11,6 +11,7 @@ import '../../features/attendance/attendance_screen.dart'; import '../../features/timetable/timetable_screen.dart'; import '../../features/exams/exams_screen.dart'; import '../../features/results/results_screen.dart'; +import '../../features/announcements/announcements_screen.dart'; import '../../features/settings/settings_screen.dart'; final appRouterProvider = Provider((ref) { @@ -49,6 +50,10 @@ final appRouterProvider = Provider((ref) { path: '/results', builder: (context, state) => const ResultsScreen(), ), + GoRoute( + path: '/announcements', + builder: (context, state) => const AnnouncementsScreen(), + ), GoRoute( path: '/settings', builder: (context, state) => const SettingsScreen(), diff --git a/flutter_app/lib/features/announcements/announcements_screen.dart b/flutter_app/lib/features/announcements/announcements_screen.dart new file mode 100644 index 0000000..78ea949 --- /dev/null +++ b/flutter_app/lib/features/announcements/announcements_screen.dart @@ -0,0 +1,496 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; +import '../../models/announcement.dart'; +import '../../providers/announcement_provider.dart'; + +class AnnouncementsScreen extends ConsumerWidget { + const AnnouncementsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeFilter = ref.watch(announcementTypeFilterProvider); + final announcementsAsync = ref.watch(announcementsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Notice Board'), + ), + body: Column( + children: [ + // Filter Chips Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterChip( + context: context, + ref: ref, + label: 'All', + filterValue: null, + isSelected: activeFilter == null, + ), + const SizedBox(width: 8), + _buildFilterChip( + context: context, + ref: ref, + label: 'Notices', + filterValue: 'notice', + isSelected: activeFilter == 'notice', + ), + const SizedBox(width: 8), + _buildFilterChip( + context: context, + ref: ref, + label: 'Circulars', + filterValue: 'circular', + isSelected: activeFilter == 'circular', + ), + const SizedBox(width: 8), + _buildFilterChip( + context: context, + ref: ref, + label: 'Events', + filterValue: 'event', + isSelected: activeFilter == 'event', + ), + ], + ), + ), + ), + const Divider(height: 1), + + // Main Content + Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(announcementsProvider); + await ref.read(announcementsProvider.future); + }, + child: announcementsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline_rounded, size: 48, color: AppTheme.errorColor), + const SizedBox(height: 12), + const Text( + 'Failed to load announcements', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + err.toString(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.refresh(announcementsProvider), + child: const Text('Retry'), + ), + ], + ), + ), + ), + data: (pageResult) { + final announcements = pageResult.announcements; + if (announcements.isEmpty) { + return ListView( + children: const [ + SizedBox(height: 100), + Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.campaign_outlined, size: 64, color: AppTheme.textMuted), + SizedBox(height: 16), + Text( + 'No announcements found', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + Text( + 'There are no active notices or circulars matching your criteria at this time.', + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.textSecondary, fontSize: 13), + ), + ], + ), + ), + ), + ], + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: announcements.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final ann = announcements[index]; + return _buildAnnouncementCard(context, ann); + }, + ); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildFilterChip({ + required BuildContext context, + required WidgetRef ref, + required String label, + required String? filterValue, + required bool isSelected, + }) { + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (_) { + ref.read(announcementTypeFilterProvider.notifier).state = filterValue; + }, + selectedColor: AppTheme.primaryColor.withOpacity(0.15), + checkmarkColor: AppTheme.primaryColor, + labelStyle: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? AppTheme.primaryColor : AppTheme.textSecondary, + ), + ); + } + + Widget _buildAnnouncementCard(BuildContext context, AnnouncementItem ann) { + return Card( + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _showAnnouncementDetail(context, ann), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Badges & Date + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildTypeBadge(ann), + if (ann.isUrgent || ann.isImportant) ...[ + const SizedBox(width: 6), + _buildPriorityBadge(ann), + ], + const Spacer(), + if (ann.date.isNotEmpty) + Text( + ann.date, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Title + Text( + ann.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 6), + + // Summary + if (ann.summary.isNotEmpty) + Text( + ann.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.3, + ), + ), + + // Footer: Author & Attachment + if (ann.author != null || ann.hasAttachment) ...[ + const SizedBox(height: 10), + Row( + children: [ + if (ann.author != null) + Row( + children: [ + const Icon(Icons.person_outline, size: 14, color: AppTheme.textMuted), + const SizedBox(width: 4), + Text( + ann.author!, + style: const TextStyle(fontSize: 11, color: AppTheme.textMuted), + ), + ], + ), + const Spacer(), + if (ann.hasAttachment) + Row( + children: [ + const Icon(Icons.attach_file, size: 14, color: AppTheme.primaryColor), + const SizedBox(width: 2), + Text( + ann.circularFilename ?? 'Attachment', + style: const TextStyle( + fontSize: 11, + color: AppTheme.primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildTypeBadge(AnnouncementItem ann) { + Color bg; + Color fg; + switch (ann.type) { + case 'circular': + bg = Colors.deepPurple.shade50; + fg = Colors.deepPurple.shade700; + break; + case 'event': + bg = Colors.teal.shade50; + fg = Colors.teal.shade700; + break; + case 'notice': + default: + bg = Colors.blue.shade50; + fg = Colors.blue.shade700; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + ann.typeLabel, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg), + ), + ); + } + + Widget _buildPriorityBadge(AnnouncementItem ann) { + Color bg; + Color fg; + if (ann.isUrgent) { + bg = Colors.red.shade50; + fg = Colors.red.shade700; + } else { + bg = Colors.amber.shade50; + fg = Colors.amber.shade900; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + ann.priorityLabel, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg), + ), + ); + } + + void _showAnnouncementDetail(BuildContext context, AnnouncementItem ann) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (context, scrollController) { + return SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header Badges + Row( + children: [ + _buildTypeBadge(ann), + if (ann.isUrgent || ann.isImportant) ...[ + const SizedBox(width: 8), + _buildPriorityBadge(ann), + ], + const Spacer(), + if (ann.date.isNotEmpty) + Text( + 'Published: ${ann.date}', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + ), + const SizedBox(height: 16), + + // Title + Text( + ann.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 12), + + // Expiry Date & Author + if (ann.expiryDate != null || ann.author != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (ann.author != null) + Row( + children: [ + const Icon(Icons.person_outline, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + 'Posted by: ${ann.author}', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + ), + if (ann.expiryDate != null) ...[ + if (ann.author != null) const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.event_busy, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Text( + 'Expires on: ${ann.expiryDate}', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 16), + + // Content + const Text( + 'Details', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + ann.summary.isNotEmpty ? ann.summary : ann.title, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + height: 1.5, + ), + ), + + // Attachment info + if (ann.hasAttachment) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)), + borderRadius: BorderRadius.circular(8), + color: AppTheme.primaryColor.withOpacity(0.05), + ), + child: Row( + children: [ + const Icon(Icons.description_outlined, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Expanded( + child: Text( + ann.circularFilename ?? 'Attached Document', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppTheme.primaryColor, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/flutter_app/lib/features/dashboard/dashboard_screen.dart b/flutter_app/lib/features/dashboard/dashboard_screen.dart index cfed427..c69b71c 100644 --- a/flutter_app/lib/features/dashboard/dashboard_screen.dart +++ b/flutter_app/lib/features/dashboard/dashboard_screen.dart @@ -679,29 +679,47 @@ class DashboardScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Row( + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon(Icons.campaign_outlined, color: AppTheme.primaryColor, size: 20), - SizedBox(width: 8), - Text( - 'Announcements', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, + const Row( + children: [ + Icon(Icons.campaign_outlined, color: AppTheme.primaryColor, size: 20), + SizedBox(width: 8), + Text( + 'Announcements', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + TextButton( + onPressed: () => context.push('/announcements'), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('View All', style: TextStyle(fontSize: 12)), + Icon(Icons.chevron_right, size: 16), + ], ), ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), ...announcements.map( - (ann) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(8), - ), + (ann) => InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => context.push('/announcements'), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(8), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -741,6 +759,7 @@ class DashboardScreen extends ConsumerWidget { ), ), ), + ), ], ), ), diff --git a/flutter_app/lib/models/announcement.dart b/flutter_app/lib/models/announcement.dart new file mode 100644 index 0000000..22f12f7 --- /dev/null +++ b/flutter_app/lib/models/announcement.dart @@ -0,0 +1,87 @@ +import '../core/utils/safe_convert.dart'; + +class AnnouncementItem { + final int id; + final String title; + final String name; + final String date; + final String datePublished; + final String? expiryDate; + final String summary; + final String content; + final String type; + final String priority; + final String? author; + final bool hasAttachment; + final String? circularFilename; + + const AnnouncementItem({ + required this.id, + required this.title, + required this.name, + required this.date, + required this.datePublished, + this.expiryDate, + required this.summary, + required this.content, + required this.type, + required this.priority, + this.author, + required this.hasAttachment, + this.circularFilename, + }); + + factory AnnouncementItem.fromJson(Map json) { + final title = SafeConvert.toSafeString( + json['title'], + SafeConvert.toSafeString(json['name'], 'Announcement'), + ); + final datePublished = SafeConvert.toSafeString( + json['date_published'], + SafeConvert.toSafeString(json['date']), + ); + + return AnnouncementItem( + id: SafeConvert.toSafeInt(json['id']), + title: title, + name: title, + date: datePublished, + datePublished: datePublished, + expiryDate: SafeConvert.toNullableString(json['expiry_date']), + summary: SafeConvert.toSafeString(json['summary']), + content: SafeConvert.toSafeString(json['content']), + type: SafeConvert.toSafeString(json['type'], 'notice'), + priority: SafeConvert.toSafeString(json['priority'], '0'), + author: SafeConvert.toNullableString(json['author']), + hasAttachment: json['has_attachment'] == true, + circularFilename: SafeConvert.toNullableString(json['circular_filename']), + ); + } + + bool get isUrgent => priority == '2'; + bool get isImportant => priority == '1'; + + String get priorityLabel { + switch (priority) { + case '2': + return 'Urgent'; + case '1': + return 'Important'; + case '0': + default: + return 'Normal'; + } + } + + String get typeLabel { + switch (type) { + case 'circular': + return 'Circular'; + case 'event': + return 'Event'; + case 'notice': + default: + return 'Notice'; + } + } +} diff --git a/flutter_app/lib/providers/announcement_provider.dart b/flutter_app/lib/providers/announcement_provider.dart new file mode 100644 index 0000000..0c75fa1 --- /dev/null +++ b/flutter_app/lib/providers/announcement_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/announcement.dart'; +import '../repositories/announcement_repository.dart'; +import 'auth_provider.dart'; + +final announcementRepositoryProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + return AnnouncementRepository(apiClient: apiClient); +}); + +final announcementTypeFilterProvider = StateProvider.autoDispose((ref) => null); + +final announcementsProvider = FutureProvider.autoDispose((ref) async { + final repository = ref.watch(announcementRepositoryProvider); + final filterType = ref.watch(announcementTypeFilterProvider); + return await repository.getAnnouncements(type: filterType); +}); + +final announcementDetailProvider = FutureProvider.autoDispose.family((ref, id) async { + final repository = ref.watch(announcementRepositoryProvider); + return await repository.getAnnouncementDetail(id); +}); diff --git a/flutter_app/lib/repositories/announcement_repository.dart b/flutter_app/lib/repositories/announcement_repository.dart new file mode 100644 index 0000000..c7b13fa --- /dev/null +++ b/flutter_app/lib/repositories/announcement_repository.dart @@ -0,0 +1,63 @@ +import '../core/api/api_client.dart'; +import '../core/api/api_endpoints.dart'; +import '../core/utils/safe_convert.dart'; +import '../models/announcement.dart'; + +class AnnouncementPageResult { + final List announcements; + final int totalCount; + final int page; + final int limit; + + const AnnouncementPageResult({ + required this.announcements, + required this.totalCount, + required this.page, + required this.limit, + }); +} + +class AnnouncementRepository { + final ApiClient _apiClient; + + AnnouncementRepository({required ApiClient apiClient}) : _apiClient = apiClient; + + Future getAnnouncements({ + int page = 1, + int limit = 20, + String? type, + }) async { + final queryParameters = { + 'page': page, + 'limit': limit, + }; + if (type != null && type.isNotEmpty && type != 'all') { + queryParameters['type'] = type; + } + + final data = await _apiClient.get( + ApiEndpoints.announcements, + queryParameters: queryParameters, + ); + + final map = SafeConvert.toNullableMap(data) ?? {}; + final list = SafeConvert.toSafeList( + map['announcements'], + (item) => AnnouncementItem.fromJson(SafeConvert.toNullableMap(item) ?? {}), + ); + + return AnnouncementPageResult( + announcements: list, + totalCount: SafeConvert.toSafeInt(map['total_count'], list.length), + page: SafeConvert.toSafeInt(map['page'], page), + limit: SafeConvert.toSafeInt(map['limit'], limit), + ); + } + + Future getAnnouncementDetail(int id) async { + final data = await _apiClient.get(ApiEndpoints.announcementDetail(id)); + final map = SafeConvert.toNullableMap(data) ?? {}; + final annMap = SafeConvert.toNullableMap(map['announcement']) ?? {}; + return AnnouncementItem.fromJson(annMap); + } +} diff --git a/flutter_app/lib/services/notification_service.dart b/flutter_app/lib/services/notification_service.dart new file mode 100644 index 0000000..c3ef596 --- /dev/null +++ b/flutter_app/lib/services/notification_service.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PushNotificationMessage { + final String id; + final String title; + final String body; + final String? type; + final Map data; + final DateTime timestamp; + + const PushNotificationMessage({ + required this.id, + required this.title, + required this.body, + this.type, + this.data = const {}, + required this.timestamp, + }); + + factory PushNotificationMessage.fromMap(Map map) { + return PushNotificationMessage( + id: map['id']?.toString() ?? UniqueKey().toString(), + title: map['title']?.toString() ?? 'Notification', + body: map['body']?.toString() ?? '', + type: map['type']?.toString(), + data: map['data'] is Map ? map['data'] as Map : {}, + timestamp: DateTime.now(), + ); + } +} + +abstract class NotificationService { + Future initialize(); + Future getDeviceToken(); + Stream get onMessageReceived; + Future showInAppNotification(PushNotificationMessage message); + void dispose(); +} + +class InAppNotificationService implements NotificationService { + final _messageController = StreamController.broadcast(); + final List _history = []; + bool _isInitialized = false; + + @override + Future initialize() async { + _isInitialized = true; + debugPrint('[NotificationService] In-App Fallback Notification Service initialized.'); + } + + @override + Future getDeviceToken() async { + // In-app fallback has no cloud device push token + return null; + } + + @override + Stream get onMessageReceived => _messageController.stream; + + List get history => List.unmodifiable(_history); + + @override + Future showInAppNotification(PushNotificationMessage message) async { + if (!_isInitialized) { + await initialize(); + } + _history.insert(0, message); + if (_history.length > 50) { + _history.removeLast(); + } + _messageController.add(message); + debugPrint('[NotificationService] In-app notification received: ${message.title} - ${message.body}'); + } + + @override + void dispose() { + _messageController.close(); + } +} + +final notificationServiceProvider = Provider((ref) { + final service = InAppNotificationService(); + ref.onDispose(() => service.dispose()); + return service; +}); diff --git a/flutter_app/test/announcements_widget_test.dart b/flutter_app/test/announcements_widget_test.dart new file mode 100644 index 0000000..6d995df --- /dev/null +++ b/flutter_app/test/announcements_widget_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:metatron_student_erp/features/announcements/announcements_screen.dart'; +import 'package:metatron_student_erp/models/announcement.dart'; +import 'package:metatron_student_erp/providers/announcement_provider.dart'; +import 'package:metatron_student_erp/repositories/announcement_repository.dart'; + +void main() { + final sampleAnnouncements = [ + const AnnouncementItem( + id: 201, + title: 'Fee Payment Deadline', + name: 'Fee Payment Deadline', + date: '2026-10-15', + datePublished: '2026-10-15', + expiryDate: '2026-11-15', + summary: 'Please ensure semester fees are paid before deadline.', + content: '

Please ensure semester fees are paid before deadline.

', + type: 'circular', + priority: '2', + author: 'Finance Office', + hasAttachment: true, + circularFilename: 'fee_notice.pdf', + ), + const AnnouncementItem( + id: 202, + title: 'Sports Day 2026', + name: 'Sports Day 2026', + date: '2026-10-18', + datePublished: '2026-10-18', + summary: 'Annual athletics and sports events registrations.', + content: '

Annual athletics and sports events registrations.

', + type: 'event', + priority: '1', + author: 'Sports Committee', + hasAttachment: false, + ), + ]; + + Widget buildTestableScreen(List overrides) { + return ProviderScope( + overrides: overrides, + child: const MaterialApp( + home: AnnouncementsScreen(), + ), + ); + } + + testWidgets('AnnouncementsScreen renders loading state initially', (tester) async { + await tester.pumpWidget( + buildTestableScreen([ + announcementsProvider.overrideWith((ref) => Future.delayed( + const Duration(seconds: 1), + () => const AnnouncementPageResult( + announcements: [], + totalCount: 0, + page: 1, + limit: 20, + ), + )), + ]), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('AnnouncementsScreen renders empty state when no notices exist', (tester) async { + await tester.pumpWidget( + buildTestableScreen([ + announcementsProvider.overrideWith((ref) => Future.value( + const AnnouncementPageResult( + announcements: [], + totalCount: 0, + page: 1, + limit: 20, + ), + )), + ]), + ); + + await tester.pumpAndSettle(); + expect(find.text('No announcements found'), findsOneWidget); + }); + + testWidgets('AnnouncementsScreen renders filter chips and notice cards with priority badges', (tester) async { + await tester.pumpWidget( + buildTestableScreen([ + announcementsProvider.overrideWith((ref) => Future.value( + AnnouncementPageResult( + announcements: sampleAnnouncements, + totalCount: 2, + page: 1, + limit: 20, + ), + )), + ]), + ); + + await tester.pumpAndSettle(); + + // Verify filter chips + expect(find.text('All'), findsOneWidget); + expect(find.text('Notices'), findsOneWidget); + expect(find.text('Circulars'), findsOneWidget); + expect(find.text('Events'), findsOneWidget); + + // Verify card titles and badges + expect(find.text('Fee Payment Deadline'), findsOneWidget); + expect(find.text('Urgent'), findsOneWidget); + expect(find.text('Circular'), findsOneWidget); + expect(find.text('Sports Day 2026'), findsOneWidget); + expect(find.text('Important'), findsOneWidget); + expect(find.text('Event'), findsOneWidget); + expect(find.text('fee_notice.pdf'), findsOneWidget); + }); + + testWidgets('AnnouncementsScreen opens detail bottom sheet when card is tapped', (tester) async { + await tester.pumpWidget( + buildTestableScreen([ + announcementsProvider.overrideWith((ref) => Future.value( + AnnouncementPageResult( + announcements: sampleAnnouncements, + totalCount: 2, + page: 1, + limit: 20, + ), + )), + ]), + ); + + await tester.pumpAndSettle(); + + // Tap the first card + await tester.tap(find.text('Fee Payment Deadline')); + await tester.pumpAndSettle(); + + // Check detail sheet elements + expect(find.text('Details'), findsOneWidget); + expect(find.text('Posted by: Finance Office'), findsOneWidget); + expect(find.text('Expires on: 2026-11-15'), findsOneWidget); + expect(find.text('Close'), findsOneWidget); + + // Close the sheet + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.text('Details'), findsNothing); + }); + + testWidgets('AnnouncementsScreen renders error state and retry button on failure', (tester) async { + await tester.pumpWidget( + buildTestableScreen([ + announcementsProvider.overrideWith((ref) => Future.error('Connection timeout')), + ]), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Failed to load announcements'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); +} diff --git a/flutter_app/test/live_odoo_integration_test.dart b/flutter_app/test/live_odoo_integration_test.dart index d101ea9..7521797 100644 --- a/flutter_app/test/live_odoo_integration_test.dart +++ b/flutter_app/test/live_odoo_integration_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:metatron_student_erp/core/api/api_client.dart'; import 'package:metatron_student_erp/core/storage/secure_storage.dart'; +import 'package:metatron_student_erp/repositories/announcement_repository.dart'; import 'package:metatron_student_erp/repositories/attendance_repository.dart'; import 'package:metatron_student_erp/repositories/auth_repository.dart'; import 'package:metatron_student_erp/repositories/exam_repository.dart'; @@ -45,6 +46,7 @@ void main() { late TimetableRepository timetableRepo; late ExamRepository examRepo; late ResultRepository resultRepo; + late AnnouncementRepository announcementRepo; setUpAll(() async { storage = TestSecureStorage(); @@ -55,6 +57,7 @@ void main() { timetableRepo = TimetableRepository(apiClient: apiClient); examRepo = ExamRepository(apiClient: apiClient); resultRepo = ResultRepository(apiClient: apiClient); + announcementRepo = AnnouncementRepository(apiClient: apiClient); }); test('1. Flutter Login against live Odoo backend returns session and user', () async { @@ -131,7 +134,23 @@ void main() { stdout.writeln('--> Results verified: ${results.length} results found'); }); - test('9. POST /api/mobile/v1/auth/logout ends session', () async { + test('9. GET /api/mobile/v1/student/announcements and detail return valid notice board data', () async { + final res = await announcementRepo.getAnnouncements(); + expect(res.announcements, isNotEmpty); + expect(res.totalCount, isPositive); + final firstAnn = res.announcements.first; + expect(firstAnn.id, isPositive); + expect(firstAnn.title, isNotEmpty); + expect(firstAnn.type, isNotEmpty); + stdout.writeln('--> Announcements verified: ${res.announcements.length} loaded (first: "${firstAnn.title}")'); + + final detail = await announcementRepo.getAnnouncementDetail(firstAnn.id); + expect(detail.id, firstAnn.id); + expect(detail.title, firstAnn.title); + stdout.writeln('--> Announcement detail verified: #${detail.id} "${detail.title}"'); + }); + + test('10. POST /api/mobile/v1/auth/logout ends session', () async { await authRepo.logout(); final cookie = await storage.getSessionCookie(); expect(cookie, isNull); diff --git a/flutter_app/test/models_and_parsing_test.dart b/flutter_app/test/models_and_parsing_test.dart index a088b09..9914b30 100644 --- a/flutter_app/test/models_and_parsing_test.dart +++ b/flutter_app/test/models_and_parsing_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:metatron_student_erp/core/utils/safe_convert.dart'; import 'package:metatron_student_erp/models/attendance.dart'; +import 'package:metatron_student_erp/models/announcement.dart'; import 'package:metatron_student_erp/models/dashboard.dart'; import 'package:metatron_student_erp/models/exam.dart'; import 'package:metatron_student_erp/models/result.dart'; @@ -371,4 +372,74 @@ void main() { expect(adminUser.isStudent, isFalse); }); }); + + group('AnnouncementItem Model Tests', () { + test('parses full announcement JSON with all attributes', () { + final json = { + 'id': 101, + 'title': 'Semester Fees Circular', + 'date_published': '2026-10-01', + 'expiry_date': '2026-11-01', + 'summary': 'Notice to pay fees on time.', + 'content': '

Notice to pay fees on time.

', + 'type': 'circular', + 'priority': '2', + 'author': 'Finance Office', + 'has_attachment': true, + 'circular_filename': 'fee_structure.pdf', + }; + + final item = AnnouncementItem.fromJson(json); + expect(item.id, 101); + expect(item.title, 'Semester Fees Circular'); + expect(item.name, 'Semester Fees Circular'); + expect(item.datePublished, '2026-10-01'); + expect(item.expiryDate, '2026-11-01'); + expect(item.type, 'circular'); + expect(item.typeLabel, 'Circular'); + expect(item.priority, '2'); + expect(item.isUrgent, isTrue); + expect(item.isImportant, isFalse); + expect(item.priorityLabel, 'Urgent'); + expect(item.author, 'Finance Office'); + expect(item.hasAttachment, isTrue); + expect(item.circularFilename, 'fee_structure.pdf'); + }); + + test('handles missing, nullable, and Odoo false attributes gracefully', () { + final json = { + 'id': 102, + 'name': 'Sports Day', + 'date': '2026-10-15', + 'expiry_date': false, + 'type': false, + 'priority': '1', + 'author': false, + 'has_attachment': false, + 'circular_filename': null, + }; + + final item = AnnouncementItem.fromJson(json); + expect(item.id, 102); + expect(item.title, 'Sports Day'); + expect(item.expiryDate, isNull); + expect(item.type, 'notice'); + expect(item.typeLabel, 'Notice'); + expect(item.priority, '1'); + expect(item.isImportant, isTrue); + expect(item.isUrgent, isFalse); + expect(item.priorityLabel, 'Important'); + expect(item.author, isNull); + expect(item.hasAttachment, isFalse); + expect(item.circularFilename, isNull); + }); + + test('defaults priority label to Normal when priority is 0 or unrecognized', () { + final json = {'id': 103, 'title': 'General Notice', 'priority': '0'}; + final item = AnnouncementItem.fromJson(json); + expect(item.priorityLabel, 'Normal'); + expect(item.isUrgent, isFalse); + expect(item.isImportant, isFalse); + }); + }); }