feat(education_announcements): integrate student notices and notifications

This commit is contained in:
Vidhya 2026-10-13 21:00:09 +05:30
parent 1d40349dce
commit bce010fcb8
17 changed files with 1588 additions and 41 deletions

View File

@ -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/<int:announcement_id>', 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)

View File

@ -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,
})

View File

@ -4,7 +4,7 @@
<record id="rule_announcement_company" model="ir.rule">
<field name="name">Announcement Multi-Company Rule</field>
<field name="model_id" ref="model_education_announcement"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<!-- Portal User Record Rules -->

View File

@ -1,2 +1,3 @@
from . import test_portal
from . import test_payment_gateway
from . import test_announcement

View File

@ -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': '<p>Testing invalid date</p>',
'date_published': today,
'expiry_date': today - timedelta(days=2),
'state': 'draft',
})
# Valid creation
ann = self.env['education.announcement'].create({
'title': 'Orientation Notice',
'content': '<p>Welcome all new students!</p>',
'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': '<p>Campus closed tomorrow</p>',
'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': '<p>Mandatory for Program A</p>',
'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': '<p>Exclusive for Program B</p>',
'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': '<p>Section 2 only</p>',
'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': '<p>Active</p>',
'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': '<p>Not yet active</p>',
'date_published': today + timedelta(days=2),
'state': 'published',
})
# Expired notice
ann_expired = self.env['education.announcement'].create({
'title': 'Expired Notice',
'content': '<p>Expired yesterday</p>',
'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': '<p>Registrations open</p>',
'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': '<p>Administrative text</p>',
'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': '<p>Hacked</p>',
'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()

View File

@ -5,12 +5,16 @@
<field name="model">education.announcement</field>
<field name="arch" type="xml">
<list string="Announcements">
<field name="priority" widget="priority"/>
<field name="title"/>
<field name="announcement_type" widget="badge" decoration-info="announcement_type == 'notice'" decoration-warning="announcement_type == 'circular'" decoration-success="announcement_type == 'event'"/>
<field name="audience_type" widget="badge" decoration-primary="audience_type == 'all'" decoration-muted="audience_type == 'cohort'"/>
<field name="date_published"/>
<field name="expiry_date"/>
<field name="active" widget="boolean_toggle"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'published'" decoration-danger="state == 'archived'"/>
<field name="notification_sent" widget="boolean_toggle" optional="show"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
@ -20,20 +24,30 @@
<field name="model">education.announcement</field>
<field name="arch" type="xml">
<form string="Announcement">
<header>
<button name="action_publish" string="Publish &amp; Notify" type="object" invisible="state == 'published'" class="oe_highlight"/>
<button name="action_archive" string="Archive" type="object" invisible="state == 'archived'"/>
<button name="action_draft" string="Reset to Draft" type="object" invisible="state == 'draft'"/>
<button name="action_resend_notification" string="Resend Notification" type="object" invisible="state != 'published' or not notification_sent" confirm="Are you sure you want to re-dispatch notifications to students?"/>
<field name="state" widget="statusbar" statusbar_visible="draft,published,archived"/>
</header>
<sheet>
<div class="oe_title">
<label for="title" class="oe_edit_only"/>
<h1><field name="title" placeholder="Announcement Title..."/></h1>
</div>
<group>
<group>
<group string="General Information">
<field name="announcement_type"/>
<field name="priority" widget="priority"/>
<field name="date_published"/>
<field name="expiry_date"/>
<field name="circular_filename" invisible="1"/>
<field name="circular_file" filename="circular_filename" widget="binary" invisible="announcement_type != 'circular'"/>
</group>
<group>
<group string="Publication &amp; Scope">
<field name="author_id"/>
<field name="audience_type"/>
<field name="active"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
@ -42,6 +56,29 @@
<page string="Announcement Details" name="content_page">
<field name="content" placeholder="Write full details here..." widget="html" options="{'safe': True}"/>
</page>
<page string="Audience &amp; Cohort Targeting" name="cohort_targeting" invisible="audience_type != 'cohort'">
<group>
<group string="Academic Scope">
<field name="program_id"/>
<field name="academic_year_id"/>
<field name="academic_term_id"/>
</group>
<group string="Class &amp; Cohort Scope">
<field name="batch_id"/>
<field name="semester_id"/>
<field name="section_id"/>
</group>
</group>
</page>
<page string="Notification Audit" name="notification_audit">
<group>
<group>
<field name="notification_sent"/>
<field name="notification_sent_date"/>
<field name="notification_recipient_count"/>
</group>
</group>
</page>
</notebook>
</sheet>
</form>
@ -55,10 +92,23 @@
<search string="Announcements">
<field name="title"/>
<field name="announcement_type"/>
<field name="program_id"/>
<field name="author_id"/>
<filter string="Published" name="published" domain="[('state', '=', 'published')]"/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Archived" name="archived_status" domain="[('state', '=', 'archived')]"/>
<separator/>
<filter string="Urgent / Important" name="urgent" domain="[('priority', 'in', ['1', '2'])]"/>
<filter string="All Students" name="all_students" domain="[('audience_type', '=', 'all')]"/>
<filter string="Cohort Specific" name="cohort_students" domain="[('audience_type', '=', 'cohort')]"/>
<separator/>
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<filter string="Inactive" name="inactive" domain="[('active', '=', False)]"/>
<group>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Type" name="group_type" context="{'group_by': 'announcement_type'}"/>
<filter string="Priority" name="group_priority" context="{'group_by': 'priority'}"/>
<filter string="Audience" name="group_audience" context="{'group_by': 'audience_type'}"/>
<filter string="Published Date" name="group_date" context="{'group_by': 'date_published'}"/>
</group>
</search>

View File

@ -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';
}

View File

@ -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<GoRouter>((ref) {
@ -49,6 +50,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/results',
builder: (context, state) => const ResultsScreen(),
),
GoRoute(
path: '/announcements',
builder: (context, state) => const AnnouncementsScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),

View File

@ -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'),
),
),
],
),
);
},
);
},
);
}
}

