Skip to content
Open
20 changes: 11 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@ Flutter app for NTUT students: course schedules, scores, enrollment, announcemen

Follow @CONTRIBUTING.md for git operation guidelines.

**Last updated:** 2026-03-05. If stale (>7 days), verify Status section against codebase.
**Last updated:** 2026-03-11. If stale (>7 days), verify Status section against codebase.

## Status

**Done:**

- PortalService, CourseService, ISchoolPlusService, StudentQueryService, GitHubService
- Service integration tests
- Drift database schema
- AuthRepository, PreferencesRepository, CourseRepository (stubs)
- Riverpod, go_router, i18n (zh_TW, en_US)
- UI: intro, login, home (table/score/profile tabs), about, easter egg, ShowcaseShell
- PortalService (auth+SSO, getSsoUrl for system browser auth, changePassword, getAvatar, uploadAvatar, getCalendar), CourseService (HTML parsing), ISchoolPlusService (getStudents, getMaterials, getMaterial), StudentQueryService (getAcademicPerformance, getRegistrationRecords, getGradeRanking, getStudentProfile), GitHubService
- Service integration tests (copy `test/test_config.json.example` to `test/test_config.json`, then run `flutter test --dart-define-from-file=test/test_config.json -r failures-only`)
- Drift database schema with all tables
- Service DTOs migrated to Dart 3 records
- AuthRepository implementation (login, logout, lazy auth via `withAuth<T>()`, session persistence via flutter_secure_storage), PreferencesRepository, CourseRepository (stubs)
- Riverpod setup (manual providers, no codegen — riverpod_generator incompatible with Drift-generated types)
- go_router navigation setup
- UI: intro screen, login screen, home screen with bottom navigation bar and three tabs (table, score, profile), about, easter egg, ShowcaseShell. Home uses `StatefulShellRoute` with `AnimatedShellContainer` for tab state preservation and cross-fade transitions. Each tab owns its own `Scaffold`.
- i18n (zh_TW, en_US) via slang

**Todo - Service Layer:**

Expand All @@ -33,7 +36,6 @@ Follow @CONTRIBUTING.md for git operation guidelines.
- getClassAndMentor (註冊編班與導師查詢)
- updateContactInfo (維護個人聯絡資料)
- getGraduationQualifications (查詢畢業資格審查)
- PortalService: getCalendar

**Todo - Repository Layer:**

Expand Down Expand Up @@ -92,7 +94,7 @@ MVVM pattern with Riverpod for DI and reactive state (manual providers, no codeg

**Services:**

- PortalService - Portal auth, SSO (auth+SSO, getSsoUrl, changePassword, getAvatar, uploadAvatar)
- PortalService - Portal auth, SSO (auth+SSO, getSsoUrl, changePassword, getAvatar, uploadAvatar, getCalendar - academic calendar events via calModeApp.do JSON API)
- CourseService - 課程系統 (`aa_0010-oauth`) — HTML parsing
- ISchoolPlusService - 北科i學園PLUS (`ischool_plus_oauth`) — getStudents, getMaterials, getMaterial
- StudentQueryService - 學生查詢專區 (`sa_003_oauth`) — getAcademicPerformance, getRegistrationRecords, getGradeRanking, getStudentProfile
Expand Down
79 changes: 79 additions & 0 deletions lib/services/portal_service.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:intl/intl.dart';

import 'package:dio_redirect_interceptor/dio_redirect_interceptor.dart';
import 'package:html/parser.dart';
Expand Down Expand Up @@ -27,6 +28,45 @@ typedef UserDto = ({
int? passwordExpiresInDays,
});

/// Represents a calendar event from the NTUT Portal.
///
/// Events come in two flavors:
/// - **Named events** with [id], [calTitle], [ownerName], [creatorName]
/// (e.g., exam periods, registration deadlines)
/// - **Holiday markers** with [isHoliday] = "1" and an empty title
/// (weekends and national holidays)
typedef CalendarEventDto = ({
/// Event ID (absent for holiday markers).
int? id,

/// Event start time (epoch milliseconds).
int? calStart,

/// Event end time (epoch milliseconds).
int? calEnd,

/// Whether this is an all-day event ("1" = yes).
String? allDay,

/// Event title / description.
String? calTitle,

/// Event location.
String? calPlace,

/// Event content / details.
String? calContent,

/// Owner name (e.g., "學校行事曆").
String? ownerName,

/// Creator name (e.g., "教務處").
String? creatorName,

/// Whether this is a holiday ("1" = yes).
String? isHoliday,
});

// dart format off
/// Identification codes for NTUT services used in SSO authentication.
///
Expand Down Expand Up @@ -297,4 +337,43 @@ class PortalService {

return (actionUrl, formData);
}

/// Fetches academic calendar events within a date range.
///
/// Returns a list of calendar events (e.g., holidays, exam periods,
/// registration deadlines) between [startDate] and [endDate] inclusive.
///
/// Requires an active portal session (call [login] first).
Future<List<CalendarEventDto>> getCalendar(
DateTime startDate,
DateTime endDate,
) async {
final formatter = DateFormat('yyyy/MM/dd');
final response = await _portalDio.get(
'calModeApp.do',
queryParameters: {
'startDate': formatter.format(startDate),
'endDate': formatter.format(endDate),
},
);

final List<dynamic> events = jsonDecode(response.data);
String? normalizeEmpty(String? value) =>
value?.isNotEmpty == true ? value : null;

return events.map<CalendarEventDto>((e) {
return (
id: e['id'] as int?,
calStart: e['calStart'] as int?,
calEnd: e['calEnd'] as int?,
allDay: normalizeEmpty(e['allDay'] as String?),
calTitle: normalizeEmpty(e['calTitle'] as String?),
calPlace: normalizeEmpty(e['calPlace'] as String?),
calContent: normalizeEmpty(e['calContent'] as String?),
ownerName: normalizeEmpty(e['ownerName'] as String?),
creatorName: normalizeEmpty(e['creatorName'] as String?),
isHoliday: normalizeEmpty(e['isHoliday'] as String?),
);
}).toList();
}
}
59 changes: 59 additions & 0 deletions test/services/calendar_service_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tattoo/services/firebase_service.dart';
import 'package:tattoo/services/portal_service.dart';

import '../test_helpers.dart';

void main() {
group('PortalService (Calendar) Tests', () {
late PortalService portalService;

setUpAll(() {
TestCredentials.validate();
});

setUp(() async {
portalService = PortalService(FirebaseService());
await portalService.login(
TestCredentials.username,
TestCredentials.password,
);
await respectfulDelay();
});

test('should return calendar events for a semester date range', () async {
final events = await portalService.getCalendar(
DateTime(2025, 1, 1),
DateTime(2025, 6, 30),
);

expect(events, isNotEmpty, reason: 'Semester should have events');

// Verify structure of first non-holiday event (skip holidays with empty titles)
final namedEvents = events.where((e) => e.calTitle != null).toList();
expect(
namedEvents,
isNotEmpty,
reason: 'Semester should have at least one event with a non-null title',
);
final event = namedEvents.first;
expect(event.calTitle, isNotNull, reason: 'Event should have a title');
expect(
event.calStart,
isNotNull,
reason: 'Event should have a start time',
);
});

test('should return empty list for a date range with no events', () async {
// A single day far in the past unlikely to have events
final events = await portalService.getCalendar(
DateTime(2000, 1, 1),
DateTime(2000, 1, 2),
);

// May still contain weekend/holiday markers, but should be a valid list
expect(events, isA<List>());
});
});
}
Loading