diff --git a/lib/page/dashboard/exam_detail.dart b/lib/page/dashboard/exam_detail.dart index f683ab97..e1371f45 100644 --- a/lib/page/dashboard/exam_detail.dart +++ b/lib/page/dashboard/exam_detail.dart @@ -20,9 +20,11 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:dan_xi/generated/l10n.dart'; +import 'package:dan_xi/model/person.dart'; import 'package:dan_xi/provider/state_provider.dart' as sp; import 'package:dan_xi/repository/fdu/data_center_repository.dart'; import 'package:dan_xi/repository/fdu/edu_service_repository.dart'; +import 'package:dan_xi/repository/fdu/graduate_exam_repository.dart'; import 'package:dan_xi/util/master_detail_view.dart'; import 'package:dan_xi/util/noticing.dart'; import 'package:dan_xi/util/platform_universal.dart'; @@ -123,6 +125,13 @@ GpaListItem? userGpa(Ref ref) { ); } +/// Provider for graduate student exam scores. +/// Returns all scores across all semesters. +@riverpod +Future> graduateExamScoreList(Ref ref) async { + return await GraduateExamRepository.getInstance().loadExamScore(); +} + /// A list page showing user's GPA scores and exam information. /// It will try to fetch [SemesterInfo] first. /// If successful, it will fetch exams in this term. In case that there is no exam, it shows the score of this term. @@ -134,6 +143,12 @@ class ExamList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Graduate students use a simplified view without semester navigation + if (sp.StateProvider.personInfo.value?.group == + UserGroup.FUDAN_POSTGRADUATE_STUDENT) { + return _buildGraduateView(context, ref); + } + final semesterBundle = ref.watch(semesterBundleProvider); final currentSemesterIndex = useState(null); final currentExamRef = useState?>(null); @@ -174,6 +189,35 @@ class ExamList extends HookConsumerWidget { )); } + /// Build simplified view for graduate students. + /// Shows all scores without semester navigation or exam schedule. + Widget _buildGraduateView(BuildContext context, WidgetRef ref) { + final scores = ref.watch(graduateExamScoreListProvider); + + return PlatformScaffold( + iosContentBottomPadding: false, + iosContentPadding: false, + appBar: PlatformAppBarX( + title: Text(S.of(context).exam_schedule), + ), + body: SafeArea( + child: switch (scores) { + AsyncData(value: final data) => + _buildGradeLayout(context, ref, data, null, isGraduate: true), + AsyncError(:final error, :final stackTrace) => ErrorPageWidget( + errorMessage: + '${S.of(context).failed}\n\nError:\n${ErrorPageWidget.generateUserFriendlyDescription(S.of(context), error)}', + error: error, + trace: stackTrace, + onTap: () => ref.invalidate(graduateExamScoreListProvider), + buttonText: S.of(context).retry, + ), + _ => Center(child: PlatformCircularProgressIndicator()), + }, + ), + ); + } + Widget _loadExamGradeHybridView(BuildContext context, WidgetRef ref, List semesters, ValueNotifier currentSemesterIndex, {ValueNotifier?>? currentExamRef}) { @@ -304,9 +348,9 @@ class ExamList extends HookConsumerWidget { Widget _buildGradeLayout(BuildContext context, WidgetRef ref, List examScores, String? semesterId, - {bool isFallback = false}) => + {bool isFallback = false, bool isGraduate = false}) => _buildRefreshableListView(context, ref, _getListWidgetsGrade( - context, ref, examScores, isFallback: isFallback),); + context, ref, examScores, isFallback: isFallback, isGraduate: isGraduate),); Widget _buildHybridLayout(BuildContext context, WidgetRef ref, List exams, List scores, String? semesterId) => @@ -360,7 +404,7 @@ class ExamList extends HookConsumerWidget { } List _getListWidgetsGrade(BuildContext context, WidgetRef ref, - List scores, {bool isFallback = false}) { + List scores, {bool isFallback = false, bool isGraduate = false}) { Widget buildLimitedCard() => Card( color: Theme.of(context).colorScheme.error, child: ListTile( @@ -376,7 +420,8 @@ class ExamList extends HookConsumerWidget { List widgets = []; if (isFallback) { widgets.add(buildLimitedCard()); - } else { + } else if (!isGraduate) { + // Only show GPA card for undergraduate students widgets.add(_buildGpaCard(context, ref)); } for (var value in scores) { diff --git a/lib/repository/fdu/edu_service_repository.dart b/lib/repository/fdu/edu_service_repository.dart index 03aac59d..3b1cd780 100644 --- a/lib/repository/fdu/edu_service_repository.dart +++ b/lib/repository/fdu/edu_service_repository.dart @@ -476,6 +476,37 @@ class ExamScore { elements[5].text.trim(), null); } + + /// Parse from graduate student score JSON. + /// + /// Expected JSON format: + /// ```json + /// { + /// "KCMC": "Course Name", + /// "KCDM": "Course Code", + /// "XF": 2.0, + /// "KCLBMC": "Course Type", + /// "CJ": "A", + /// "JDZ": 4.0 + /// } + /// ``` + factory ExamScore.fromGraduateJson(Map json) { + final String id = json['KCDM']?.toString() ?? ''; + final String name = json['KCMC']?.toString() ?? ''; + final String type = json['KCLBMC']?.toString() ?? ''; + final num? credit = json['XF']; + final String level = json['CJ']?.toString() ?? ''; + final num? gpa = json['JDZ']; + + return ExamScore( + id, + name, + type, + credit?.toString() ?? '', + level, + gpa?.toString(), + ); + } } class GpaListItem { diff --git a/lib/repository/fdu/graduate_exam_repository.dart b/lib/repository/fdu/graduate_exam_repository.dart new file mode 100644 index 00000000..2672892c --- /dev/null +++ b/lib/repository/fdu/graduate_exam_repository.dart @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2025 DanXi-Dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'package:dan_xi/repository/base_repository.dart'; +import 'package:dan_xi/repository/fdu/edu_service_repository.dart'; +import 'package:dio/dio.dart'; + +import 'neo_login_tool.dart'; + +/// Repository for graduate student exam/score data. +/// +/// This repository fetches data from the graduate student system. +/// +/// ## API Details +/// ### Get Scores +/// URL: https://yzsfwapp.fudan.edu.cn/gsapp/sys/wdcjapp/modules/xscjcx/jdjscjcx.do +/// Login URL: https://yzsfwapp.fudan.edu.cn/gsapp/sys/wdcjapp/*default/index.do +/// +/// Response: +/// ```json +/// { +/// "code": "0", +/// "datas": { +/// "jdjscjcx": { +/// "totalSize": 8, +/// "pageSize": 999, +/// "rows": [ +/// { +/// "KCMC": "Course Name", +/// "KCDM": "Course Code", +/// "XF": 2.0, +/// "CZRXM": "Teacher", +/// "KCLBMC": "Course Type", +/// "CJ": "A", +/// "JDZ": 4.0 +/// } +/// ] +/// } +/// } +/// } +/// ``` +class GraduateExamRepository extends BaseRepositoryWithDio { + static const String _SEMESTER_URL = + 'https://zlapp.fudan.edu.cn/fudanyjskb/wap/default/get-index'; + + static const String _SCORE_URL = + 'https://yzsfwapp.fudan.edu.cn/gsapp/sys/wdcjapp/modules/xscjcx/jdjscjcx.do'; + + static const String _SCORE_LOGIN_URL = + 'https://yzsfwapp.fudan.edu.cn/gsapp/sys/wdcjapp/*default/index.do'; + + @override + String get linkHost => "fudan.edu.cn"; + + GraduateExamRepository._(); + + static final _instance = GraduateExamRepository._(); + + factory GraduateExamRepository.getInstance() => _instance; + + /// Load all exam scores for graduate students. + /// + /// Returns all scores across all semesters. + Future> loadExamScore() { + final options = RequestOptions(method: "GET", path: _SCORE_URL); + return FudanSession.request(options, (response) { + final Map data = response.data; + + final String? code = data['code']; + if (code != '0') { + throw GraduateExamException('Failed to load scores: code=$code'); + } + + final Map datas = data['datas']; + final Map jdjscjcx = datas['jdjscjcx']; + final List rows = jdjscjcx['rows']; + + return rows + .map((row) => ExamScore.fromGraduateJson(row as Map)) + .toList(); + }, manualLoginUrl: Uri.parse(_SCORE_LOGIN_URL)); + } +} + +/// Exception for graduate exam/score related errors. +class GraduateExamException implements Exception { + final String message; + + GraduateExamException(this.message); + + @override + String toString() => 'GraduateExamException: $message'; +}