View File

@ -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 {
),
),
),
),
],
),
),

View File

@ -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<String, dynamic> 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';
}
}
}

View File

@ -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<AnnouncementRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return AnnouncementRepository(apiClient: apiClient);
});
final announcementTypeFilterProvider = StateProvider.autoDispose<String?>((ref) => null);
final announcementsProvider = FutureProvider.autoDispose<AnnouncementPageResult>((ref) async {
final repository = ref.watch(announcementRepositoryProvider);
final filterType = ref.watch(announcementTypeFilterProvider);
return await repository.getAnnouncements(type: filterType);
});
final announcementDetailProvider = FutureProvider.autoDispose.family<AnnouncementItem, int>((ref, id) async {
final repository = ref.watch(announcementRepositoryProvider);
return await repository.getAnnouncementDetail(id);
});

View File

@ -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<AnnouncementItem> 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<AnnouncementPageResult> getAnnouncements({
int page = 1,
int limit = 20,
String? type,
}) async {
final queryParameters = <String, dynamic>{
'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<AnnouncementItem> 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);
}
}

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> ? map['data'] as Map<String, dynamic> : {},
timestamp: DateTime.now(),
);
}
}
abstract class NotificationService {
Future<void> initialize();
Future<String?> getDeviceToken();
Stream<PushNotificationMessage> get onMessageReceived;
Future<void> showInAppNotification(PushNotificationMessage message);
void dispose();
}
class InAppNotificationService implements NotificationService {
final _messageController = StreamController<PushNotificationMessage>.broadcast();
final List<PushNotificationMessage> _history = [];
bool _isInitialized = false;
@override
Future<void> initialize() async {
_isInitialized = true;
debugPrint('[NotificationService] In-App Fallback Notification Service initialized.');
}
@override
Future<String?> getDeviceToken() async {
// In-app fallback has no cloud device push token
return null;
}
@override
Stream<PushNotificationMessage> get onMessageReceived => _messageController.stream;
List<PushNotificationMessage> get history => List.unmodifiable(_history);
@override
Future<void> 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<NotificationService>((ref) {
final service = InAppNotificationService();
ref.onDispose(() => service.dispose());
return service;
});

View File

@ -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: '<p>Please ensure semester fees are paid before deadline.</p>',
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: '<p>Annual athletics and sports events registrations.</p>',
type: 'event',
priority: '1',
author: 'Sports Committee',
hasAttachment: false,
),
];
Widget buildTestableScreen(List<Override> 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);
});
}

View File

@ -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);

View File

@ -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': '<p>Notice to pay fees on time.</p>',
'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);
});
});
}