Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ Flutter app for NTUT students: course schedules, scores, enrollment, announcemen

Follow @CONTRIBUTING.md for git operation guidelines.

**Last updated:** 2026-02-27. If stale (>30 days), verify Status section against codebase.
**Last updated:** 2026-03-03. If stale (>30 days), verify Status section against codebase.

## Status

**Done:**

- PortalService (auth+SSO, getSsoUrl for system browser auth, changePassword, getAvatar, uploadAvatar), CourseService (HTML parsing), ISchoolPlusService (getStudents, getMaterials, getMaterial)
- CalendarService (getCalendar - academic calendar events via calModeApp.do JSON API)
- StudentQueryService (getAcademicPerformance, getRegistrationRecords, getGradeRanking, getStudentProfile)
- HTTP utils, InvalidCookieFilter interceptor
- Drift database schema with all tables
Expand Down Expand Up @@ -39,7 +40,6 @@ Follow @CONTRIBUTING.md for git operation guidelines.
- getClassAndMentor (註冊編班與導師查詢)
- updateContactInfo (維護個人聯絡資料)
- getGraduationQualifications (查詢畢業資格審查)
- PortalService: getCalendar

**Todo - Repository Layer:**

Expand Down Expand Up @@ -96,6 +96,7 @@ MVVM pattern with Riverpod for DI and reactive state:
**Services:**

- PortalService - Portal auth, SSO
- CalendarService - 學校行事曆 (calModeApp.do JSON API, requires portal session)
- CourseService - 課程系統 (`aa_0010-oauth`)
- ISchoolPlusService - 北科i學園PLUS (`ischool_plus_oauth`)
- StudentQueryService - 學生查詢專區 (`sa_003_oauth`)
Expand Down
99 changes: 99 additions & 0 deletions lib/services/calendar_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'dart:convert';

import 'package:intl/intl.dart';
import 'package:riverpod/riverpod.dart';
import 'package:tattoo/services/portal_service.dart';
import 'package:tattoo/utils/http.dart';

/// 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,
});

/// Provides the singleton [CalendarService] instance.
final calendarServiceProvider = Provider<CalendarService>(
(ref) => CalendarService(ref.read(portalServiceProvider)),
);

/// Service for fetching NTUT academic calendar events.
///
/// Uses the `calModeApp.do` JSON API on the NTUT Portal host.
/// Requires an active portal session (shared cookie jar from [PortalService.login]).
class CalendarService {
final Dio _dio;

CalendarService(PortalService portalService) : _dio = portalService.portalDio;

/// 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 [PortalService.login] first).
Future<List<CalendarEventDto>> getCalendar(
DateTime startDate,
DateTime endDate,
) async {
final formatter = DateFormat('yyyy/MM/dd');
final response = await _dio.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();
}
}
4 changes: 4 additions & 0 deletions lib/services/portal_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ final portalServiceProvider = Provider<PortalService>((ref) => PortalService());
class PortalService {
late final Dio _portalDio;

/// The portal Dio instance, for use by services that share the portal host
/// (e.g., [CalendarService]).
Dio get portalDio => _portalDio;

PortalService() {
// Emulate the NTUT iOS app's HTTP client
_portalDio = createDio()
Expand Down
72 changes: 72 additions & 0 deletions test/services/cal_debug_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// import 'package:flutter_test/flutter_test.dart';
// import 'package:tattoo/services/calendar_service.dart';
// import 'package:tattoo/services/portal_service.dart';

// import '../test_helpers.dart';

// /// Reference test to inspect raw JSON from calModeApp.do.
// ///
// /// Run with:
// /// ```bash
// /// flutter test --dart-define-from-file=test/test_config.json -r expanded test/services/cal_debug_test.dart
// /// ```
// ///
// /// Example response (2025/09 semester):
// /// ```json
// /// [
// /// {
// /// "id": 60564,
// /// "calStart": 1755619200000,
// /// "calEnd": 1756818000000,
// /// "calTitle": "新生網路預選(日間部 17:00 截止;進修部 21:00 截止)",
// /// "calPlace": "",
// /// "calContent": "",
// /// "calColor": "#DDDDDD;#000000",
// /// "ownerId": "1540521049552",
// /// "ownerName": "學校行事曆",
// /// "creatorId": "ntutoaa",
// /// "creatorName": "教務處",
// /// "modifyDate": 1751339649000,
// /// "hasBeenDeleted": 0,
// /// "calAlertList": [],
// /// "calInviteeList": []
// /// },
// /// {
// /// "calStart": 1758297600000,
// /// "calEnd": 1758384000000,
// /// "allDay": "1",
// /// "calTitle": "",
// /// "ownerId": "holiday_system",
// /// "isHoliday": "1",
// /// "calAlertList": [],
// /// "calInviteeList": []
// /// }
// /// ]
// /// ```
// void main() {
// test('print raw calModeApp.do JSON', () async {
// TestCredentials.validate();
// final portalService = PortalService();
// await portalService.login(
// TestCredentials.username,
// TestCredentials.password,
// );

// final events = await CalendarService(portalService).getCalendar(
// DateTime(2025, 1, 1),
// DateTime(2026, 12, 31),
// );

// print('=== ${events.length} events ===');
// for (final e in events) {
// final start = e.calStart != null
// ? DateTime.fromMillisecondsSinceEpoch(e.calStart!)
// : null;
// final end = e.calEnd != null
// ? DateTime.fromMillisecondsSinceEpoch(e.calEnd!)
// : null;
// final holiday = e.isHoliday == '1' ? ' [holiday]' : '';
// print(' $start ~ $end: ${e.calTitle ?? "(no title)"}$holiday');
// }
// });
// }
61 changes: 61 additions & 0 deletions test/services/calendar_service_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tattoo/services/calendar_service.dart';
import 'package:tattoo/services/portal_service.dart';

import '../test_helpers.dart';

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

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

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

test('should return calendar events for a semester date range', () async {
final events = await calendarService.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 calendarService.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>());
});
});
}