From c2bbb25688400330c1087d3d6b1f761a699bcf8a Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 13:21:11 +0000 Subject: [PATCH 01/28] updated minimumOSVersion --- packages/uni_app/ios/Flutter/AppFrameworkInfo.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uni_app/ios/Flutter/AppFrameworkInfo.plist b/packages/uni_app/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf765..0d1408009 100644 --- a/packages/uni_app/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/uni_app/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 13.0 + 15.0 From 86e472531267265249d4ed6d2d5727982f3224e3 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 13:21:33 +0000 Subject: [PATCH 02/28] uncommented code --- .../widgets/modal_professor_info.dart | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index 8a734d4cb..f325ee6bf 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -1,12 +1,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uni/controller/networking/network_router.dart'; +import 'package:uni/controller/networking/url_launcher.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -// import 'package:uni_ui/icons.dart'; +import 'package:uni_ui/icons.dart'; import 'package:uni_ui/modal/modal.dart'; -// import 'package:uni_ui/modal/widgets/info_row.dart'; +import 'package:uni_ui/modal/widgets/info_row.dart'; import 'package:uni_ui/modal/widgets/person_info.dart'; class ProfessorInfoModal extends ConsumerWidget { @@ -16,6 +19,12 @@ class ProfessorInfoModal extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(sessionProvider).value!; + final rooms = professor.rooms.join(', '); + final baseUrls = NetworkRouter.getBaseUrlsFromSession(session); + final scheduleUrl = baseUrls.isNotEmpty + ? '${baseUrls[0]}hor_geral.docentes_view?pv_doc_codigo=${professor.code}' + : null; + return ModalDialog( children: [ FutureBuilder( @@ -30,23 +39,36 @@ class ProfessorInfoModal extends ConsumerWidget { studentNumber: int.parse(professor.code), ), ), - // Professor model hasn't the necessary fields - /* - ModalInfoRow( - title: 'Email', - description: '[email-professor@up.pt]', - icon: UniIcons.email, - trailing: UniIcon( - UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, + if (professor.institutionalEmail != null) + ModalInfoRow( + title: S.of(context).email, + description: professor.institutionalEmail, + icon: UniIcons.email, + trailing: UniIcon( + UniIcons.caretRight, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () => launchUrlWithToast( + context, + 'mailto:${professor.institutionalEmail}', + ), + ), + if (rooms.isNotEmpty) + ModalInfoRow( + title: S.of(context).room, + description: rooms, + icon: UniIcons.location, + ), + if (scheduleUrl != null) + ModalInfoRow( + title: S.of(context).schedule, + icon: UniIcons.lecture, + trailing: UniIcon( + UniIcons.caretRight, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () => launchUrlWithToast(context, scheduleUrl), ), - ), - const ModalInfoRow( - title: 'Sala', - description: '[sala]', - icon: UniIcons.location, - ), - */ ], ); } From f6f1753d330b226767b669434b40e07638a637ff Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 13:23:04 +0000 Subject: [PATCH 03/28] updated parser and sheet --- .../parsers/parser_course_unit_info.dart | 22 +++++-- .../model/entities/course_units/sheet.dart | 65 ++++++++++++++++++- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/uni_app/lib/controller/parsers/parser_course_unit_info.dart b/packages/uni_app/lib/controller/parsers/parser_course_unit_info.dart index 3cbc5f0d0..a56e71b80 100644 --- a/packages/uni_app/lib/controller/parsers/parser_course_unit_info.dart +++ b/packages/uni_app/lib/controller/parsers/parser_course_unit_info.dart @@ -56,7 +56,7 @@ Future parseSheet(http.Response response) async { ); final regents = (json['responsabilidades'] as List).map((element) { - return Professor.fromJson(element as Map); + return Professor.fromJson(element as Map, isRegent: true); }).toList(); for (final regent in regents) { @@ -96,15 +96,23 @@ List getCourseUnitProfessors(List> ds) { final professors = []; for (final map in ds) { for (final docente in map['docentes'] as List) { - final professor = Professor( - code: (docente as Map)['doc_codigo'].toString(), - name: shortName(docente['nome'].toString()), + final professor = Professor.fromJson( + docente as Map, classes: [map['tipo'].toString()], ); + if (professors.contains(professor)) { - professors[professors.indexWhere((element) => element == professor)] - .classes - .add(map['tipo'].toString()); + final existingProfessor = + professors[professors.indexWhere( + (element) => element == professor, + )]; + + existingProfessor.classes.add(map['tipo'].toString()); + existingProfessor.institutionalEmail ??= professor.institutionalEmail; + existingProfessor.rooms = { + ...existingProfessor.rooms, + ...professor.rooms, + }.toList(); } else { professors.add(professor); } diff --git a/packages/uni_app/lib/model/entities/course_units/sheet.dart b/packages/uni_app/lib/model/entities/course_units/sheet.dart index cabd920eb..fafe1547d 100644 --- a/packages/uni_app/lib/model/entities/course_units/sheet.dart +++ b/packages/uni_app/lib/model/entities/course_units/sheet.dart @@ -29,22 +29,81 @@ class Professor { required this.code, required this.name, required this.classes, + this.institutionalEmail, + this.rooms = const [], this.picture, this.isRegent = false, }); - factory Professor.fromJson(Map json) { + factory Professor.fromJson( + Map json, { + List classes = const [], + bool isRegent = false, + }) { return Professor( - code: json['codigo'].toString(), + code: (json['codigo'] ?? json['doc_codigo']).toString(), name: shortName(json['nome'].toString()), - classes: [], + classes: classes, + institutionalEmail: _extractInstitutionalEmail(json), + rooms: _extractRooms(json), + isRegent: isRegent, ); } + static String? _extractInstitutionalEmail(Map json) { + final candidates = [ + json['email_institucional'], + json['email'], + json['mail'], + json['e_mail'], + json['contacto'], + ]; + + for (final candidate in candidates) { + final value = candidate?.toString().trim(); + if (value != null && value.isNotEmpty && value.contains('@')) { + return value; + } + } + + return null; + } + + static List _extractRooms(Map json) { + final candidates = [ + json['salas'], + json['sala'], + json['gabinete'], + json['gabinetes'], + ]; + + final rooms = {}; + + for (final candidate in candidates) { + if (candidate is List) { + for (final room in candidate) { + final value = room.toString().trim(); + if (value.isNotEmpty) { + rooms.add(value); + } + } + } else { + final value = candidate?.toString().trim(); + if (value != null && value.isNotEmpty) { + rooms.add(value); + } + } + } + + return rooms.toList(); + } + File? picture; String code; String name; List classes; + String? institutionalEmail; + List rooms; bool isRegent; @override From 7fc25bec6e655997c00c9c3b8d4c03a866b6e91f Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 14:21:32 +0000 Subject: [PATCH 04/28] updated rooms fetching logic --- .../widgets/modal_professor_info.dart | 209 ++++++++++++++++-- 1 file changed, 188 insertions(+), 21 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index f325ee6bf..cd6cc9897 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -1,29 +1,169 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:html/parser.dart'; import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; +import 'package:uni/session/flows/base/session.dart'; import 'package:uni_ui/icons.dart'; import 'package:uni_ui/modal/modal.dart'; import 'package:uni_ui/modal/widgets/info_row.dart'; import 'package:uni_ui/modal/widgets/person_info.dart'; +class _ProfessorExtraInfo { + const _ProfessorExtraInfo({this.email, this.rooms = const []}); + + final String? email; + final List rooms; +} + class ProfessorInfoModal extends ConsumerWidget { const ProfessorInfoModal(this.professor, {super.key}); final Professor professor; + Iterable _splitAndCleanRooms(String raw) sync* { + for (final token in raw.split(RegExp(r'\s*,\s*|\s*;\s*'))) { + final value = token + .trim() + .replaceAll( + RegExp( + r'^(Salas?|Gabinetes?|Rooms?)\s*:?\s*', + caseSensitive: false, + ), + '', + ) + .replaceAll(RegExp(r'^[sS]\s*:\s*'), '') + .trim(); + if (value.isNotEmpty) { + yield value; + } + } + } + + List _dedupeRooms(Iterable roomValues) { + final normalizedToDisplay = {}; + + for (final raw in roomValues) { + for (final room in _splitAndCleanRooms(raw)) { + final key = room.replaceAll(RegExp(r'\s+'), '').toUpperCase(); + normalizedToDisplay.putIfAbsent(key, () => room); + } + } + + return normalizedToDisplay.values.toList(); + } + + Future<_ProfessorExtraInfo> _fetchProfessorExtraInfo( + Session session, + String professorProfileUrl, + ) async { + final email = professor.institutionalEmail; + final rooms = {...professor.rooms}; + + if (email != null && rooms.isNotEmpty) { + return _ProfessorExtraInfo(email: email, rooms: rooms.toList()); + } + + try { + final response = await NetworkRouter.getWithCookies( + professorProfileUrl, + {}, + session, + ); + final document = parse(response.body); + + final mailToLinks = document.querySelectorAll('a[href^="mailto:"]'); + var parsedEmail = email; + for (final link in mailToLinks) { + final href = link.attributes['href']; + if (href == null) { + continue; + } + + final value = href.replaceFirst('mailto:', '').trim(); + if (value.contains('@')) { + parsedEmail = value; + break; + } + } + + if (parsedEmail == null) { + final bodyText = document.body?.text ?? ''; + final emailMatch = RegExp( + r'([A-Za-z0-9._%+-]+)\s*@\s*([A-Za-z0-9.-]+\.[A-Za-z]{2,})', + ).firstMatch(bodyText); + if (emailMatch != null) { + parsedEmail = + '${emailMatch.group(1)?.trim()}@${emailMatch.group(2)?.trim()}'; + } + } + + for (final roomLink in document.querySelectorAll( + 'a[href*="instal_geral.espaco_view"]', + )) { + final room = roomLink.text.trim(); + if (room.isNotEmpty) { + rooms.add(room); + } + } + + final roomLabelRegex = RegExp( + r'(Sala|Salas|Gabinete|Gabinetes|Room|Rooms)\s*:?\s*(.+)', + caseSensitive: false, + ); + + for (final row in document.querySelectorAll('tr')) { + final cells = row.querySelectorAll('th,td'); + if (cells.length < 2) { + continue; + } + + final label = cells.first.text.trim(); + if (!RegExp( + '(Sala|Gabinete|Room)', + caseSensitive: false, + ).hasMatch(label)) { + continue; + } + + final value = cells[1].text.trim(); + if (value.isNotEmpty) { + rooms.add(value); + } + } + + for (final element in document.querySelectorAll('p,li,span,div')) { + final text = element.text.trim().replaceAll('\n', ' '); + final match = roomLabelRegex.firstMatch(text); + final value = match?.group(2)?.trim(); + if (value != null && value.isNotEmpty && value.length <= 64) { + rooms.add(value); + } + } + + return _ProfessorExtraInfo( + email: parsedEmail, + rooms: _dedupeRooms(rooms), + ); + } catch (_) { + return _ProfessorExtraInfo(email: email, rooms: _dedupeRooms(rooms)); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(sessionProvider).value!; - final rooms = professor.rooms.join(', '); final baseUrls = NetworkRouter.getBaseUrlsFromSession(session); final scheduleUrl = baseUrls.isNotEmpty ? '${baseUrls[0]}hor_geral.docentes_view?pv_doc_codigo=${professor.code}' : null; + final professorProfileUrl = baseUrls.isNotEmpty + ? '${baseUrls[0]}func_geral.formview?p_codigo=${professor.code}' + : null; return ModalDialog( children: [ @@ -39,26 +179,53 @@ class ProfessorInfoModal extends ConsumerWidget { studentNumber: int.parse(professor.code), ), ), - if (professor.institutionalEmail != null) - ModalInfoRow( - title: S.of(context).email, - description: professor.institutionalEmail, - icon: UniIcons.email, - trailing: UniIcon( - UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, - ), - onPressed: () => launchUrlWithToast( - context, - 'mailto:${professor.institutionalEmail}', - ), - ), - if (rooms.isNotEmpty) - ModalInfoRow( - title: S.of(context).room, - description: rooms, - icon: UniIcons.location, - ), + FutureBuilder<_ProfessorExtraInfo>( + future: professorProfileUrl != null + ? _fetchProfessorExtraInfo(session, professorProfileUrl) + : Future.value( + _ProfessorExtraInfo( + email: professor.institutionalEmail, + rooms: professor.rooms, + ), + ), + builder: (context, snapshot) { + final info = + snapshot.data ?? + _ProfessorExtraInfo( + email: professor.institutionalEmail, + rooms: professor.rooms, + ); + final rows = []; + + if (info.email != null) { + rows.add( + ModalInfoRow( + title: S.of(context).email, + description: info.email, + icon: UniIcons.email, + trailing: UniIcon( + UniIcons.caretRight, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () => + launchUrlWithToast(context, 'mailto:${info.email}'), + ), + ); + } + + if (info.rooms.isNotEmpty) { + rows.add( + ModalInfoRow( + title: S.of(context).room, + description: info.rooms.join(', '), + icon: UniIcons.location, + ), + ); + } + + return Column(mainAxisSize: MainAxisSize.min, children: rows); + }, + ), if (scheduleUrl != null) ModalInfoRow( title: S.of(context).schedule, From cfd1e997818a3640c664c150c190ff3c6dc7b394 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 17:49:36 +0000 Subject: [PATCH 05/28] fixed emial fetch logic --- .../widgets/modal_professor_info.dart | 185 +++++++++++------- 1 file changed, 113 insertions(+), 72 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index cd6cc9897..0dc0e64de 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -59,7 +59,7 @@ class ProfessorInfoModal extends ConsumerWidget { Future<_ProfessorExtraInfo> _fetchProfessorExtraInfo( Session session, - String professorProfileUrl, + List baseUrls, ) async { final email = professor.institutionalEmail; final rooms = {...professor.rooms}; @@ -68,90 +68,134 @@ class ProfessorInfoModal extends ConsumerWidget { return _ProfessorExtraInfo(email: email, rooms: rooms.toList()); } - try { - final response = await NetworkRouter.getWithCookies( - professorProfileUrl, - {}, - session, - ); - final document = parse(response.body); - - final mailToLinks = document.querySelectorAll('a[href^="mailto:"]'); - var parsedEmail = email; - for (final link in mailToLinks) { - final href = link.attributes['href']; - if (href == null) { - continue; + for (final baseUrl in baseUrls) { + final profileUrl = + '${baseUrl}func_geral.formview?p_codigo=${professor.code}'; + try { + final response = await NetworkRouter.getWithCookies( + profileUrl, + {}, + session, + ); + final document = parse(response.body); + + var parsedEmail = email; + + // 1. mailto: links (case-insensitive href check) + for (final link in document.querySelectorAll('a[href]')) { + final href = link.attributes['href'] ?? ''; + if (!href.toLowerCase().startsWith('mailto:')) continue; + + final value = href + .substring('mailto:'.length) + .split('?') + .first + .trim(); + if (value.contains('@')) { + parsedEmail = value; + break; + } } - final value = href.replaceFirst('mailto:', '').trim(); - if (value.contains('@')) { - parsedEmail = value; - break; + // 2. Table row with an email label (e.g. "E-mail" / "Email") + if (parsedEmail == null) { + final emailLabelRegex = RegExp(r'E-?mail', caseSensitive: false); + for (final row in document.querySelectorAll('tr')) { + final cells = row.querySelectorAll('th,td'); + if (cells.length < 2) continue; + if (!emailLabelRegex.hasMatch(cells.first.text)) continue; + + final value = cells[1].text.trim(); + if (value.contains('@')) { + parsedEmail = value; + break; + } + } } - } - if (parsedEmail == null) { - final bodyText = document.body?.text ?? ''; - final emailMatch = RegExp( - r'([A-Za-z0-9._%+-]+)\s*@\s*([A-Za-z0-9.-]+\.[A-Za-z]{2,})', - ).firstMatch(bodyText); - if (emailMatch != null) { - parsedEmail = - '${emailMatch.group(1)?.trim()}@${emailMatch.group(2)?.trim()}'; + // 3. Obfuscated email: SIGARRA anti-spam onclick pattern + // onclick="…'lto'+':local'+secure+'domain'…" + // Read via DOM so HTML entities (' etc.) are decoded first. + if (parsedEmail == null) { + for (final link in document.querySelectorAll('a[onclick]')) { + final onclick = link.attributes['onclick'] ?? ''; + final onclickMatch = RegExp( + r"lto'\+':([A-Za-z0-9._%+\-]+)'\+secure\+'([A-Za-z0-9.\-]+\.[A-Za-z]{2,})'", + ).firstMatch(onclick); + if (onclickMatch != null) { + parsedEmail = '${onclickMatch.group(1)}@${onclickMatch.group(2)}'; + break; + } + } } - } - for (final roomLink in document.querySelectorAll( - 'a[href*="instal_geral.espaco_view"]', - )) { - final room = roomLink.text.trim(); - if (room.isNotEmpty) { - rooms.add(room); + // 4. Full body text regex fallback + if (parsedEmail == null) { + final bodyText = document.body?.text ?? ''; + final emailMatch = RegExp( + r'([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})', + ).firstMatch(bodyText); + if (emailMatch != null) { + parsedEmail = + '${emailMatch.group(1)?.trim()}@${emailMatch.group(2)?.trim()}'; + } } - } - final roomLabelRegex = RegExp( - r'(Sala|Salas|Gabinete|Gabinetes|Room|Rooms)\s*:?\s*(.+)', - caseSensitive: false, - ); + if (parsedEmail == null) continue; - for (final row in document.querySelectorAll('tr')) { - final cells = row.querySelectorAll('th,td'); - if (cells.length < 2) { - continue; + for (final roomLink in document.querySelectorAll( + 'a[href*="instal_geral.espaco_view"]', + )) { + final room = roomLink.text.trim(); + if (room.isNotEmpty) { + rooms.add(room); + } } - final label = cells.first.text.trim(); - if (!RegExp( - '(Sala|Gabinete|Room)', + final roomLabelRegex = RegExp( + r'(Sala|Salas|Gabinete|Gabinetes|Room|Rooms)\s*:?\s*(.+)', caseSensitive: false, - ).hasMatch(label)) { - continue; - } + ); + + for (final row in document.querySelectorAll('tr')) { + final cells = row.querySelectorAll('th,td'); + if (cells.length < 2) { + continue; + } - final value = cells[1].text.trim(); - if (value.isNotEmpty) { - rooms.add(value); + final label = cells.first.text.trim(); + if (!RegExp( + '(Sala|Gabinete|Room)', + caseSensitive: false, + ).hasMatch(label)) { + continue; + } + + final value = cells[1].text.trim(); + if (value.isNotEmpty) { + rooms.add(value); + } } - } - for (final element in document.querySelectorAll('p,li,span,div')) { - final text = element.text.trim().replaceAll('\n', ' '); - final match = roomLabelRegex.firstMatch(text); - final value = match?.group(2)?.trim(); - if (value != null && value.isNotEmpty && value.length <= 64) { - rooms.add(value); + for (final element in document.querySelectorAll('p,li,span,div')) { + final text = element.text.trim().replaceAll('\n', ' '); + final match = roomLabelRegex.firstMatch(text); + final value = match?.group(2)?.trim(); + if (value != null && value.isNotEmpty && value.length <= 64) { + rooms.add(value); + } } - } - return _ProfessorExtraInfo( - email: parsedEmail, - rooms: _dedupeRooms(rooms), - ); - } catch (_) { - return _ProfessorExtraInfo(email: email, rooms: _dedupeRooms(rooms)); + return _ProfessorExtraInfo( + email: parsedEmail, + rooms: _dedupeRooms(rooms), + ); + } catch (_) { + continue; + } } + + return _ProfessorExtraInfo(email: email, rooms: _dedupeRooms(rooms)); } @override @@ -161,9 +205,6 @@ class ProfessorInfoModal extends ConsumerWidget { final scheduleUrl = baseUrls.isNotEmpty ? '${baseUrls[0]}hor_geral.docentes_view?pv_doc_codigo=${professor.code}' : null; - final professorProfileUrl = baseUrls.isNotEmpty - ? '${baseUrls[0]}func_geral.formview?p_codigo=${professor.code}' - : null; return ModalDialog( children: [ @@ -180,8 +221,8 @@ class ProfessorInfoModal extends ConsumerWidget { ), ), FutureBuilder<_ProfessorExtraInfo>( - future: professorProfileUrl != null - ? _fetchProfessorExtraInfo(session, professorProfileUrl) + future: baseUrls.isNotEmpty + ? _fetchProfessorExtraInfo(session, baseUrls) : Future.value( _ProfessorExtraInfo( email: professor.institutionalEmail, From 3c2df5c1d3113d4e11fd5a847a6d664e5e628f18 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 18:03:08 +0000 Subject: [PATCH 06/28] simplified email fetch logic --- .../widgets/modal_professor_info.dart | 41 +++---------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index 0dc0e64de..20d8833d3 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -81,11 +81,9 @@ class ProfessorInfoModal extends ConsumerWidget { var parsedEmail = email; - // 1. mailto: links (case-insensitive href check) for (final link in document.querySelectorAll('a[href]')) { final href = link.attributes['href'] ?? ''; if (!href.toLowerCase().startsWith('mailto:')) continue; - final value = href .substring('mailto:'.length) .split('?') @@ -97,50 +95,21 @@ class ProfessorInfoModal extends ConsumerWidget { } } - // 2. Table row with an email label (e.g. "E-mail" / "Email") - if (parsedEmail == null) { - final emailLabelRegex = RegExp(r'E-?mail', caseSensitive: false); - for (final row in document.querySelectorAll('tr')) { - final cells = row.querySelectorAll('th,td'); - if (cells.length < 2) continue; - if (!emailLabelRegex.hasMatch(cells.first.text)) continue; - - final value = cells[1].text.trim(); - if (value.contains('@')) { - parsedEmail = value; - break; - } - } - } - - // 3. Obfuscated email: SIGARRA anti-spam onclick pattern - // onclick="…'lto'+':local'+secure+'domain'…" - // Read via DOM so HTML entities (' etc.) are decoded first. + // SIGARRA obfuscates @ as an HTML entity; read onclick via DOM so + // entities are decoded, then extract local+domain from the JS pattern. if (parsedEmail == null) { for (final link in document.querySelectorAll('a[onclick]')) { final onclick = link.attributes['onclick'] ?? ''; - final onclickMatch = RegExp( + final m = RegExp( r"lto'\+':([A-Za-z0-9._%+\-]+)'\+secure\+'([A-Za-z0-9.\-]+\.[A-Za-z]{2,})'", ).firstMatch(onclick); - if (onclickMatch != null) { - parsedEmail = '${onclickMatch.group(1)}@${onclickMatch.group(2)}'; + if (m != null) { + parsedEmail = '${m.group(1)}@${m.group(2)}'; break; } } } - // 4. Full body text regex fallback - if (parsedEmail == null) { - final bodyText = document.body?.text ?? ''; - final emailMatch = RegExp( - r'([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})', - ).firstMatch(bodyText); - if (emailMatch != null) { - parsedEmail = - '${emailMatch.group(1)?.trim()}@${emailMatch.group(2)?.trim()}'; - } - } - if (parsedEmail == null) continue; for (final roomLink in document.querySelectorAll( From e1b0eae2df1645a694ad7ef090f4b100251296b0 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 18:18:08 +0000 Subject: [PATCH 07/28] added shimmer --- .../widgets/modal_professor_info.dart | 98 +++++++++++++------ 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index 20d8833d3..aa7df7197 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:html/parser.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; @@ -83,7 +84,9 @@ class ProfessorInfoModal extends ConsumerWidget { for (final link in document.querySelectorAll('a[href]')) { final href = link.attributes['href'] ?? ''; - if (!href.toLowerCase().startsWith('mailto:')) continue; + if (!href.toLowerCase().startsWith('mailto:')) { + continue; + } final value = href .substring('mailto:'.length) .split('?') @@ -110,7 +113,9 @@ class ProfessorInfoModal extends ConsumerWidget { } } - if (parsedEmail == null) continue; + if (parsedEmail == null) { + continue; + } for (final roomLink in document.querySelectorAll( 'a[href*="instal_geral.espaco_view"]', @@ -199,41 +204,50 @@ class ProfessorInfoModal extends ConsumerWidget { ), ), builder: (context, snapshot) { - final info = - snapshot.data ?? - _ProfessorExtraInfo( - email: professor.institutionalEmail, - rooms: professor.rooms, - ); - final rows = []; - - if (info.email != null) { - rows.add( - ModalInfoRow( - title: S.of(context).email, - description: info.email, - icon: UniIcons.email, - trailing: UniIcon( - UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, + if (!snapshot.hasData) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ShimmerInfoRow( + title: S.of(context).email, + icon: UniIcons.email, ), - onPressed: () => - launchUrlWithToast(context, 'mailto:${info.email}'), - ), + _ShimmerInfoRow( + title: S.of(context).room, + icon: UniIcons.location, + ), + ], ); } - if (info.rooms.isNotEmpty) { - rows.add( + final info = snapshot.data!; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalInfoRow( + title: S.of(context).email, + description: info.email ?? '—', + icon: UniIcons.email, + trailing: info.email != null + ? UniIcon( + UniIcons.caretRight, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + onPressed: info.email != null + ? () => + launchUrlWithToast(context, 'mailto:${info.email}') + : null, + ), ModalInfoRow( title: S.of(context).room, - description: info.rooms.join(', '), + description: info.rooms.isNotEmpty + ? info.rooms.join(', ') + : '—', icon: UniIcons.location, ), - ); - } - - return Column(mainAxisSize: MainAxisSize.min, children: rows); + ], + ); }, ), if (scheduleUrl != null) @@ -250,3 +264,29 @@ class ProfessorInfoModal extends ConsumerWidget { ); } } + +class _ShimmerInfoRow extends StatelessWidget { + const _ShimmerInfoRow({required this.title, required this.icon}); + + final String title; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: ListTile( + dense: true, + leading: UniIcon(icon, color: Theme.of(context).colorScheme.primary), + title: Text(title, style: Theme.of(context).textTheme.headlineSmall), + subtitle: Shimmer.fromColors( + baseColor: Colors.grey[300]!, + highlightColor: Colors.grey[100]!, + child: Container(height: 10, width: 140, color: Colors.white), + ), + ), + ); + } +} From b968af7f26c1a58738ad28e5f8fd3ba505c90cd5 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 18:39:55 +0000 Subject: [PATCH 08/28] add professor schedule support to SchedulePage --- .../lib/view/academic_path/schedule_page.dart | 132 ++++++++++++++---- 1 file changed, 102 insertions(+), 30 deletions(-) diff --git a/packages/uni_app/lib/view/academic_path/schedule_page.dart b/packages/uni_app/lib/view/academic_path/schedule_page.dart index 034972b57..8e95067aa 100644 --- a/packages/uni_app/lib/view/academic_path/schedule_page.dart +++ b/packages/uni_app/lib/view/academic_path/schedule_page.dart @@ -1,54 +1,100 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:uni/model/providers/riverpod/default_consumer.dart'; import 'package:uni/model/providers/riverpod/lecture_provider.dart'; +import 'package:uni/model/providers/riverpod/session_provider.dart'; import 'package:uni/view/academic_path/widgets/no_classes_widget.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_shimmer.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_view.dart'; +import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; + +final professorLecturesProvider = FutureProvider.autoDispose + .family, String>((ref, professorCode) async { + final session = await ref.watch(sessionProvider.future); + if (session == null) { + return []; + } + return ScheduleFetcherNewApiProfessor( + professorCode: professorCode, + ).getLectures(session); + }); class SchedulePage extends ConsumerWidget { - SchedulePage({super.key, DateTime? now}) : now = now ?? DateTime.now(); + SchedulePage({super.key, DateTime? now, this.professorCode}) + : now = now ?? DateTime.now(); final DateTime now; + final String? professorCode; @override Widget build(BuildContext context, WidgetRef ref) { return MediaQuery.removePadding( context: context, removeBottom: true, - child: DefaultConsumer>( - provider: lectureProvider, - builder: (context, ref, lectures) { - final startOfWeek = _getStartOfWeek(now, lectures); - - return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); - }, - nullContentWidget: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Container( - height: constraints.maxHeight, - padding: const EdgeInsets.only(bottom: 120), - child: const Center(child: NoClassesWidget()), - ), + child: professorCode != null + ? _buildProfessorSchedule(context, ref) + : _buildStudentSchedule(context, ref), + ); + } + + Widget _buildProfessorSchedule(BuildContext context, WidgetRef ref) { + final asyncLectures = ref.watch(professorLecturesProvider(professorCode!)); + return asyncLectures.when( + loading: () => const ShimmerSchedulePage(), + error: (_, _) => const Center(child: NoClassesWidget()), + data: (allLectures) { + final startOfWeek = _getStartOfWeek(now, allLectures); + final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); + final lectures = allLectures + .where( + (l) => + l.startTime.isAfter(startOfWeek) && + l.startTime.isBefore(endOfNextWeek), + ) + .toList(); + if (lectures.isEmpty) { + return const Center(child: NoClassesWidget()); + } + return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); + }, + ); + } + + Widget _buildStudentSchedule(BuildContext context, WidgetRef ref) { + return DefaultConsumer>( + provider: lectureProvider, + builder: (context, ref, lectures) { + final startOfWeek = _getStartOfWeek(now, lectures); + + return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); + }, + nullContentWidget: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + height: constraints.maxHeight, + padding: const EdgeInsets.only(bottom: 120), + child: const Center(child: NoClassesWidget()), ), ), - hasContent: (lectures) => lectures.isNotEmpty, - mapper: (lectures) { - final startOfWeek = _getStartOfWeek(now, lectures); - final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); - - return lectures - .where( - (lecture) => - lecture.startTime.isAfter(startOfWeek) && - lecture.startTime.isBefore(endOfNextWeek), - ) - .toList(); - }, - loadingWidget: const ShimmerSchedulePage(), ), + hasContent: (lectures) => lectures.isNotEmpty, + mapper: (lectures) { + final startOfWeek = _getStartOfWeek(now, lectures); + final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); + + return lectures + .where( + (lecture) => + lecture.startTime.isAfter(startOfWeek) && + lecture.startTime.isBefore(endOfNextWeek), + ) + .toList(); + }, + loadingWidget: const ShimmerSchedulePage(), ); } @@ -65,3 +111,29 @@ class SchedulePage extends ConsumerWidget { return !hasLecturesThisWeek ? secondSunday : initialSunday; } } + +class ProfessorSchedulePageView extends ConsumerStatefulWidget { + const ProfessorSchedulePageView(this.professor, {super.key}); + + final Professor professor; + + @override + ConsumerState createState() => + _ProfessorSchedulePageViewState(); +} + +class _ProfessorSchedulePageViewState + extends SecondaryPageViewState { + @override + Future onRefresh() async { + ref.invalidate(professorLecturesProvider(widget.professor.code)); + } + + @override + String? getTitle() => widget.professor.name; + + @override + Widget getBody(BuildContext context) { + return SchedulePage(professorCode: widget.professor.code); + } +} From e71ada9557cfbf0e49200095b0d2a5e86ed9b142 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 18:40:02 +0000 Subject: [PATCH 09/28] add navProfessorSchedule route --- packages/uni_app/lib/utils/navigation_items.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uni_app/lib/utils/navigation_items.dart b/packages/uni_app/lib/utils/navigation_items.dart index e70a8dd2b..b1fd7a225 100644 --- a/packages/uni_app/lib/utils/navigation_items.dart +++ b/packages/uni_app/lib/utils/navigation_items.dart @@ -3,6 +3,7 @@ enum NavigationItem { navPersonalArea('area'), navExams('exames'), navCourseUnit('cadeira'), + navProfessorSchedule('horario_docente'), navStops('autocarros'), navLocations('locais', faculties: {'feup'}), navRestaurants('restaurantes'), From b8209492d9891354303c4ee25406d935eb617139 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 18:40:08 +0000 Subject: [PATCH 10/28] register professor schedule route --- packages/uni_app/lib/main.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/uni_app/lib/main.dart b/packages/uni_app/lib/main.dart index fbefafce8..919ac7b93 100644 --- a/packages/uni_app/lib/main.dart +++ b/packages/uni_app/lib/main.dart @@ -19,11 +19,13 @@ import 'package:uni/controller/local_storage/migrations/migration_controller.dar import 'package:uni/controller/local_storage/preferences_controller.dart'; import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/course_unit.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/plausible/plausible_provider.dart'; import 'package:uni/model/providers/riverpod/theme_provider.dart'; import 'package:uni/utils/navigation_items.dart'; import 'package:uni/view/about/about.dart'; import 'package:uni/view/academic_path/academic_path.dart'; +import 'package:uni/view/academic_path/schedule_page.dart'; import 'package:uni/view/bug_report/bug_report.dart'; import 'package:uni/view/calendar/calendar.dart'; import 'package:uni/view/course_unit_info/course_unit_info.dart'; @@ -181,6 +183,7 @@ class ApplicationState extends ConsumerState { onGenerateRoute: (settings) { final args = settings.arguments; final courseUnit = args is CourseUnit ? args : null; + final professor = args is Professor ? args : null; final transitionFunctions = Function()>{ '/${NavigationItem.navSplash.route}': () => PageTransition.splashTransitionRoute( @@ -250,6 +253,11 @@ class ApplicationState extends ConsumerState { page: CourseUnitDetailPageView(courseUnit!), settings: settings, ), + '/${NavigationItem.navProfessorSchedule.route}': () => + PageTransition.makePageTransition( + page: ProfessorSchedulePageView(professor!), + settings: settings, + ), }; final builder = transitionFunctions[settings.name]; From b28dc81f121aa7568ec044ef54636331bea087ce Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Mon, 9 Mar 2026 18:40:14 +0000 Subject: [PATCH 11/28] show professor schedule in-app instead of external link --- .../widgets/modal_professor_info.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index aa7df7197..b2c24a042 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -10,6 +10,7 @@ import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; import 'package:uni/session/flows/base/session.dart'; +import 'package:uni/utils/navigation_items.dart'; import 'package:uni_ui/icons.dart'; import 'package:uni_ui/modal/modal.dart'; import 'package:uni_ui/modal/widgets/info_row.dart'; @@ -176,9 +177,6 @@ class ProfessorInfoModal extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(sessionProvider).value!; final baseUrls = NetworkRouter.getBaseUrlsFromSession(session); - final scheduleUrl = baseUrls.isNotEmpty - ? '${baseUrls[0]}hor_geral.docentes_view?pv_doc_codigo=${professor.code}' - : null; return ModalDialog( children: [ @@ -250,7 +248,7 @@ class ProfessorInfoModal extends ConsumerWidget { ); }, ), - if (scheduleUrl != null) + if (baseUrls.isNotEmpty) ModalInfoRow( title: S.of(context).schedule, icon: UniIcons.lecture, @@ -258,7 +256,11 @@ class ProfessorInfoModal extends ConsumerWidget { UniIcons.caretRight, color: Theme.of(context).colorScheme.primary, ), - onPressed: () => launchUrlWithToast(context, scheduleUrl), + onPressed: () => Navigator.pushNamed( + context, + '/${NavigationItem.navProfessorSchedule.route}', + arguments: professor, + ), ), ], ); From 8754710356529e44766c5858c3149b61bf90ea83 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 18:16:18 +0000 Subject: [PATCH 12/28] refactor: Remove institutional email and rooms fields and their extraction logic from the Sheet model. --- .../model/entities/course_units/sheet.dart | 50 ------------------- 1 file changed, 50 deletions(-) diff --git a/packages/uni_app/lib/model/entities/course_units/sheet.dart b/packages/uni_app/lib/model/entities/course_units/sheet.dart index fafe1547d..5170fb447 100644 --- a/packages/uni_app/lib/model/entities/course_units/sheet.dart +++ b/packages/uni_app/lib/model/entities/course_units/sheet.dart @@ -44,60 +44,10 @@ class Professor { code: (json['codigo'] ?? json['doc_codigo']).toString(), name: shortName(json['nome'].toString()), classes: classes, - institutionalEmail: _extractInstitutionalEmail(json), - rooms: _extractRooms(json), isRegent: isRegent, ); } - static String? _extractInstitutionalEmail(Map json) { - final candidates = [ - json['email_institucional'], - json['email'], - json['mail'], - json['e_mail'], - json['contacto'], - ]; - - for (final candidate in candidates) { - final value = candidate?.toString().trim(); - if (value != null && value.isNotEmpty && value.contains('@')) { - return value; - } - } - - return null; - } - - static List _extractRooms(Map json) { - final candidates = [ - json['salas'], - json['sala'], - json['gabinete'], - json['gabinetes'], - ]; - - final rooms = {}; - - for (final candidate in candidates) { - if (candidate is List) { - for (final room in candidate) { - final value = room.toString().trim(); - if (value.isNotEmpty) { - rooms.add(value); - } - } - } else { - final value = candidate?.toString().trim(); - if (value != null && value.isNotEmpty) { - rooms.add(value); - } - } - } - - return rooms.toList(); - } - File? picture; String code; String name; From 912fb8ec2e8a726edb2232acc95de561d82c578b Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 18:52:35 +0000 Subject: [PATCH 13/28] Refactor: Extract professor extra information fetching logic into a dedicated fetcher and Riverpod provider. --- .../fetchers/professor_info_fetcher.dart | 157 +++++++++++ .../riverpod/professor_info_provider.dart | 23 ++ .../widgets/modal_professor_info.dart | 246 ++++-------------- 3 files changed, 230 insertions(+), 196 deletions(-) create mode 100644 packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart create mode 100644 packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart diff --git a/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart b/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart new file mode 100644 index 000000000..71acbe250 --- /dev/null +++ b/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart @@ -0,0 +1,157 @@ +import 'package:html/parser.dart'; +import 'package:uni/controller/networking/network_router.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; +import 'package:uni/session/flows/base/session.dart'; + +class ProfessorExtraInfo { + const ProfessorExtraInfo({this.email, this.rooms = const []}); + final String? email; + final List rooms; +} + +class ProfessorInfoFetcher { + Future fetchProfessorInfo( + Professor professor, + Session session, + List baseUrls, + ) async { + final email = professor.institutionalEmail; + final rooms = {...professor.rooms}; + + if (email != null && rooms.isNotEmpty) { + return ProfessorExtraInfo(email: email, rooms: rooms.toList()); + } + + for (final baseUrl in baseUrls) { + final profileUrl = + '${baseUrl}func_geral.formview?p_codigo=${professor.code}'; + try { + final response = await NetworkRouter.getWithCookies( + profileUrl, + {}, + session, + ); + final document = parse(response.body); + + var parsedEmail = email; + + for (final link in document.querySelectorAll('a[href]')) { + final href = link.attributes['href'] ?? ''; + if (!href.toLowerCase().startsWith('mailto:')) { + continue; + } + final value = href + .substring('mailto:'.length) + .split('?') + .first + .trim(); + if (value.contains('@')) { + parsedEmail = value; + break; + } + } + + if (parsedEmail == null) { + for (final link in document.querySelectorAll('a[onclick]')) { + final onclick = link.attributes['onclick'] ?? ''; + final m = RegExp( + r"lto'\+':([A-Za-z0-9._%+\-]+)'\+secure\+'([A-Za-z0-9.\-]+\.[A-Za-z]{2,})'", + ).firstMatch(onclick); + if (m != null) { + parsedEmail = '${m.group(1)}@${m.group(2)}'; + break; + } + } + } + + if (parsedEmail == null) { + continue; + } + + for (final roomLink in document.querySelectorAll( + 'a[href*="instal_geral.espaco_view"]', + )) { + final room = roomLink.text.trim(); + if (room.isNotEmpty) { + rooms.add(room); + } + } + + final roomLabelRegex = RegExp( + r'(Sala|Salas|Gabinete|Gabinetes|Room|Rooms)\s*:?\s*(.+)', + caseSensitive: false, + ); + + for (final row in document.querySelectorAll('tr')) { + final cells = row.querySelectorAll('th,td'); + if (cells.length < 2) { + continue; + } + + final label = cells.first.text.trim(); + if (!RegExp( + '(Sala|Gabinete|Room)', + caseSensitive: false, + ).hasMatch(label)) { + continue; + } + + final value = cells[1].text.trim(); + if (value.isNotEmpty) { + rooms.add(value); + } + } + + for (final element in document.querySelectorAll('p,li,span,div')) { + final text = element.text.trim().replaceAll('\n', ' '); + final match = roomLabelRegex.firstMatch(text); + final value = match?.group(2)?.trim(); + if (value != null && value.isNotEmpty && value.length <= 64) { + rooms.add(value); + } + } + + return ProfessorExtraInfo( + email: parsedEmail, + rooms: _dedupeRooms(rooms), + ); + } catch (_) { + continue; + } + } + + return ProfessorExtraInfo(email: email, rooms: _dedupeRooms(rooms)); + } + + List _dedupeRooms(Iterable roomValues) { + final normalizedToDisplay = {}; + + for (final raw in roomValues) { + for (final room in _splitAndCleanRooms(raw)) { + final key = room.replaceAll(RegExp(r'\s+'), '').toUpperCase(); + normalizedToDisplay.putIfAbsent(key, () => room); + } + } + + return normalizedToDisplay.values.toList(); + } + + Iterable _splitAndCleanRooms(String raw) sync* { + for (final token in raw.split(RegExp(r'\s*,\s*|\s*;\s*'))) { + final value = token + .trim() + .replaceAll( + RegExp( + r'^(Salas?|Gabinetes?|Rooms?)\s*:?\s*', + caseSensitive: false, + ), + '', + ) + .replaceAll(RegExp(r'^[sS]\s*:\s*'), '') + .trim(); + if (value.isNotEmpty) { + yield value; + } + } + } +} diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart new file mode 100644 index 000000000..853c6b71b --- /dev/null +++ b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uni/controller/fetchers/professor_info_fetcher.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; +import 'package:uni/model/providers/riverpod/session_provider.dart'; +import 'package:uni/session/flows/base/session.dart'; + +final professorInfoProvider = + FutureProvider.family(( + ref, + professor, + ) async { + final session = await ref.read(sessionProvider.future); + final baseUrls = session != null + ? List.from( + session.faculties.map((f) => 'https://sigarra.up.pt/$f/pt/'), + ) + : []; + return ProfessorInfoFetcher().fetchProfessorInfo( + professor, + session!, + baseUrls, + ); + }); diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index b2c24a042..af3a29eb3 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:html/parser.dart'; +import 'package:uni/model/providers/riverpod/professor_info_provider.dart'; import 'package:shimmer/shimmer.dart'; import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/networking/url_launcher.dart'; @@ -9,170 +9,16 @@ import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -import 'package:uni/session/flows/base/session.dart'; import 'package:uni/utils/navigation_items.dart'; import 'package:uni_ui/icons.dart'; import 'package:uni_ui/modal/modal.dart'; import 'package:uni_ui/modal/widgets/info_row.dart'; import 'package:uni_ui/modal/widgets/person_info.dart'; -class _ProfessorExtraInfo { - const _ProfessorExtraInfo({this.email, this.rooms = const []}); - - final String? email; - final List rooms; -} - class ProfessorInfoModal extends ConsumerWidget { const ProfessorInfoModal(this.professor, {super.key}); final Professor professor; - Iterable _splitAndCleanRooms(String raw) sync* { - for (final token in raw.split(RegExp(r'\s*,\s*|\s*;\s*'))) { - final value = token - .trim() - .replaceAll( - RegExp( - r'^(Salas?|Gabinetes?|Rooms?)\s*:?\s*', - caseSensitive: false, - ), - '', - ) - .replaceAll(RegExp(r'^[sS]\s*:\s*'), '') - .trim(); - if (value.isNotEmpty) { - yield value; - } - } - } - - List _dedupeRooms(Iterable roomValues) { - final normalizedToDisplay = {}; - - for (final raw in roomValues) { - for (final room in _splitAndCleanRooms(raw)) { - final key = room.replaceAll(RegExp(r'\s+'), '').toUpperCase(); - normalizedToDisplay.putIfAbsent(key, () => room); - } - } - - return normalizedToDisplay.values.toList(); - } - - Future<_ProfessorExtraInfo> _fetchProfessorExtraInfo( - Session session, - List baseUrls, - ) async { - final email = professor.institutionalEmail; - final rooms = {...professor.rooms}; - - if (email != null && rooms.isNotEmpty) { - return _ProfessorExtraInfo(email: email, rooms: rooms.toList()); - } - - for (final baseUrl in baseUrls) { - final profileUrl = - '${baseUrl}func_geral.formview?p_codigo=${professor.code}'; - try { - final response = await NetworkRouter.getWithCookies( - profileUrl, - {}, - session, - ); - final document = parse(response.body); - - var parsedEmail = email; - - for (final link in document.querySelectorAll('a[href]')) { - final href = link.attributes['href'] ?? ''; - if (!href.toLowerCase().startsWith('mailto:')) { - continue; - } - final value = href - .substring('mailto:'.length) - .split('?') - .first - .trim(); - if (value.contains('@')) { - parsedEmail = value; - break; - } - } - - // SIGARRA obfuscates @ as an HTML entity; read onclick via DOM so - // entities are decoded, then extract local+domain from the JS pattern. - if (parsedEmail == null) { - for (final link in document.querySelectorAll('a[onclick]')) { - final onclick = link.attributes['onclick'] ?? ''; - final m = RegExp( - r"lto'\+':([A-Za-z0-9._%+\-]+)'\+secure\+'([A-Za-z0-9.\-]+\.[A-Za-z]{2,})'", - ).firstMatch(onclick); - if (m != null) { - parsedEmail = '${m.group(1)}@${m.group(2)}'; - break; - } - } - } - - if (parsedEmail == null) { - continue; - } - - for (final roomLink in document.querySelectorAll( - 'a[href*="instal_geral.espaco_view"]', - )) { - final room = roomLink.text.trim(); - if (room.isNotEmpty) { - rooms.add(room); - } - } - - final roomLabelRegex = RegExp( - r'(Sala|Salas|Gabinete|Gabinetes|Room|Rooms)\s*:?\s*(.+)', - caseSensitive: false, - ); - - for (final row in document.querySelectorAll('tr')) { - final cells = row.querySelectorAll('th,td'); - if (cells.length < 2) { - continue; - } - - final label = cells.first.text.trim(); - if (!RegExp( - '(Sala|Gabinete|Room)', - caseSensitive: false, - ).hasMatch(label)) { - continue; - } - - final value = cells[1].text.trim(); - if (value.isNotEmpty) { - rooms.add(value); - } - } - - for (final element in document.querySelectorAll('p,li,span,div')) { - final text = element.text.trim().replaceAll('\n', ' '); - final match = roomLabelRegex.firstMatch(text); - final value = match?.group(2)?.trim(); - if (value != null && value.isNotEmpty && value.length <= 64) { - rooms.add(value); - } - } - - return _ProfessorExtraInfo( - email: parsedEmail, - rooms: _dedupeRooms(rooms), - ); - } catch (_) { - continue; - } - } - - return _ProfessorExtraInfo(email: email, rooms: _dedupeRooms(rooms)); - } - @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(sessionProvider).value!; @@ -192,18 +38,40 @@ class ProfessorInfoModal extends ConsumerWidget { studentNumber: int.parse(professor.code), ), ), - FutureBuilder<_ProfessorExtraInfo>( - future: baseUrls.isNotEmpty - ? _fetchProfessorExtraInfo(session, baseUrls) - : Future.value( - _ProfessorExtraInfo( - email: professor.institutionalEmail, - rooms: professor.rooms, + Consumer( + builder: (context, ref, _) { + final infoAsyncValue = ref.watch(professorInfoProvider(professor)); + return infoAsyncValue.when( + data: (info) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalInfoRow( + title: S.of(context).email, + description: info.email ?? '—', + icon: UniIcons.email, + trailing: info.email != null + ? UniIcon( + UniIcons.caretRight, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + onPressed: info.email != null + ? () => launchUrlWithToast( + context, + 'mailto:${info.email}', + ) + : null, + ), + ModalInfoRow( + title: S.of(context).room, + description: info.rooms.isNotEmpty + ? info.rooms.join(', ') + : '—', + icon: UniIcons.location, ), - ), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Column( + ], + ), + loading: () => Column( mainAxisSize: MainAxisSize.min, children: [ _ShimmerInfoRow( @@ -215,36 +83,22 @@ class ProfessorInfoModal extends ConsumerWidget { icon: UniIcons.location, ), ], - ); - } - - final info = snapshot.data!; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ModalInfoRow( - title: S.of(context).email, - description: info.email ?? '—', - icon: UniIcons.email, - trailing: info.email != null - ? UniIcon( - UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, - ) - : const SizedBox(), - onPressed: info.email != null - ? () => - launchUrlWithToast(context, 'mailto:${info.email}') - : null, - ), - ModalInfoRow( - title: S.of(context).room, - description: info.rooms.isNotEmpty - ? info.rooms.join(', ') - : '—', - icon: UniIcons.location, - ), - ], + ), + error: (err, stack) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalInfoRow( + title: S.of(context).email, + description: '—', + icon: UniIcons.email, + ), + ModalInfoRow( + title: S.of(context).room, + description: '—', + icon: UniIcons.location, + ), + ], + ), ); }, ), From 32ab1487ca6a74f508b4c472531e89808db6d70c Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 18:55:36 +0000 Subject: [PATCH 14/28] refactor: extract `_ShimmerInfoRow` into a new, reusable `ShimmerInfoRow` widget. --- .../widgets/modal_professor_info.dart | 32 ++----------------- .../widgets/shimmer_info_row.dart | 30 +++++++++++++++++ 2 files changed, 33 insertions(+), 29 deletions(-) create mode 100644 packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index af3a29eb3..bcec75130 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/model/providers/riverpod/professor_info_provider.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; @@ -14,6 +13,7 @@ import 'package:uni_ui/icons.dart'; import 'package:uni_ui/modal/modal.dart'; import 'package:uni_ui/modal/widgets/info_row.dart'; import 'package:uni_ui/modal/widgets/person_info.dart'; +import 'shimmer_info_row.dart'; class ProfessorInfoModal extends ConsumerWidget { const ProfessorInfoModal(this.professor, {super.key}); @@ -74,11 +74,11 @@ class ProfessorInfoModal extends ConsumerWidget { loading: () => Column( mainAxisSize: MainAxisSize.min, children: [ - _ShimmerInfoRow( + ShimmerInfoRow( title: S.of(context).email, icon: UniIcons.email, ), - _ShimmerInfoRow( + ShimmerInfoRow( title: S.of(context).room, icon: UniIcons.location, ), @@ -120,29 +120,3 @@ class ProfessorInfoModal extends ConsumerWidget { ); } } - -class _ShimmerInfoRow extends StatelessWidget { - const _ShimmerInfoRow({required this.title, required this.icon}); - - final String title; - final IconData icon; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: ListTile( - dense: true, - leading: UniIcon(icon, color: Theme.of(context).colorScheme.primary), - title: Text(title, style: Theme.of(context).textTheme.headlineSmall), - subtitle: Shimmer.fromColors( - baseColor: Colors.grey[300]!, - highlightColor: Colors.grey[100]!, - child: Container(height: 10, width: 140, color: Colors.white), - ), - ), - ); - } -} diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart new file mode 100644 index 000000000..7092221f2 --- /dev/null +++ b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:uni_ui/icons.dart'; + +class ShimmerInfoRow extends StatelessWidget { + const ShimmerInfoRow({required this.title, required this.icon, Key? key}) + : super(key: key); + + final String title; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: ListTile( + dense: true, + leading: UniIcon(icon, color: Theme.of(context).colorScheme.primary), + title: Text(title, style: Theme.of(context).textTheme.headlineSmall), + subtitle: Shimmer.fromColors( + baseColor: Colors.grey[300]!, + highlightColor: Colors.grey[100]!, + child: Container(height: 10, width: 140, color: Colors.white), + ), + ), + ); + } +} From 99360e9d8d7c35f85a3374eb43ffa2630b067dac Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 19:13:11 +0000 Subject: [PATCH 15/28] refactor: separate professor schedule display into its own page and file. --- packages/uni_app/lib/main.dart | 2 +- .../lib/view/academic_path/schedule_page.dart | 68 +------------ .../professor/professor_schedule_page.dart | 97 +++++++++++++++++++ 3 files changed, 100 insertions(+), 67 deletions(-) create mode 100644 packages/uni_app/lib/view/professor/professor_schedule_page.dart diff --git a/packages/uni_app/lib/main.dart b/packages/uni_app/lib/main.dart index 919ac7b93..5d234a72f 100644 --- a/packages/uni_app/lib/main.dart +++ b/packages/uni_app/lib/main.dart @@ -25,7 +25,7 @@ import 'package:uni/model/providers/riverpod/theme_provider.dart'; import 'package:uni/utils/navigation_items.dart'; import 'package:uni/view/about/about.dart'; import 'package:uni/view/academic_path/academic_path.dart'; -import 'package:uni/view/academic_path/schedule_page.dart'; +import 'package:uni/view/professor/professor_schedule_page.dart'; import 'package:uni/view/bug_report/bug_report.dart'; import 'package:uni/view/calendar/calendar.dart'; import 'package:uni/view/course_unit_info/course_unit_info.dart'; diff --git a/packages/uni_app/lib/view/academic_path/schedule_page.dart b/packages/uni_app/lib/view/academic_path/schedule_page.dart index 8e95067aa..1e70c4f54 100644 --- a/packages/uni_app/lib/view/academic_path/schedule_page.dart +++ b/packages/uni_app/lib/view/academic_path/schedule_page.dart @@ -11,55 +11,17 @@ import 'package:uni/view/academic_path/widgets/schedule_page_shimmer.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_view.dart'; import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; -final professorLecturesProvider = FutureProvider.autoDispose - .family, String>((ref, professorCode) async { - final session = await ref.watch(sessionProvider.future); - if (session == null) { - return []; - } - return ScheduleFetcherNewApiProfessor( - professorCode: professorCode, - ).getLectures(session); - }); - class SchedulePage extends ConsumerWidget { - SchedulePage({super.key, DateTime? now, this.professorCode}) - : now = now ?? DateTime.now(); + SchedulePage({super.key, DateTime? now}) : now = now ?? DateTime.now(); final DateTime now; - final String? professorCode; @override Widget build(BuildContext context, WidgetRef ref) { return MediaQuery.removePadding( context: context, removeBottom: true, - child: professorCode != null - ? _buildProfessorSchedule(context, ref) - : _buildStudentSchedule(context, ref), - ); - } - - Widget _buildProfessorSchedule(BuildContext context, WidgetRef ref) { - final asyncLectures = ref.watch(professorLecturesProvider(professorCode!)); - return asyncLectures.when( - loading: () => const ShimmerSchedulePage(), - error: (_, _) => const Center(child: NoClassesWidget()), - data: (allLectures) { - final startOfWeek = _getStartOfWeek(now, allLectures); - final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); - final lectures = allLectures - .where( - (l) => - l.startTime.isAfter(startOfWeek) && - l.startTime.isBefore(endOfNextWeek), - ) - .toList(); - if (lectures.isEmpty) { - return const Center(child: NoClassesWidget()); - } - return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); - }, + child: _buildStudentSchedule(context, ref), ); } @@ -111,29 +73,3 @@ class SchedulePage extends ConsumerWidget { return !hasLecturesThisWeek ? secondSunday : initialSunday; } } - -class ProfessorSchedulePageView extends ConsumerStatefulWidget { - const ProfessorSchedulePageView(this.professor, {super.key}); - - final Professor professor; - - @override - ConsumerState createState() => - _ProfessorSchedulePageViewState(); -} - -class _ProfessorSchedulePageViewState - extends SecondaryPageViewState { - @override - Future onRefresh() async { - ref.invalidate(professorLecturesProvider(widget.professor.code)); - } - - @override - String? getTitle() => widget.professor.name; - - @override - Widget getBody(BuildContext context) { - return SchedulePage(professorCode: widget.professor.code); - } -} diff --git a/packages/uni_app/lib/view/professor/professor_schedule_page.dart b/packages/uni_app/lib/view/professor/professor_schedule_page.dart new file mode 100644 index 000000000..37a776267 --- /dev/null +++ b/packages/uni_app/lib/view/professor/professor_schedule_page.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; +import 'package:uni/model/entities/lecture.dart'; +import 'package:uni/model/providers/riverpod/session_provider.dart'; +import 'package:uni/view/academic_path/widgets/no_classes_widget.dart'; +import 'package:uni/view/academic_path/widgets/schedule_page_shimmer.dart'; +import 'package:uni/view/academic_path/widgets/schedule_page_view.dart'; +import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; + +final professorLecturesProvider = FutureProvider.autoDispose + .family, String>((ref, professorCode) async { + final session = await ref.watch(sessionProvider.future); + if (session == null) { + return []; + } + return ScheduleFetcherNewApiProfessor( + professorCode: professorCode, + ).getLectures(session); + }); + +class ProfessorSchedulePage extends ConsumerWidget { + ProfessorSchedulePage({super.key, required this.professor}); + + final Professor professor; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MediaQuery.removePadding( + context: context, + removeBottom: true, + child: _buildProfessorSchedule(context, ref), + ); + } + + Widget _buildProfessorSchedule(BuildContext context, WidgetRef ref) { + final asyncLectures = ref.watch(professorLecturesProvider(professor.code)); + final now = DateTime.now(); + return asyncLectures.when( + loading: () => const ShimmerSchedulePage(), + error: (_, _) => const Center(child: NoClassesWidget()), + data: (allLectures) { + final startOfWeek = _getStartOfWeek(now, allLectures); + final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); + final lectures = allLectures + .where( + (l) => + l.startTime.isAfter(startOfWeek) && + l.startTime.isBefore(endOfNextWeek), + ) + .toList(); + if (lectures.isEmpty) { + return const Center(child: NoClassesWidget()); + } + return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); + }, + ); + } + + DateTime _getStartOfWeek(DateTime now, List lectures) { + final initialSunday = now.subtract(Duration(days: now.weekday % 7)); + final secondSunday = initialSunday.add(const Duration(days: 7)); + final hasLecturesThisWeek = lectures.any( + (lecture) => + lecture.endTime.isAfter(now) && + lecture.startTime.isBefore(secondSunday), + ); + return !hasLecturesThisWeek ? secondSunday : initialSunday; + } +} + +class ProfessorSchedulePageView extends ConsumerStatefulWidget { + const ProfessorSchedulePageView(this.professor, {super.key}); + + final Professor professor; + + @override + ConsumerState createState() => + _ProfessorSchedulePageViewState(); +} + +class _ProfessorSchedulePageViewState + extends SecondaryPageViewState { + @override + Future onRefresh() async { + ref.invalidate(professorLecturesProvider(widget.professor.code)); + } + + @override + String? getTitle() => widget.professor.name; + + @override + Widget getBody(BuildContext context) { + return ProfessorSchedulePage(professor: widget.professor); + } +} From 75bec0cd692a12efa5feda7d731960bedfb6b823 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 19:22:07 +0000 Subject: [PATCH 16/28] reverted changes on schedule_page --- .../lib/view/academic_path/schedule_page.dart | 64 ++++++++----------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/packages/uni_app/lib/view/academic_path/schedule_page.dart b/packages/uni_app/lib/view/academic_path/schedule_page.dart index 1e70c4f54..034972b57 100644 --- a/packages/uni_app/lib/view/academic_path/schedule_page.dart +++ b/packages/uni_app/lib/view/academic_path/schedule_page.dart @@ -1,15 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; -import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:uni/model/providers/riverpod/default_consumer.dart'; import 'package:uni/model/providers/riverpod/lecture_provider.dart'; -import 'package:uni/model/providers/riverpod/session_provider.dart'; import 'package:uni/view/academic_path/widgets/no_classes_widget.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_shimmer.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_view.dart'; -import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; class SchedulePage extends ConsumerWidget { SchedulePage({super.key, DateTime? now}) : now = now ?? DateTime.now(); @@ -21,42 +17,38 @@ class SchedulePage extends ConsumerWidget { return MediaQuery.removePadding( context: context, removeBottom: true, - child: _buildStudentSchedule(context, ref), - ); - } - - Widget _buildStudentSchedule(BuildContext context, WidgetRef ref) { - return DefaultConsumer>( - provider: lectureProvider, - builder: (context, ref, lectures) { - final startOfWeek = _getStartOfWeek(now, lectures); + child: DefaultConsumer>( + provider: lectureProvider, + builder: (context, ref, lectures) { + final startOfWeek = _getStartOfWeek(now, lectures); - return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); - }, - nullContentWidget: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Container( - height: constraints.maxHeight, - padding: const EdgeInsets.only(bottom: 120), - child: const Center(child: NoClassesWidget()), + return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); + }, + nullContentWidget: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + height: constraints.maxHeight, + padding: const EdgeInsets.only(bottom: 120), + child: const Center(child: NoClassesWidget()), + ), ), ), - ), - hasContent: (lectures) => lectures.isNotEmpty, - mapper: (lectures) { - final startOfWeek = _getStartOfWeek(now, lectures); - final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); + hasContent: (lectures) => lectures.isNotEmpty, + mapper: (lectures) { + final startOfWeek = _getStartOfWeek(now, lectures); + final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); - return lectures - .where( - (lecture) => - lecture.startTime.isAfter(startOfWeek) && - lecture.startTime.isBefore(endOfNextWeek), - ) - .toList(); - }, - loadingWidget: const ShimmerSchedulePage(), + return lectures + .where( + (lecture) => + lecture.startTime.isAfter(startOfWeek) && + lecture.startTime.isBefore(endOfNextWeek), + ) + .toList(); + }, + loadingWidget: const ShimmerSchedulePage(), + ), ); } From 769998b6c8efedb351c98aafc34c80b5b20df9f9 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 19:28:18 +0000 Subject: [PATCH 17/28] refactor: update widget constructors to use super.key and clean up imports. --- packages/uni_app/lib/main.dart | 2 +- .../lib/model/providers/riverpod/professor_info_provider.dart | 1 - .../view/course_unit_info/widgets/modal_professor_info.dart | 2 +- .../lib/view/course_unit_info/widgets/shimmer_info_row.dart | 3 +-- .../uni_app/lib/view/professor/professor_schedule_page.dart | 4 ++-- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/uni_app/lib/main.dart b/packages/uni_app/lib/main.dart index 5d234a72f..e50e900be 100644 --- a/packages/uni_app/lib/main.dart +++ b/packages/uni_app/lib/main.dart @@ -25,7 +25,6 @@ import 'package:uni/model/providers/riverpod/theme_provider.dart'; import 'package:uni/utils/navigation_items.dart'; import 'package:uni/view/about/about.dart'; import 'package:uni/view/academic_path/academic_path.dart'; -import 'package:uni/view/professor/professor_schedule_page.dart'; import 'package:uni/view/bug_report/bug_report.dart'; import 'package:uni/view/calendar/calendar.dart'; import 'package:uni/view/course_unit_info/course_unit_info.dart'; @@ -35,6 +34,7 @@ import 'package:uni/view/home/home.dart'; import 'package:uni/view/locale_notifier.dart'; import 'package:uni/view/login/login.dart'; import 'package:uni/view/map/map.dart'; +import 'package:uni/view/professor/professor_schedule_page.dart'; import 'package:uni/view/profile/profile.dart'; import 'package:uni/view/restaurant/restaurant_page_view.dart'; import 'package:uni/view/splash/splash.dart'; diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart index 853c6b71b..523eb9b98 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/controller/fetchers/professor_info_fetcher.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -import 'package:uni/session/flows/base/session.dart'; final professorInfoProvider = FutureProvider.family(( diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index bcec75130..96de3db4a 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -2,8 +2,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/model/providers/riverpod/professor_info_provider.dart'; -import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/networking/url_launcher.dart'; +import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart index 7092221f2..351466532 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart @@ -3,8 +3,7 @@ import 'package:shimmer/shimmer.dart'; import 'package:uni_ui/icons.dart'; class ShimmerInfoRow extends StatelessWidget { - const ShimmerInfoRow({required this.title, required this.icon, Key? key}) - : super(key: key); + const ShimmerInfoRow({required this.title, required this.icon, super.key}); final String title; final IconData icon; diff --git a/packages/uni_app/lib/view/professor/professor_schedule_page.dart b/packages/uni_app/lib/view/professor/professor_schedule_page.dart index 37a776267..d0554c9d7 100644 --- a/packages/uni_app/lib/view/professor/professor_schedule_page.dart +++ b/packages/uni_app/lib/view/professor/professor_schedule_page.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; import 'package:uni/view/academic_path/widgets/no_classes_widget.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_shimmer.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_view.dart'; import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; -import 'package:uni/model/entities/course_units/sheet.dart'; final professorLecturesProvider = FutureProvider.autoDispose .family, String>((ref, professorCode) async { @@ -21,7 +21,7 @@ final professorLecturesProvider = FutureProvider.autoDispose }); class ProfessorSchedulePage extends ConsumerWidget { - ProfessorSchedulePage({super.key, required this.professor}); + const ProfessorSchedulePage({super.key, required this.professor}); final Professor professor; From 40bc85e7a288616fcbc1fbf33904d15484880324 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Sun, 15 Mar 2026 19:32:24 +0000 Subject: [PATCH 18/28] fixed lint --- .../view/course_unit_info/widgets/modal_professor_info.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index 96de3db4a..9cd53eb55 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -1,11 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:uni/model/providers/riverpod/professor_info_provider.dart'; -import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/controller/networking/network_router.dart'; +import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; +import 'package:uni/model/providers/riverpod/professor_info_provider.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; import 'package:uni/utils/navigation_items.dart'; From 05daa1115a56b3479210a8e4afaf44ca053741d2 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Thu, 2 Apr 2026 17:30:59 +0100 Subject: [PATCH 19/28] removed unnecessary data duplication --- .../fetchers/professor_info_fetcher.dart | 37 +++++++++++++------ .../riverpod/professor_info_provider.dart | 33 ++++++++--------- .../widgets/modal_professor_info.dart | 8 ++-- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart b/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart index 71acbe250..3198dbbec 100644 --- a/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart +++ b/packages/uni_app/lib/controller/fetchers/professor_info_fetcher.dart @@ -3,14 +3,8 @@ import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/session/flows/base/session.dart'; -class ProfessorExtraInfo { - const ProfessorExtraInfo({this.email, this.rooms = const []}); - final String? email; - final List rooms; -} - class ProfessorInfoFetcher { - Future fetchProfessorInfo( + Future fetchProfessorInfo( Professor professor, Session session, List baseUrls, @@ -19,7 +13,15 @@ class ProfessorInfoFetcher { final rooms = {...professor.rooms}; if (email != null && rooms.isNotEmpty) { - return ProfessorExtraInfo(email: email, rooms: rooms.toList()); + return Professor( + code: professor.code, + name: professor.name, + classes: professor.classes, + institutionalEmail: email, + rooms: rooms.toList(), + picture: professor.picture, + isRegent: professor.isRegent, + ); } for (final baseUrl in baseUrls) { @@ -111,16 +113,29 @@ class ProfessorInfoFetcher { } } - return ProfessorExtraInfo( - email: parsedEmail, + return Professor( + code: professor.code, + name: professor.name, + classes: professor.classes, + institutionalEmail: parsedEmail, rooms: _dedupeRooms(rooms), + picture: professor.picture, + isRegent: professor.isRegent, ); } catch (_) { continue; } } - return ProfessorExtraInfo(email: email, rooms: _dedupeRooms(rooms)); + return Professor( + code: professor.code, + name: professor.name, + classes: professor.classes, + institutionalEmail: email, + rooms: _dedupeRooms(rooms), + picture: professor.picture, + isRegent: professor.isRegent, + ); } List _dedupeRooms(Iterable roomValues) { diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart index 523eb9b98..c1c3569ec 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart @@ -3,20 +3,19 @@ import 'package:uni/controller/fetchers/professor_info_fetcher.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -final professorInfoProvider = - FutureProvider.family(( - ref, - professor, - ) async { - final session = await ref.read(sessionProvider.future); - final baseUrls = session != null - ? List.from( - session.faculties.map((f) => 'https://sigarra.up.pt/$f/pt/'), - ) - : []; - return ProfessorInfoFetcher().fetchProfessorInfo( - professor, - session!, - baseUrls, - ); - }); +final professorInfoProvider = FutureProvider.family(( + ref, + professor, +) async { + final session = await ref.read(sessionProvider.future); + final baseUrls = session != null + ? List.from( + session.faculties.map((f) => 'https://sigarra.up.pt/$f/pt/'), + ) + : []; + return ProfessorInfoFetcher().fetchProfessorInfo( + professor, + session!, + baseUrls, + ); +}); diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index 9cd53eb55..4245ffe94 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -47,18 +47,18 @@ class ProfessorInfoModal extends ConsumerWidget { children: [ ModalInfoRow( title: S.of(context).email, - description: info.email ?? '—', + description: info.institutionalEmail ?? '—', icon: UniIcons.email, - trailing: info.email != null + trailing: info.institutionalEmail != null ? UniIcon( UniIcons.caretRight, color: Theme.of(context).colorScheme.primary, ) : const SizedBox(), - onPressed: info.email != null + onPressed: info.institutionalEmail != null ? () => launchUrlWithToast( context, - 'mailto:${info.email}', + 'mailto:${info.institutionalEmail}', ) : null, ), From 68ef74bc65e3cb0d32ed662c1db534665880bb47 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Thu, 2 Apr 2026 17:32:49 +0100 Subject: [PATCH 20/28] refactored modal --- .../widgets/modal_professor_info.dart | 116 +++++++++--------- 1 file changed, 56 insertions(+), 60 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index 4245ffe94..f2d78800e 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -5,6 +5,7 @@ import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; +import 'package:uni/model/providers/riverpod/default_consumer.dart'; import 'package:uni/model/providers/riverpod/professor_info_provider.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; @@ -38,69 +39,64 @@ class ProfessorInfoModal extends ConsumerWidget { studentNumber: int.parse(professor.code), ), ), - Consumer( - builder: (context, ref, _) { - final infoAsyncValue = ref.watch(professorInfoProvider(professor)); - return infoAsyncValue.when( - data: (info) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ModalInfoRow( - title: S.of(context).email, - description: info.institutionalEmail ?? '—', - icon: UniIcons.email, - trailing: info.institutionalEmail != null - ? UniIcon( - UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, - ) - : const SizedBox(), - onPressed: info.institutionalEmail != null - ? () => launchUrlWithToast( - context, - 'mailto:${info.institutionalEmail}', - ) - : null, - ), - ModalInfoRow( - title: S.of(context).room, - description: info.rooms.isNotEmpty - ? info.rooms.join(', ') - : '—', - icon: UniIcons.location, - ), - ], + DefaultConsumer( + provider: professorInfoProvider(professor), + builder: (context, ref, info) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalInfoRow( + title: S.of(context).email, + description: info.institutionalEmail ?? '—', + icon: UniIcons.email, + trailing: info.institutionalEmail != null + ? UniIcon( + UniIcons.caretRight, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + onPressed: info.institutionalEmail != null + ? () => launchUrlWithToast( + context, + 'mailto:${info.institutionalEmail}', + ) + : null, ), - loading: () => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ShimmerInfoRow( - title: S.of(context).email, - icon: UniIcons.email, - ), - ShimmerInfoRow( - title: S.of(context).room, - icon: UniIcons.location, - ), - ], + ModalInfoRow( + title: S.of(context).room, + description: info.rooms.isNotEmpty + ? info.rooms.join(', ') + : '—', + icon: UniIcons.location, ), - error: (err, stack) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ModalInfoRow( - title: S.of(context).email, - description: '—', - icon: UniIcons.email, - ), - ModalInfoRow( - title: S.of(context).room, - description: '—', - icon: UniIcons.location, - ), - ], + ], + ), + loadingWidget: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ShimmerInfoRow(title: S.of(context).email, icon: UniIcons.email), + ShimmerInfoRow( + title: S.of(context).room, + icon: UniIcons.location, + ), + ], + ), + errorWidget: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalInfoRow( + title: S.of(context).email, + description: '—', + icon: UniIcons.email, ), - ); - }, + ModalInfoRow( + title: S.of(context).room, + description: '—', + icon: UniIcons.location, + ), + ], + ), + nullContentWidget: const SizedBox.shrink(), + hasContent: (info) => true, ), if (baseUrls.isNotEmpty) ModalInfoRow( From 8c6a2344edf7ccf266b2951d951b034c25fc662a Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Thu, 2 Apr 2026 17:54:19 +0100 Subject: [PATCH 21/28] removed duplicated logic --- .../riverpod/course_units_info_provider.dart | 21 ++--- .../riverpod/professor_lectures_provider.dart | 38 +++++++++ .../professor/professor_schedule_page.dart | 82 ++++++++++--------- 3 files changed, 89 insertions(+), 52 deletions(-) create mode 100644 packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart diff --git a/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart index f2d817ea7..e9d091253 100644 --- a/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart @@ -2,13 +2,13 @@ import 'dart:collection'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart'; -import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; import 'package:uni/model/entities/course_units/course_unit.dart'; import 'package:uni/model/entities/course_units/course_unit_class.dart'; import 'package:uni/model/entities/course_units/course_unit_directory.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:uni/model/providers/riverpod/cached_async_notifier.dart'; +import 'package:uni/model/providers/riverpod/professor_lectures_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; typedef SheetsMap = Map; @@ -205,11 +205,6 @@ class CourseUnitsInfoNotifier } Future fetchClassProfessors(CourseUnit courseUnit) async { - final session = await ref.read(sessionProvider.future); - if (session == null) { - return; - } - final sheet = courseUnitsSheets[courseUnit]; if (sheet == null) { return; @@ -229,14 +224,14 @@ class CourseUnitsInfoNotifier } for (final professor in professors) { - final fetcher = ScheduleFetcherNewApiProfessor( - professorCode: professor.code, - ); - try { - final lectures = await fetcher.getLectures( - session, - lectiveYear: lectiveYear, + final lectures = await ref.read( + professorLecturesProvider( + ProfessorLecturesParams( + professor: professor, + lectiveYear: lectiveYear, + ), + ).future, ); for (final lecture in lectures) { diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart new file mode 100644 index 000000000..2f692995a --- /dev/null +++ b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; +import 'package:uni/model/entities/course_units/sheet.dart'; +import 'package:uni/model/entities/lecture.dart'; +import 'package:uni/model/providers/riverpod/session_provider.dart'; + +/// Parameters for fetching professor lectures. +class ProfessorLecturesParams { + ProfessorLecturesParams({required this.professor, this.lectiveYear}); + + final Professor professor; + final int? lectiveYear; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ProfessorLecturesParams && + other.professor == professor && + other.lectiveYear == lectiveYear; + } + + @override + int get hashCode => Object.hash(professor, lectiveYear); +} + +/// Fetches the list of lectures for a specific professor. +/// +/// Optionally filters by lective year if provided. +final professorLecturesProvider = FutureProvider.autoDispose + .family, ProfessorLecturesParams>((ref, params) async { + final session = await ref.watch(sessionProvider.future); + if (session == null) { + return []; + } + return ScheduleFetcherNewApiProfessor( + professorCode: params.professor.code, + ).getLectures(session, lectiveYear: params.lectiveYear); + }); diff --git a/packages/uni_app/lib/view/professor/professor_schedule_page.dart b/packages/uni_app/lib/view/professor/professor_schedule_page.dart index d0554c9d7..e5b056665 100644 --- a/packages/uni_app/lib/view/professor/professor_schedule_page.dart +++ b/packages/uni_app/lib/view/professor/professor_schedule_page.dart @@ -1,60 +1,60 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:uni/controller/fetchers/schedule_fetcher/schedule_fetcher_new_api.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/entities/lecture.dart'; -import 'package:uni/model/providers/riverpod/session_provider.dart'; +import 'package:uni/model/providers/riverpod/default_consumer.dart'; +import 'package:uni/model/providers/riverpod/professor_lectures_provider.dart'; import 'package:uni/view/academic_path/widgets/no_classes_widget.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_shimmer.dart'; import 'package:uni/view/academic_path/widgets/schedule_page_view.dart'; import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; -final professorLecturesProvider = FutureProvider.autoDispose - .family, String>((ref, professorCode) async { - final session = await ref.watch(sessionProvider.future); - if (session == null) { - return []; - } - return ScheduleFetcherNewApiProfessor( - professorCode: professorCode, - ).getLectures(session); - }); - class ProfessorSchedulePage extends ConsumerWidget { - const ProfessorSchedulePage({super.key, required this.professor}); + ProfessorSchedulePage({super.key, required this.professor, DateTime? now}) + : now = now ?? DateTime.now(); final Professor professor; + final DateTime now; @override Widget build(BuildContext context, WidgetRef ref) { return MediaQuery.removePadding( context: context, removeBottom: true, - child: _buildProfessorSchedule(context, ref), - ); - } + child: DefaultConsumer>( + provider: professorLecturesProvider( + ProfessorLecturesParams(professor: professor), + ), + builder: (context, ref, lectures) { + final startOfWeek = _getStartOfWeek(now, lectures); - Widget _buildProfessorSchedule(BuildContext context, WidgetRef ref) { - final asyncLectures = ref.watch(professorLecturesProvider(professor.code)); - final now = DateTime.now(); - return asyncLectures.when( - loading: () => const ShimmerSchedulePage(), - error: (_, _) => const Center(child: NoClassesWidget()), - data: (allLectures) { - final startOfWeek = _getStartOfWeek(now, allLectures); - final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); - final lectures = allLectures - .where( - (l) => - l.startTime.isAfter(startOfWeek) && - l.startTime.isBefore(endOfNextWeek), - ) - .toList(); - if (lectures.isEmpty) { - return const Center(child: NoClassesWidget()); - } - return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); - }, + return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); + }, + nullContentWidget: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + height: constraints.maxHeight, + padding: const EdgeInsets.only(bottom: 120), + child: const Center(child: NoClassesWidget()), + ), + ), + ), + hasContent: (lectures) => lectures.isNotEmpty, + mapper: (lectures) { + final startOfWeek = _getStartOfWeek(now, lectures); + final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); + + return lectures + .where( + (lecture) => + lecture.startTime.isAfter(startOfWeek) && + lecture.startTime.isBefore(endOfNextWeek), + ) + .toList(); + }, + loadingWidget: const ShimmerSchedulePage(), + ), ); } @@ -84,7 +84,11 @@ class _ProfessorSchedulePageViewState extends SecondaryPageViewState { @override Future onRefresh() async { - ref.invalidate(professorLecturesProvider(widget.professor.code)); + ref.invalidate( + professorLecturesProvider( + ProfessorLecturesParams(professor: widget.professor), + ), + ); } @override From 0858c736e2231257c69d469604851c3f116cf069 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Thu, 2 Apr 2026 18:00:59 +0100 Subject: [PATCH 22/28] removed duplicated (or more) classes --- .../riverpod/professor_lectures_provider.dart | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart index 2f692995a..b5a600bcb 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart @@ -26,13 +26,31 @@ class ProfessorLecturesParams { /// Fetches the list of lectures for a specific professor. /// /// Optionally filters by lective year if provided. +/// Deduplicates lectures taught to multiple classes. final professorLecturesProvider = FutureProvider.autoDispose .family, ProfessorLecturesParams>((ref, params) async { final session = await ref.watch(sessionProvider.future); if (session == null) { return []; } - return ScheduleFetcherNewApiProfessor( + final lectures = await ScheduleFetcherNewApiProfessor( professorCode: params.professor.code, ).getLectures(session, lectiveYear: params.lectiveYear); + + // Deduplicate lectures that are taught to multiple classes + final seen = {}; + final uniqueLectures = []; + + for (final lecture in lectures) { + // Create a unique key based on time and content + final key = + '${lecture.startTime}|${lecture.endTime}|${lecture.subject}|${lecture.room}|${lecture.typeClass}'; + + if (!seen.contains(key)) { + seen.add(key); + uniqueLectures.add(lecture); + } + } + + return uniqueLectures; }); From 899d30f3119f91299cd25f9fc1362d759235477f Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Thu, 2 Apr 2026 18:07:51 +0100 Subject: [PATCH 23/28] fix lint --- .../model/providers/riverpod/professor_lectures_provider.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart index b5a600bcb..02d3b16bc 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart @@ -13,7 +13,9 @@ class ProfessorLecturesParams { @override bool operator ==(Object other) { - if (identical(this, other)) return true; + if (identical(this, other)) { + return true; + } return other is ProfessorLecturesParams && other.professor == professor && other.lectiveYear == lectiveYear; From c9138bd0569b49cc25de8f18fa818a9e0dbcdaa0 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Wed, 22 Apr 2026 11:38:37 +0100 Subject: [PATCH 24/28] refactor: professor_info_provider - keep FutureProvider.family due to AsyncNotifier.family complexity --- .../lib/model/providers/riverpod/professor_info_provider.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart index c1c3569ec..4eafbc9ac 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart @@ -3,6 +3,7 @@ import 'package:uni/controller/fetchers/professor_info_fetcher.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; +// Note: this provider expects a Professor object and uses its code as the cache key final professorInfoProvider = FutureProvider.family(( ref, professor, From 3167eaa4549e6eb92afe43589703124b2001e9a2 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Wed, 22 Apr 2026 11:39:49 +0100 Subject: [PATCH 25/28] refactor: replace ProfessorLecturesParams class with record type Simplifies the code by using Dart 3.10+ record syntax (professor, lectiveYear?) instead of a custom params class. Records automatically handle equality and hashing. --- .../riverpod/course_units_info_provider.dart | 7 +---- .../riverpod/professor_lectures_provider.dart | 30 ++++--------------- .../professor/professor_schedule_page.dart | 10 ++----- pubspec.lock | 16 +++++----- 4 files changed, 17 insertions(+), 46 deletions(-) diff --git a/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart index e9d091253..c8040b0f4 100644 --- a/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/course_units_info_provider.dart @@ -226,12 +226,7 @@ class CourseUnitsInfoNotifier for (final professor in professors) { try { final lectures = await ref.read( - professorLecturesProvider( - ProfessorLecturesParams( - professor: professor, - lectiveYear: lectiveYear, - ), - ).future, + professorLecturesProvider((professor, lectiveYear)).future, ); for (final lecture in lectures) { diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart index 02d3b16bc..bb609f2ba 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart @@ -4,40 +4,22 @@ import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -/// Parameters for fetching professor lectures. -class ProfessorLecturesParams { - ProfessorLecturesParams({required this.professor, this.lectiveYear}); - - final Professor professor; - final int? lectiveYear; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is ProfessorLecturesParams && - other.professor == professor && - other.lectiveYear == lectiveYear; - } - - @override - int get hashCode => Object.hash(professor, lectiveYear); -} - /// Fetches the list of lectures for a specific professor. /// /// Optionally filters by lective year if provided. /// Deduplicates lectures taught to multiple classes. +/// Family parameter: (professor, lectiveYear?) final professorLecturesProvider = FutureProvider.autoDispose - .family, ProfessorLecturesParams>((ref, params) async { + .family, (Professor, int?)>((ref, params) async { + final (professor, lectiveYear) = params; + final session = await ref.watch(sessionProvider.future); if (session == null) { return []; } final lectures = await ScheduleFetcherNewApiProfessor( - professorCode: params.professor.code, - ).getLectures(session, lectiveYear: params.lectiveYear); + professorCode: professor.code, + ).getLectures(session, lectiveYear: lectiveYear); // Deduplicate lectures that are taught to multiple classes final seen = {}; diff --git a/packages/uni_app/lib/view/professor/professor_schedule_page.dart b/packages/uni_app/lib/view/professor/professor_schedule_page.dart index e5b056665..e11605621 100644 --- a/packages/uni_app/lib/view/professor/professor_schedule_page.dart +++ b/packages/uni_app/lib/view/professor/professor_schedule_page.dart @@ -22,9 +22,7 @@ class ProfessorSchedulePage extends ConsumerWidget { context: context, removeBottom: true, child: DefaultConsumer>( - provider: professorLecturesProvider( - ProfessorLecturesParams(professor: professor), - ), + provider: professorLecturesProvider((professor, null)), builder: (context, ref, lectures) { final startOfWeek = _getStartOfWeek(now, lectures); @@ -84,11 +82,7 @@ class _ProfessorSchedulePageViewState extends SecondaryPageViewState { @override Future onRefresh() async { - ref.invalidate( - professorLecturesProvider( - ProfessorLecturesParams(professor: widget.professor), - ), - ); + ref.invalidate(professorLecturesProvider((widget.professor, null))); } @override diff --git a/pubspec.lock b/pubspec.lock index ed88702b7..880070b88 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1026,10 +1026,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1720,26 +1720,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.29.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.15" timelines: dependency: transitive description: From 27d67a77e9edf6fc4bc1a348456189cd264606b4 Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Wed, 22 Apr 2026 11:40:55 +0100 Subject: [PATCH 26/28] refactor: simplify deduplication in professorLecturesProvider Use the same .toSet().toList() deduplication approach as lectureProvider to reduce code duplication. This relies on Lecture's equality implementation. --- .../riverpod/professor_info_provider.dart | 1 - .../riverpod/professor_lectures_provider.dart | 23 ++++--------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart index 4eafbc9ac..c1c3569ec 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_info_provider.dart @@ -3,7 +3,6 @@ import 'package:uni/controller/fetchers/professor_info_fetcher.dart'; import 'package:uni/model/entities/course_units/sheet.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -// Note: this provider expects a Professor object and uses its code as the cache key final professorInfoProvider = FutureProvider.family(( ref, professor, diff --git a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart index bb609f2ba..1163b5164 100644 --- a/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/professor_lectures_provider.dart @@ -7,8 +7,7 @@ import 'package:uni/model/providers/riverpod/session_provider.dart'; /// Fetches the list of lectures for a specific professor. /// /// Optionally filters by lective year if provided. -/// Deduplicates lectures taught to multiple classes. -/// Family parameter: (professor, lectiveYear?) +/// Uses the same deduplication strategy as the main lectureProvider. final professorLecturesProvider = FutureProvider.autoDispose .family, (Professor, int?)>((ref, params) async { final (professor, lectiveYear) = params; @@ -17,24 +16,12 @@ final professorLecturesProvider = FutureProvider.autoDispose if (session == null) { return []; } + final lectures = await ScheduleFetcherNewApiProfessor( professorCode: professor.code, ).getLectures(session, lectiveYear: lectiveYear); - // Deduplicate lectures that are taught to multiple classes - final seen = {}; - final uniqueLectures = []; - - for (final lecture in lectures) { - // Create a unique key based on time and content - final key = - '${lecture.startTime}|${lecture.endTime}|${lecture.subject}|${lecture.room}|${lecture.typeClass}'; - - if (!seen.contains(key)) { - seen.add(key); - uniqueLectures.add(lecture); - } - } - - return uniqueLectures; + // Use the same deduplication as lectureProvider: convert to Set and back + // This relies on Lecture's equality implementation + return lectures.toSet().toList(); }); From fd59585d8182becbcfc1af1a0cb66ffa62830efe Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Wed, 22 Apr 2026 11:46:15 +0100 Subject: [PATCH 27/28] fix: update shimmer_info_row colors for dark mode support Replace hardcoded Colors.grey[300]/[100] with Theme.of(context).disabledColor to properly support both light and dark themes. Aligns with other shimmer widgets in the codebase. --- .../lib/view/course_unit_info/widgets/shimmer_info_row.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart index 351466532..71fce0372 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart @@ -19,8 +19,8 @@ class ShimmerInfoRow extends StatelessWidget { leading: UniIcon(icon, color: Theme.of(context).colorScheme.primary), title: Text(title, style: Theme.of(context).textTheme.headlineSmall), subtitle: Shimmer.fromColors( - baseColor: Colors.grey[300]!, - highlightColor: Colors.grey[100]!, + baseColor: Theme.of(context).disabledColor.withAlpha(0x7f), + highlightColor: Theme.of(context).disabledColor, child: Container(height: 10, width: 140, color: Colors.white), ), ), From e26680f60ed3e09811c60f0bca17c309dcc4d0cb Mon Sep 17 00:00:00 2001 From: Pedro Lunet Date: Wed, 22 Apr 2026 12:01:14 +0100 Subject: [PATCH 28/28] fix: improve icon contrast in course unit info modal - Use onSecondary color instead of primary for better contrast against secondary background in both light and dark modes - Apply consistently to email, schedule, and shimmer icons - Fixes low visibility of trailing caret icons in dark mode --- .../view/course_unit_info/widgets/modal_professor_info.dart | 4 ++-- .../lib/view/course_unit_info/widgets/shimmer_info_row.dart | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart index f2d78800e..17eebee9e 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/modal_professor_info.dart @@ -51,7 +51,7 @@ class ProfessorInfoModal extends ConsumerWidget { trailing: info.institutionalEmail != null ? UniIcon( UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, + color: Theme.of(context).colorScheme.onSecondary, ) : const SizedBox(), onPressed: info.institutionalEmail != null @@ -104,7 +104,7 @@ class ProfessorInfoModal extends ConsumerWidget { icon: UniIcons.lecture, trailing: UniIcon( UniIcons.caretRight, - color: Theme.of(context).colorScheme.primary, + color: Theme.of(context).colorScheme.onSecondary, ), onPressed: () => Navigator.pushNamed( context, diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart index 71fce0372..495b5d618 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/shimmer_info_row.dart @@ -16,7 +16,10 @@ class ShimmerInfoRow extends StatelessWidget { ), child: ListTile( dense: true, - leading: UniIcon(icon, color: Theme.of(context).colorScheme.primary), + leading: UniIcon( + icon, + color: Theme.of(context).colorScheme.onSecondary, + ), title: Text(title, style: Theme.of(context).textTheme.headlineSmall), subtitle: Shimmer.fromColors( baseColor: Theme.of(context).disabledColor.withAlpha(0x7f),