diff --git a/packages/uni_app/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart b/packages/uni_app/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart index 0f46bf248..aed931189 100644 --- a/packages/uni_app/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart +++ b/packages/uni_app/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart @@ -18,7 +18,7 @@ class CourseUnitsInfoFetcher implements SessionDependantFetcher { } Future fetchSheet(Session session, int occurId) async { - //TODO: Through this link we can't retrieve the sheet of a course unit in english + // TODO: Through this link we can't retrieve the sheet of a course unit in english final responses = await Future.wait( getEndpoints(session) .map( @@ -71,47 +71,63 @@ class CourseUnitsInfoFetcher implements SessionDependantFetcher { Session session, int occurrId, ) async { - var courseUnitClasses = []; + final endpoints = getEndpoints(session); + final allCourseChoices = await Future.wait( + endpoints.map((endpoint) { + final url = + '$endpoint' + 'it_listagem.lista_cursos_disciplina?pv_ocorrencia_id=$occurrId'; + return NetworkRouter.getWithCookies(url, {}, session) + .then((res) => (endpoint, res)) + .catchError((_) => (endpoint, Response('', 500))); + }), + ); + + final classUrls = <(String, String)>{}; + for (final (endpoint, response) in allCourseChoices) { + if (response.statusCode != 200) { + continue; + } - for (final endpoint in getEndpoints(session)) { - // Crawl classes from all courses that the course unit is offered in - final courseChoiceUrl = - '$endpoint' - 'it_listagem.lista_cursos_disciplina?pv_ocorrencia_id=$occurrId'; - final courseChoiceResponse = await NetworkRouter.getWithCookies( - courseChoiceUrl, - {}, - session, - ); - final courseChoiceDocument = parse(courseChoiceResponse.body); - final urls = courseChoiceDocument + final document = parse(response.body); + final links = document .querySelectorAll('a') .where( - (element) => - element.attributes['href'] != null && - element.attributes['href']!.contains( + (e) => + e.attributes['href']?.contains( 'it_listagem.lista_turma_disciplina', - ), - ) - .map((e) { - var url = e.attributes['href']!; - if (!url.contains('sigarra.up.pt')) { - url = endpoint + url; - } - return url; - }) - .toList(); + ) ?? + false, + ); - for (final url in urls) { - try { - final response = await NetworkRouter.getWithCookies(url, {}, session); - courseUnitClasses += parseCourseUnitClasses(response, endpoint); - } catch (_) { - continue; + for (final link in links) { + var url = link.attributes['href']!; + if (!url.contains('sigarra.up.pt')) { + url = endpoint + url; } + classUrls.add((endpoint, url)); + } + } + + final classResponses = await Future.wait( + classUrls.map( + (item) => NetworkRouter.getWithCookies(item.$2, {}, session) + .then((res) => (item.$1, res)) + .catchError((_) => (item.$1, Response('', 500))), + ), + ); + + final Map classesByName = {}; + for (final (endpoint, response) in classResponses) { + if (response.statusCode != 200) { + continue; + } + final parsedClasses = parseCourseUnitClasses(response, endpoint); + for (final c in parsedClasses) { + classesByName[c.className] = c; } } - return courseUnitClasses; + return classesByName.values.toList(); } } 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..21510acde 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 @@ -142,28 +142,53 @@ List parseCourseUnitClasses( ) { final classes = []; final document = parse(response.body); - final titles = document.querySelectorAll('#conteudoinner h3').sublist(1); + final titles = document.querySelectorAll('#conteudoinner h3'); + + if (titles.isEmpty) { + return []; + } for (final title in titles) { + final titleText = title.text.trim(); + if (!titleText.contains('Turma') && !titleText.contains('Class')) { + continue; + } + + final parts = titleText.split(RegExp(r'\s+')); + if (parts.length < 2) { + continue; + } + final className = parts[1].replaceAll(' ', '').trim(); + final table = title.nextElementSibling; - final className = title.innerHtml.substring( - title.innerHtml.indexOf(' ') + 1, - title.innerHtml.indexOf('&'), - ); + if (table == null || table.localName != 'table') { + continue; + } - final rows = table?.querySelectorAll('tr'); - if (rows == null || rows.length < 2) { + final allRows = table.querySelectorAll('tr'); + if (allRows.length < 2) { continue; } - final studentRows = rows.sublist(1); + final studentRows = allRows.sublist(1); final students = []; for (final row in studentRows) { - final columns = row.querySelectorAll('td.k.t'); - final studentName = columns[0].children[0].innerHtml; - final studentNumber = int.tryParse(columns[1].innerHtml.trim()) ?? 0; - final studentMail = columns[2].innerHtml; + final columns = row.querySelectorAll('td'); + if (columns.length < 3) { + continue; + } + + final nameLink = columns[0].querySelector('a'); + final studentName = nameLink != null + ? nameLink.text.trim() + : columns[0].text.trim(); + final studentNumber = int.tryParse(columns[1].text.trim()) ?? 0; + final studentMail = columns[2].text.trim(); + + if (studentNumber == 0) { + continue; + } final studentPhoto = Uri.parse( '${baseUrl}fotografias_service.foto?pct_cod=$studentNumber', @@ -182,7 +207,9 @@ List parseCourseUnitClasses( ); } - classes.add(CourseUnitClass(className, students)); + if (students.isNotEmpty) { + classes.add(CourseUnitClass(className, students)); + } } return classes; diff --git a/packages/uni_app/lib/model/entities/course_units/course_unit.dart b/packages/uni_app/lib/model/entities/course_units/course_unit.dart index c8a88e2ec..4be565b87 100644 --- a/packages/uni_app/lib/model/entities/course_units/course_unit.dart +++ b/packages/uni_app/lib/model/entities/course_units/course_unit.dart @@ -64,6 +64,23 @@ class CourseUnit { Map toJson() => _$CourseUnitToJson(this); + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is CourseUnit && + other.occurrId == occurrId && + other.name == name && + other.schoolYear == schoolYear; + } + + @override + int get hashCode { + return Object.hash(occurrId, name, schoolYear); + } + bool enrollmentIsValid() { return status == 'V' || status == 'C'; } 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 5a7901c11..bc38e2eee 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 @@ -60,6 +60,16 @@ class CourseUnitsInfoNotifier ); } + @override + Future build() async { + return ( + {}, + >{}, + >{}, + >>{}, + ); + } + @override Future loadFromStorage() async { return ( diff --git a/packages/uni_app/lib/view/academic_path/academic_path.dart b/packages/uni_app/lib/view/academic_path/academic_path.dart index f1d0d25ca..85eb8fb65 100644 --- a/packages/uni_app/lib/view/academic_path/academic_path.dart +++ b/packages/uni_app/lib/view/academic_path/academic_path.dart @@ -29,6 +29,7 @@ class AcademicPathPageViewState S.of(context).nav_title(NavigationItem.navAcademicPath.route); late TabController tabController; + late final List _tabs; @override void initState() { @@ -38,6 +39,7 @@ class AcademicPathPageViewState length: 3, initialIndex: widget.initialTabIndex, ); + _tabs = [const CoursesPage(), SchedulePage(), const ExamsPage()]; } @override @@ -61,10 +63,7 @@ class AcademicPathPageViewState @override Widget getBody(BuildContext context) { - return TabBarView( - controller: tabController, - children: [const CoursesPage(), SchedulePage(), const ExamsPage()], - ); + return TabBarView(controller: tabController, children: _tabs); } @override diff --git a/packages/uni_app/lib/view/academic_path/courses_page.dart b/packages/uni_app/lib/view/academic_path/courses_page.dart index 35bc245f1..b26328801 100644 --- a/packages/uni_app/lib/view/academic_path/courses_page.dart +++ b/packages/uni_app/lib/view/academic_path/courses_page.dart @@ -19,9 +19,13 @@ class CoursesPage extends ConsumerStatefulWidget { ConsumerState createState() => CoursesPageState(); } -class CoursesPageState extends ConsumerState { +class CoursesPageState extends ConsumerState + with AutomaticKeepAliveClientMixin { static Locale? _lastLocale; + @override + bool get wantKeepAlive => true; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -123,8 +127,8 @@ class CoursesPageState extends ConsumerState { return '???'; } - //TODO: This fix(finished courses the abbreviation is null) works when the - //app is in portuguese, but not in english. Where instead of LEIC it will be BICE. + // TODO: This fix(finished courses the abbreviation is null) works when the + // app is in portuguese, but not in english. Where instead of LEIC it will be BICE. return course.name! .replaceAll('Licenciatura', 'Licenciatura.') .replaceAll('Mestrado', 'Mestrado.') @@ -134,6 +138,7 @@ class CoursesPageState extends ConsumerState { @override Widget build(BuildContext context) { + super.build(context); return DefaultConsumer( provider: profileProvider, builder: (context, ref, profile) { diff --git a/packages/uni_app/lib/view/academic_path/exam_page.dart b/packages/uni_app/lib/view/academic_path/exam_page.dart index 8c90d1dcd..270ccbc0f 100644 --- a/packages/uni_app/lib/view/academic_path/exam_page.dart +++ b/packages/uni_app/lib/view/academic_path/exam_page.dart @@ -15,13 +15,18 @@ class ExamsPage extends ConsumerStatefulWidget { ConsumerState createState() => _ExamsPageState(); } -class _ExamsPageState extends ConsumerState { +class _ExamsPageState extends ConsumerState + with AutomaticKeepAliveClientMixin { List hiddenExams = PreferencesController.getHiddenExams(); Map filteredExamTypes = PreferencesController.getFilteredExams(); + @override + bool get wantKeepAlive => true; + @override Widget build(BuildContext context) { + super.build(context); /* If we want to filters exams again filteredExamTypes[Exam.getExamTypeLong(exam.examType)] ?? 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..aa252ffa0 100644 --- a/packages/uni_app/lib/view/academic_path/schedule_page.dart +++ b/packages/uni_app/lib/view/academic_path/schedule_page.dart @@ -7,22 +7,36 @@ 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'; -class SchedulePage extends ConsumerWidget { +class SchedulePage extends ConsumerStatefulWidget { SchedulePage({super.key, DateTime? now}) : now = now ?? DateTime.now(); final DateTime now; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SchedulePageState(); +} + +class _SchedulePageState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); return MediaQuery.removePadding( context: context, removeBottom: true, child: DefaultConsumer>( provider: lectureProvider, builder: (context, ref, lectures) { - final startOfWeek = _getStartOfWeek(now, lectures); + final startOfWeek = _getStartOfWeek(widget.now, lectures); - return SchedulePageView(lectures, startOfWeek: startOfWeek, now: now); + return SchedulePageView( + lectures, + startOfWeek: startOfWeek, + now: widget.now, + ); }, nullContentWidget: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( @@ -36,7 +50,7 @@ class SchedulePage extends ConsumerWidget { ), hasContent: (lectures) => lectures.isNotEmpty, mapper: (lectures) { - final startOfWeek = _getStartOfWeek(now, lectures); + final startOfWeek = _getStartOfWeek(widget.now, lectures); final endOfNextWeek = startOfWeek.add(const Duration(days: 14)); return lectures diff --git a/packages/uni_app/lib/view/academic_path/widgets/exams_page_view.dart b/packages/uni_app/lib/view/academic_path/widgets/exams_page_view.dart index adb6f4c2e..0fd8be3af 100644 --- a/packages/uni_app/lib/view/academic_path/widgets/exams_page_view.dart +++ b/packages/uni_app/lib/view/academic_path/widgets/exams_page_view.dart @@ -35,45 +35,9 @@ class ExamsPageView extends ConsumerWidget { .expand((y) => List.generate(12, (index) => DateTime(y, index + 1))) .toList(); - final tabs = monthsDates.map((date) { - return SizedBox( - width: 30, - height: 34, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Text( - date.shortMonth(locale), - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - maxLines: 1, - ), - ), - Expanded( - child: Text( - '${date.month}', - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - maxLines: 1, - ), - ), - ], - ), - ); - }).toList(); - - final content = monthsDates.map((date) { - final monthKey = '${date.year}-${date.month}'; - final examsForMonth = examsByMonth[monthKey] ?? []; - return ExamMonthTimeline( - now: now, - monthDate: date, - exams: examsForMonth, - hiddenExams: hiddenExams, - onToggleHidden: onToggleHidden, - ); - }).toList(); + final tabEnabled = monthsDates + .map((date) => examsByMonth.containsKey('${date.year}-${date.month}')) + .toList(); final initialTabIndex = monthsDates.indexWhere((date) { final monthKey = '${date.year}-${date.month}'; @@ -83,14 +47,49 @@ class ExamsPageView extends ConsumerWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Timeline( - tabs: tabs, - content: content, + tabs: monthsDates.map((date) { + return SizedBox( + width: 30, + height: 34, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + date.shortMonth(locale), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, + ), + ), + Expanded( + child: Text( + '${date.month}', + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 1, + ), + ), + ], + ), + ); + }).toList(), + content: null, + contentBuilder: (context, index) { + final date = monthsDates[index]; + final monthKey = '${date.year}-${date.month}'; + final examsForMonth = examsByMonth[monthKey] ?? []; + return ExamMonthTimeline( + key: ValueKey('exams-month-$monthKey'), + now: now, + monthDate: date, + exams: examsForMonth, + hiddenExams: hiddenExams, + onToggleHidden: onToggleHidden, + ); + }, initialTab: initialTabIndex == -1 ? 0 : initialTabIndex, - tabEnabled: monthsDates - .map( - (date) => examsByMonth.containsKey('${date.year}-${date.month}'), - ) - .toList(), + tabEnabled: tabEnabled, ), ); } diff --git a/packages/uni_app/lib/view/academic_path/widgets/schedule_page_view.dart b/packages/uni_app/lib/view/academic_path/widgets/schedule_page_view.dart index 3323486c4..6d959ad0e 100644 --- a/packages/uni_app/lib/view/academic_path/widgets/schedule_page_view.dart +++ b/packages/uni_app/lib/view/academic_path/widgets/schedule_page_view.dart @@ -26,7 +26,7 @@ class SchedulePageView extends ConsumerWidget { ); final daysOfTheWeek = ref - .read(localeProvider.notifier) + .watch(localeProvider.notifier) .getWeekdaysWithLocale(); final reorderedDaysOfTheWeek = [ @@ -41,6 +41,29 @@ class SchedulePageView extends ConsumerWidget { date.day == now.day, ); + final lecturesByDay = >{}; + for (final lecture in lectures) { + final key = DateTime( + lecture.startTime.year, + lecture.startTime.month, + lecture.startTime.day, + ).millisecondsSinceEpoch; + lecturesByDay.putIfAbsent(key, () => []).add(lecture); + } + + List getLectures(DateTime date) { + final key = DateTime( + date.year, + date.month, + date.day, + ).millisecondsSinceEpoch; + return lecturesByDay[key] ?? []; + } + + final tabEnabled = reorderedDates + .map((date) => getLectures(date).isNotEmpty) + .toList(); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Timeline( @@ -76,38 +99,27 @@ class SchedulePageView extends ConsumerWidget { ), ) .toList(), - content: reorderedDates - .map( - (date) => ScheduleDayTimeline( - key: Key('schedule-page-day-view-${date.weekday}'), - now: now, - day: date, - lectures: _lecturesOfDay(lectures, date), - ), - ) - .toList(), + content: null, + contentBuilder: (context, index) { + final date = reorderedDates[index]; + return ScheduleDayTimeline( + key: ValueKey( + 'schedule-page-day-view-${date.millisecondsSinceEpoch}', + ), + now: now, + day: date, + lectures: getLectures(date), + ); + }, initialTab: (todayIndex != -1 && - _lecturesOfDay(lectures, reorderedDates[todayIndex]).isNotEmpty) + getLectures(reorderedDates[todayIndex]).isNotEmpty) ? todayIndex : reorderedDates.indexWhere( - (date) => - date.isAfter(now) && - _lecturesOfDay(lectures, date).isNotEmpty, + (date) => date.isAfter(now) && getLectures(date).isNotEmpty, ), - tabEnabled: reorderedDates - .map((date) => _lecturesOfDay(lectures, date).isNotEmpty) - .toList(), + tabEnabled: tabEnabled, ), ); } - - List _lecturesOfDay(List lectures, DateTime date) { - return lectures.where((lecture) { - final startTime = lecture.startTime; - return startTime.year == date.year && - startTime.month == date.month && - startTime.day == date.day; - }).toList(); - } } diff --git a/packages/uni_app/lib/view/bug_report/bug_report.dart b/packages/uni_app/lib/view/bug_report/bug_report.dart index 44fef0f0f..cacfff0c8 100644 --- a/packages/uni_app/lib/view/bug_report/bug_report.dart +++ b/packages/uni_app/lib/view/bug_report/bug_report.dart @@ -28,11 +28,6 @@ class BugReportPageViewState extends SecondaryPageViewState { loadBugClassList(); } - @override - void initState() { - super.initState(); - } - final bugDescriptions = { 0: 'bug_description_visual_detail', 1: 'bug_description_error', diff --git a/packages/uni_app/lib/view/course_unit_info/course_unit_info.dart b/packages/uni_app/lib/view/course_unit_info/course_unit_info.dart index beeb6fc48..0d1fcca15 100644 --- a/packages/uni_app/lib/view/course_unit_info/course_unit_info.dart +++ b/packages/uni_app/lib/view/course_unit_info/course_unit_info.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/generated/l10n.dart'; @@ -7,7 +9,6 @@ import 'package:uni/model/providers/riverpod/course_units_info_provider.dart'; import 'package:uni/model/providers/riverpod/exam_provider.dart'; import 'package:uni/view/course_unit_info/widgets/course_unit_classes.dart'; import 'package:uni/view/course_unit_info/widgets/course_unit_files.dart'; -import 'package:uni/view/course_unit_info/widgets/course_unit_no_files.dart'; import 'package:uni/view/course_unit_info/widgets/course_unit_sheet.dart'; import 'package:uni/view/widgets/pages_layouts/secondary/secondary.dart'; import 'package:uni_ui/icons.dart'; @@ -28,15 +29,19 @@ class CourseUnitDetailPageView extends ConsumerStatefulWidget { class CourseUnitDetailPageViewState extends SecondaryPageViewState with SingleTickerProviderStateMixin { - List courseUnitExams = []; - late TabController tabController; + late final List _tabs; @override void initState() { super.initState(); tabController = TabController(vsync: this, length: 3); tabController.addListener(_onTabChanged); + _tabs = [ + _courseUnitSheetView(context), + _courseUnitClassesView(context), + _courseUnitFilesView(context), + ]; } void _onTabChanged() { @@ -46,48 +51,55 @@ class CourseUnitDetailPageViewState } Future loadInfo({required bool force}) async { - final courseUnitsProvider = ref.read(courseUnitsInfoProvider.notifier); + final notifier = ref.read(courseUnitsInfoProvider.notifier); + final stateValue = ref.read(courseUnitsInfoProvider).value; + + final futures = >[]; + final sheets = stateValue?.$1; + if (sheets == null || !sheets.containsKey(widget.courseUnit) || force) { + futures.add(notifier.fetchCourseUnitSheet(widget.courseUnit)); + } - final courseUnitSheet = - courseUnitsProvider.courseUnitsSheets[widget.courseUnit]; - if (courseUnitSheet == null || force) { - await courseUnitsProvider.fetchCourseUnitSheet(widget.courseUnit); + final files = stateValue?.$3; + if (files == null || !files.containsKey(widget.courseUnit) || force) { + futures.add(notifier.fetchCourseUnitFiles(widget.courseUnit)); } - final courseUnitFiles = - courseUnitsProvider.courseUnitsFiles[widget.courseUnit]; - if (courseUnitFiles == null || force) { - await courseUnitsProvider.fetchCourseUnitFiles(widget.courseUnit); + if (futures.isNotEmpty) { + await Future.wait(futures); } } Future loadClasses({required bool force}) async { - final courseUnitsProvider = ref.read(courseUnitsInfoProvider.notifier); + final notifier = ref.read(courseUnitsInfoProvider.notifier); + final stateValue = ref.read(courseUnitsInfoProvider).value; - final courseUnitClasses = - courseUnitsProvider.courseUnitsClasses[widget.courseUnit]; - if (courseUnitClasses == null || force) { - await courseUnitsProvider.fetchCourseUnitClasses(widget.courseUnit); + final futures = >[]; + final classes = stateValue?.$2; + if (classes == null || !classes.containsKey(widget.courseUnit) || force) { + futures.add(notifier.fetchCourseUnitClasses(widget.courseUnit)); } - final courseUnitClassProfessors = - courseUnitsProvider.courseUnitsClassProfessors[widget.courseUnit]; - if (courseUnitClassProfessors == null || force) { - await courseUnitsProvider.fetchClassProfessors(widget.courseUnit); + final classProfessors = stateValue?.$4; + if (classProfessors == null || + !classProfessors.containsKey(widget.courseUnit) || + force) { + futures.add(notifier.fetchClassProfessors(widget.courseUnit)); + } + + if (futures.isNotEmpty) { + await Future.wait(futures); } } @override Future onRefresh() async { - await loadInfo(force: true); - if (tabController.index == 1) { - await loadClasses(force: true); - } + await Future.wait([loadInfo(force: true), loadClasses(force: true)]); } @override Future onLoad(BuildContext context) async { - await loadInfo(force: false); + unawaited(Future.wait([loadInfo(force: false), loadClasses(force: false)])); } @override @@ -105,38 +117,32 @@ class CourseUnitDetailPageViewState @override Widget getBody(BuildContext context) { - return TabBarView( - controller: tabController, - children: [ - _courseUnitSheetView(context), - _courseUnitClassesView(context), - _courseUnitFilesView(context), - ], - ); + return TabBarView(controller: tabController, children: _tabs); } Widget _courseUnitSheetView(BuildContext context) { return Consumer( builder: (context, ref, _) { - final sheet = ref - .watch(courseUnitsInfoProvider.notifier) - .courseUnitsSheets[widget.courseUnit]; + final sheet = ref.watch( + courseUnitsInfoProvider.select((s) => s.value?.$1[widget.courseUnit]), + ); final exams = ref.watch(examProvider); final courseExams = exams.maybeWhen( - data: (list) => list! - .where( - (exam) => exam.subjectAcronym == widget.courseUnit.abbreviation, - ) - .toList(), + data: (list) => + list + ?.where( + (exam) => + exam.subjectAcronym == widget.courseUnit.abbreviation, + ) + .toList() ?? + [], orElse: () => [], ); if (sheet == null) { - return Center( - child: Text(S.of(context).no_info, textAlign: TextAlign.center), - ); + return const Center(child: CircularProgressIndicator()); } return CourseUnitSheetView(sheet, courseExams); @@ -145,36 +151,33 @@ class CourseUnitDetailPageViewState } Widget _courseUnitFilesView(BuildContext context) { - final files = ref - .read(courseUnitsInfoProvider.notifier) - .courseUnitsFiles[widget.courseUnit]; - - if (files == null || files.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Container( - height: constraints.maxHeight, - padding: const EdgeInsets.only(bottom: 120), - child: const Center(child: NoFilesWidget()), - ), - ), - ); - } + return Consumer( + builder: (context, ref, _) { + final files = ref.watch( + courseUnitsInfoProvider.select((s) => s.value?.$3[widget.courseUnit]), + ); + + if (files == null) { + return const Center(child: CircularProgressIndicator()); + } - return CourseUnitFilesView(files); + return CourseUnitFilesView(files); + }, + ); } Widget _courseUnitClassesView(BuildContext context) { return Consumer( builder: (context, ref, _) { - ref.watch(courseUnitsInfoProvider); - final provider = ref.read(courseUnitsInfoProvider.notifier); - - final classes = provider.courseUnitsClasses[widget.courseUnit]; - final sheet = provider.courseUnitsSheets[widget.courseUnit]; - final classProfessors = - provider.courseUnitsClassProfessors[widget.courseUnit]; + final classes = ref.watch( + courseUnitsInfoProvider.select((s) => s.value?.$2[widget.courseUnit]), + ); + final sheet = ref.watch( + courseUnitsInfoProvider.select((s) => s.value?.$1[widget.courseUnit]), + ); + final classProfessors = ref.watch( + courseUnitsInfoProvider.select((s) => s.value?.$4[widget.courseUnit]), + ); if (classes == null) { return const Center(child: CircularProgressIndicator()); @@ -214,7 +217,6 @@ class CourseUnitDetailPageViewState color: Theme.of(context).iconTheme.color, ), onPressed: () async { - // If the course unit isn't from FEUP, sigarra redirects to the correct page final url = Uri.parse( 'https://sigarra.up.pt/feup/pt/ucurr_geral.ficha_uc_view?pv_ocorrencia_id=${widget.courseUnit.occurrId}', ); diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_classes.dart b/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_classes.dart index 176639705..b79606dab 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_classes.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_classes.dart @@ -33,7 +33,8 @@ class CourseUnitClassesView extends ConsumerStatefulWidget { _CourseUnitClassesViewState(); } -class _CourseUnitClassesViewState extends ConsumerState { +class _CourseUnitClassesViewState extends ConsumerState + with AutomaticKeepAliveClientMixin { static const double _itemWidth = 140; static const double _edgeSpacing = 14; static const _scrollDuration = Duration(milliseconds: 300); @@ -43,6 +44,9 @@ class _CourseUnitClassesViewState extends ConsumerState { int? selectedIndex; late int studentNumber; + @override + bool get wantKeepAlive => true; + void _scrollToSelectedClass() { if (selectedIndex == null || widget.classes.isEmpty) { return; @@ -85,13 +89,17 @@ class _CourseUnitClassesViewState extends ConsumerState { @override Widget build(BuildContext context) { + super.build(context); final sessionAsync = ref.read(sessionProvider); return sessionAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (session) { - final studentNumber = getStudentNumber(session!); + if (session == null) { + return const Center(child: Text('No session available.')); + } + final studentNumber = getStudentNumber(session); if (selectedIndex == null) { selectedIndex = widget.classes.indexWhere( diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_files.dart b/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_files.dart index c3a5bca3a..155cdf5ea 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_files.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_files.dart @@ -13,12 +13,23 @@ import 'package:uni_ui/cards/folder_card.dart'; import 'course_unit_no_files.dart'; -class CourseUnitFilesView extends ConsumerWidget { +class CourseUnitFilesView extends ConsumerStatefulWidget { const CourseUnitFilesView(this.files, {super.key}); final List files; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _CourseUnitFilesViewState(); +} + +class _CourseUnitFilesViewState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); final sessionAsync = ref.read(sessionProvider); return sessionAsync.when( @@ -29,7 +40,7 @@ class CourseUnitFilesView extends ConsumerWidget { return const Center(child: Text('No session available.')); } - final cards = files + final cards = widget.files .where((element) => element.files.isNotEmpty) .map( (e) => _buildCard(context, e.folderName, e.files, session), diff --git a/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_sheet.dart b/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_sheet.dart index 10445b820..d697e924d 100644 --- a/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_sheet.dart +++ b/packages/uni_app/lib/view/course_unit_info/widgets/course_unit_sheet.dart @@ -21,20 +21,31 @@ import 'package:uni_ui/cards/remaining_instructors_card.dart'; const double _horizontalSpacing = 8; const double _verticalSpacing = 8; -class CourseUnitSheetView extends ConsumerWidget { +class CourseUnitSheetView extends ConsumerStatefulWidget { const CourseUnitSheetView(this.courseUnitSheet, this.exams, {super.key}); final Sheet courseUnitSheet; final List exams; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _CourseUnitSheetViewState(); +} + +class _CourseUnitSheetViewState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); return ListView( children: [ _buildSection( title: S.of(context).instructors, titlePadding: 20, - content: courseUnitSheet.professors.isEmpty + content: widget.courseUnitSheet.professors.isEmpty ? Padding( padding: const EdgeInsets.only(top: 8, left: 20), child: Text( @@ -42,7 +53,7 @@ class CourseUnitSheetView extends ConsumerWidget { style: Theme.of(context).textTheme.bodyLarge, ), ) - : courseUnitSheet.professors.length <= 4 + : widget.courseUnitSheet.professors.length <= 4 ? Padding( padding: const EdgeInsets.symmetric( horizontal: 20, @@ -51,7 +62,7 @@ class CourseUnitSheetView extends ConsumerWidget { child: Wrap( spacing: _horizontalSpacing, runSpacing: _verticalSpacing, - children: courseUnitSheet.professors + children: widget.courseUnitSheet.professors .map( (instructor) => _InstructorCard(instructor: instructor), @@ -66,7 +77,7 @@ class CourseUnitSheetView extends ConsumerWidget { vertical: 2, ), child: _LimitedInstructorsRow( - instructors: courseUnitSheet.professors, + instructors: widget.courseUnitSheet.professors, ), ), secondChild: Padding( @@ -77,7 +88,7 @@ class CourseUnitSheetView extends ConsumerWidget { child: Wrap( spacing: _horizontalSpacing, runSpacing: _verticalSpacing, - children: courseUnitSheet.professors + children: widget.courseUnitSheet.professors .map( (instructor) => _InstructorCard(instructor: instructor), @@ -91,7 +102,7 @@ class CourseUnitSheetView extends ConsumerWidget { _buildSection( title: S.of(context).assessments, titlePadding: 20, - content: exams.isEmpty + content: widget.exams.isEmpty ? Padding( padding: const EdgeInsets.only(top: 8, left: 20), child: Text( @@ -103,23 +114,23 @@ class CourseUnitSheetView extends ConsumerWidget { height: 100, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: exams.length + 2, + itemCount: widget.exams.length + 2, separatorBuilder: (context, index) => const SizedBox(width: _horizontalSpacing), itemBuilder: (context, index) { - if (index == 0 || index == exams.length + 1) { + if (index == 0 || index == widget.exams.length + 1) { return const SizedBox(width: 10); } return SizedBox( width: 240, child: ExamCard( - name: exams[index - 1].subject, - acronym: exams[index - 1].subjectAcronym, - rooms: exams[index - 1].rooms, - type: exams[index - 1].examType, - startTime: exams[index - 1].startTime, - examDay: exams[index - 1].start.day.toString(), - examMonth: exams[index - 1].monthAcronym( + name: widget.exams[index - 1].subject, + acronym: widget.exams[index - 1].subjectAcronym, + rooms: widget.exams[index - 1].rooms, + type: widget.exams[index - 1].examType, + startTime: widget.exams[index - 1].startTime, + examDay: widget.exams[index - 1].start.day.toString(), + examMonth: widget.exams[index - 1].monthAcronym( PreferencesController.getLocale(), ), showIcon: false, @@ -135,8 +146,8 @@ class CourseUnitSheetView extends ConsumerWidget { child: _buildSection( title: S.of(context).program, content: HtmlWidget( - courseUnitSheet.content != 'null' - ? courseUnitSheet.content + widget.courseUnitSheet.content != 'null' + ? widget.courseUnitSheet.content : S.of(context).no_info, textStyle: Theme.of(context).textTheme.bodyLarge, ), @@ -148,8 +159,8 @@ class CourseUnitSheetView extends ConsumerWidget { child: _buildSection( title: S.of(context).evaluation, content: HtmlWidget( - courseUnitSheet.evaluation != 'null' - ? courseUnitSheet.evaluation + widget.courseUnitSheet.evaluation != 'null' + ? widget.courseUnitSheet.evaluation : S.of(context).no_info, textStyle: Theme.of(context).textTheme.bodyLarge, ), @@ -161,15 +172,15 @@ class CourseUnitSheetView extends ConsumerWidget { child: _buildSection( title: S.of(context).frequency, content: HtmlWidget( - courseUnitSheet.frequency != 'null' - ? courseUnitSheet.frequency + widget.courseUnitSheet.frequency != 'null' + ? widget.courseUnitSheet.frequency : S.of(context).no_info, textStyle: Theme.of(context).textTheme.bodyLarge, ), context: context, ), ), - if (courseUnitSheet.books.isNotEmpty) + if (widget.courseUnitSheet.books.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _buildSection( @@ -178,7 +189,7 @@ class CourseUnitSheetView extends ConsumerWidget { width: double.infinity, child: Wrap( alignment: WrapAlignment.spaceBetween, - children: courseUnitSheet.books.map((book) { + children: widget.courseUnitSheet.books.map((book) { return book.isbn.isNotEmpty ? FutureBuilder( future: BookThumbFetcher().fetchBookThumb( diff --git a/packages/uni_app/lib/view/home/home.dart b/packages/uni_app/lib/view/home/home.dart index c80a84464..7f7a897c0 100644 --- a/packages/uni_app/lib/view/home/home.dart +++ b/packages/uni_app/lib/view/home/home.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -131,7 +133,10 @@ class HomePageViewState extends ConsumerState { appBar: homeAppBar(context), bottomNavigationBar: const AppBottomNavbar(), body: RefreshIndicator( - onRefresh: () => refreshPage(context), + onRefresh: () async { + unawaited(refreshPage(context)); + return; + }, child: ListView.separated( itemCount: favoriteCards.length + 1, separatorBuilder: (_, _) => const SizedBox(height: 10), diff --git a/packages/uni_app/lib/view/widgets/pages_layouts/general/general.dart b/packages/uni_app/lib/view/widgets/pages_layouts/general/general.dart index 33b2f1b7d..7b58942a4 100644 --- a/packages/uni_app/lib/view/widgets/pages_layouts/general/general.dart +++ b/packages/uni_app/lib/view/widgets/pages_layouts/general/general.dart @@ -14,7 +14,6 @@ import 'package:uni/view/widgets/pages_layouts/general/widgets/top_navigation_ba abstract class GeneralPageViewState extends ConsumerState { var _loadedOnce = false; - var _loading = true; var _connected = true; // Function called when the user pulls down the screen to refresh @@ -51,39 +50,36 @@ abstract class GeneralPageViewState bool getResizeToAvoidBottomInset() => true; @override - Widget build(BuildContext context) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (_loadedOnce || !mounted) { + void initState() { + super.initState(); + _initLoad(); + } + + Future _initLoad() async { + if (_loadedOnce || !mounted) { + return; + } + _loadedOnce = true; + + try { + await onLoad(context); + } catch (err, st) { + if (!mounted) { return; } - _loadedOnce = true; - setState(() { - _loading = true; - }); - - try { - await onLoad(context); - } catch (err, st) { - if (!mounted) { - return; - } - if (err is SocketException) { - setState(() { - _connected = false; - }); - } else { - Logger().e('Failed to load page info: $err\n$st'); - await Sentry.captureException(err, stackTrace: st); - } - } - - if (mounted) { + if (err is SocketException) { setState(() { - _loading = false; + _connected = false; }); + } else { + Logger().e('Failed to load page info: $err\n$st'); + await Sentry.captureException(err, stackTrace: st); } - }); + } + } + @override + Widget build(BuildContext context) { // TODO:(thePeras): Is this stills a thing? if (!_connected) { return getScaffold( @@ -106,12 +102,7 @@ abstract class GeneralPageViewState ); } - return getScaffold( - context, - _loading - ? const Center(child: CircularProgressIndicator()) - : getBody(context), - ); + return getScaffold(context, getBody(context)); } Widget getScaffold(BuildContext context, Widget body) { diff --git a/packages/uni_app/lib/view/widgets/pages_layouts/general/widgets/refresh_state.dart b/packages/uni_app/lib/view/widgets/pages_layouts/general/widgets/refresh_state.dart index 775142590..e34a47e7c 100644 --- a/packages/uni_app/lib/view/widgets/pages_layouts/general/widgets/refresh_state.dart +++ b/packages/uni_app/lib/view/widgets/pages_layouts/general/widgets/refresh_state.dart @@ -1,9 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/model/providers/riverpod/profile_provider.dart'; import 'package:uni/model/providers/riverpod/session_provider.dart'; -class RefreshState extends ConsumerWidget { +class RefreshState extends ConsumerStatefulWidget { const RefreshState({ required this.onRefresh, required this.header, @@ -16,42 +18,44 @@ class RefreshState extends ConsumerWidget { final Widget body; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _RefreshStateState(); +} + +class _RefreshStateState extends ConsumerState { + final GlobalKey _refreshIndicatorKey = + GlobalKey(); + + @override + Widget build(BuildContext context) { return Column( children: [ - ?header, + if (widget.header != null) widget.header!, Expanded( child: LayoutBuilder( builder: (context, viewportConstraints) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: RefreshIndicator( - key: GlobalKey(), + key: _refreshIndicatorKey, notificationPredicate: (notification) => notification.metrics.axisDirection == AxisDirection.down, onRefresh: () async { - await onRefresh(); + unawaited(widget.onRefresh()); if (context.mounted) { - await ProfileNotifier.fetchOrGetCachedProfilePicture( - ref.read(sessionProvider).value!, + unawaited( + ProfileNotifier.fetchOrGetCachedProfilePicture( + ref.read(sessionProvider).value!, + ), ); } + return; }, child: ConstrainedBox( constraints: BoxConstraints( minHeight: viewportConstraints.maxHeight, maxHeight: viewportConstraints.maxHeight, ), - child: Builder( - builder: (context) => GestureDetector( - onHorizontalDragEnd: (dragDetails) { - if (dragDetails.primaryVelocity! > 2) { - Scaffold.of(context).openDrawer(); - } - }, - child: body, - ), - ), + child: widget.body, ), ), ); diff --git a/packages/uni_ui/lib/cards/timeline_card.dart b/packages/uni_ui/lib/cards/timeline_card.dart index d411259c9..2c92c638f 100644 --- a/packages/uni_ui/lib/cards/timeline_card.dart +++ b/packages/uni_ui/lib/cards/timeline_card.dart @@ -23,7 +23,7 @@ class TimelineItem extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( + SizedBox( width: titleWidth, child: Column( children: [ @@ -35,7 +35,7 @@ class TimelineItem extends StatelessWidget { Column( children: [ Container( - margin: EdgeInsets.only(bottom: 5, left: 10, right: 10), + margin: const EdgeInsets.only(bottom: 5, left: 10, right: 10), width: 20, height: 20, decoration: BoxDecoration( @@ -60,11 +60,11 @@ class TimelineItem extends StatelessWidget { : null, ), Container( - margin: EdgeInsets.only(bottom: 5, left: 10, right: 10), + margin: const EdgeInsets.only(bottom: 5, left: 10, right: 10), height: lineHeight, width: 3, decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), + borderRadius: const BorderRadius.all(Radius.circular(10)), color: Theme.of(context).primaryColor, ), ), @@ -82,11 +82,6 @@ class CardTimeline extends StatelessWidget { final List items; @override Widget build(BuildContext context) { - return ListView.builder( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: items.length, - itemBuilder: (context, index) => items[index], - ); + return Column(mainAxisSize: MainAxisSize.min, children: items); } } diff --git a/packages/uni_ui/lib/timeline/timeline.dart b/packages/uni_ui/lib/timeline/timeline.dart index 78d1ca58b..1bf7d520b 100644 --- a/packages/uni_ui/lib/timeline/timeline.dart +++ b/packages/uni_ui/lib/timeline/timeline.dart @@ -2,17 +2,22 @@ import 'package:flutter/material.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:uni_ui/common/generic_squircle.dart'; +typedef TimelineContentBuilder = + Widget Function(BuildContext context, int index); + class Timeline extends StatefulWidget { const Timeline({ required this.tabs, required this.content, required this.initialTab, required this.tabEnabled, + this.contentBuilder, super.key, }); final List tabs; - final List content; + final List? content; + final TimelineContentBuilder? contentBuilder; final int initialTab; final List tabEnabled; @@ -21,256 +26,254 @@ class Timeline extends StatefulWidget { } class _TimelineState extends State { - late int _currentIndex; + late final ValueNotifier _currentIndexNotifier; final ItemScrollController _itemScrollController = ItemScrollController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); final ScrollController _tabScrollController = ScrollController(); final List _tabKeys = []; - final GlobalKey _tabsRowKey = GlobalKey(); final GlobalKey _tabsViewportKey = GlobalKey(); bool _didInitialScroll = false; + bool _isManualScrolling = false; @override void initState() { super.initState(); - _currentIndex = widget.initialTab; + _currentIndexNotifier = ValueNotifier(widget.initialTab); _tabKeys.addAll(List.generate(widget.tabs.length, (index) => GlobalKey())); - _itemPositionsListener.itemPositions.addListener(() { - if (!_didInitialScroll) return; - - final positions = _itemPositionsListener.itemPositions.value; - final visiblePositions = positions.where( - (ItemPosition position) => position.itemLeadingEdge >= 0, - ); - if (visiblePositions.isNotEmpty) { - final firstVisibleIndex = visiblePositions - .reduce( - (ItemPosition current, ItemPosition next) => - current.itemLeadingEdge < next.itemLeadingEdge - ? current - : next, - ) - .index; - - if (_currentIndex != firstVisibleIndex) { - setState(() { - _currentIndex = firstVisibleIndex; - }); - - _scrollToCenterTab(firstVisibleIndex); - } - } - }); + _itemPositionsListener.itemPositions.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; try { await _itemScrollController.scrollTo( - index: _currentIndex, + index: _currentIndexNotifier.value, duration: const Duration(milliseconds: 1), curve: Curves.easeInOut, ); } catch (_) {} - if (mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scrollToCenterTab(_currentIndex); - }); - } - + Future.delayed(const Duration(milliseconds: 150), () { + if (mounted) _scrollToCenterTab(_currentIndexNotifier.value); + }); _didInitialScroll = true; }); } + void _onScroll() { + if (!_didInitialScroll || _isManualScrolling) return; + + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + + final visiblePositions = positions.where( + (ItemPosition position) => position.itemLeadingEdge >= -0.1, + ); + + if (visiblePositions.isNotEmpty) { + final firstVisibleIndex = visiblePositions + .reduce( + (ItemPosition current, ItemPosition next) => + current.itemLeadingEdge < next.itemLeadingEdge ? current : next, + ) + .index; + + if (_currentIndexNotifier.value != firstVisibleIndex) { + _currentIndexNotifier.value = firstVisibleIndex; + _scrollToCenterTab(firstVisibleIndex); + } + } + } + @override void dispose() { + _itemPositionsListener.itemPositions.removeListener(_onScroll); + _currentIndexNotifier.dispose(); _tabScrollController.dispose(); super.dispose(); } - void _onTabTapped(int index) { - if (!widget.tabEnabled[index]) return; - _itemScrollController.scrollTo( + Future _onTabTapped(int index) async { + if (!widget.tabEnabled[index] || _currentIndexNotifier.value == index) { + return; + } + + _isManualScrolling = true; + _currentIndexNotifier.value = index; + _scrollToCenterTab(index); + + await _itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); - _scrollToCenterTab(index); + + _isManualScrolling = false; } void _scrollToCenterTab(int index) { - if (index < 0 || index >= _tabKeys.length) return; - final ctx = _tabKeys[index].currentContext; - if (ctx == null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scrollToCenterTab(index); - }); - return; - } + if (!mounted || index < 0 || index >= _tabKeys.length) return; - final RenderBox? viewportBox = - _tabsViewportKey.currentContext?.findRenderObject() as RenderBox?; - if (viewportBox == null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scrollToCenterTab(index); - }); - return; - } - final double viewportWidth = viewportBox.size.width; - final RenderBox tabBox = ctx.findRenderObject() as RenderBox; + final tabCtx = _tabKeys[index].currentContext; + final viewportCtx = _tabsViewportKey.currentContext; - final rowCtx = _tabsRowKey.currentContext; - if (rowCtx == null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _scrollToCenterTab(index); - }); - return; - } + if (tabCtx == null || viewportCtx == null) return; - final RenderBox rowBox = rowCtx.findRenderObject() as RenderBox; + final RenderBox tabBox = tabCtx.findRenderObject() as RenderBox; + final RenderBox viewportBox = viewportCtx.findRenderObject() as RenderBox; - final tabGlobal = tabBox.localToGlobal(Offset.zero); - final tabLocalInRow = rowBox.globalToLocal(tabGlobal); + final tabGlobalPos = tabBox.localToGlobal(Offset.zero); + final viewportGlobalPos = viewportBox.localToGlobal(Offset.zero); + final tabRelativePos = tabGlobalPos.dx - viewportGlobalPos.dx; final tabWidth = tabBox.size.width; + final viewportWidth = viewportBox.size.width; - final desiredScrollWithinRow = - tabLocalInRow.dx + (tabWidth / 2) - (viewportWidth / 2); - - final offset = desiredScrollWithinRow.clamp( - 0.0, - _tabScrollController.position.maxScrollExtent, - ); + if (_tabScrollController.hasClients) { + final currentOffset = _tabScrollController.offset; + final targetOffset = + (currentOffset + + (tabRelativePos + tabWidth / 2) - + (viewportWidth / 2)) + .clamp(0.0, _tabScrollController.position.maxScrollExtent); - _tabScrollController.animateTo( - offset, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + _tabScrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } } @override Widget build(BuildContext context) { return Column( children: [ - SizedBox( - height: 70, - child: Stack( - children: [ - SingleChildScrollView( - key: _tabsViewportKey, - scrollDirection: Axis.horizontal, - controller: _tabScrollController, - child: Row( - key: _tabsRowKey, - children: widget.tabs.asMap().entries.map((entry) { - int index = entry.key; - Widget tab = entry.value; - bool isSelected = _currentIndex == index; - TextStyle textStyle = Theme.of( - context, - ).textTheme.bodySmall!; - return GestureDetector( - onTap: () => _onTabTapped(index), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 5.0, - ), - child: GenericSquircle( - borderRadius: 10, - child: Container( - key: _tabKeys[index], - padding: const EdgeInsets.symmetric( - vertical: 9.0, - horizontal: 8.0, - ), - color: isSelected - ? Theme.of( - context, - ).colorScheme.tertiary.withValues(alpha: 0.25) - : Colors.transparent, - child: DefaultTextStyle( - style: textStyle.copyWith( - color: widget.tabEnabled[index] - ? (isSelected - ? Theme.of( - context, - ).colorScheme.primary - : Colors.black) - : Colors.grey, + RepaintBoundary( + child: SizedBox( + height: 70, + child: Stack( + children: [ + ValueListenableBuilder( + valueListenable: _currentIndexNotifier, + builder: (context, currentIndex, _) { + return SingleChildScrollView( + key: _tabsViewportKey, + scrollDirection: Axis.horizontal, + controller: _tabScrollController, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: List.generate(widget.tabs.length, (index) { + final isSelected = currentIndex == index; + final textStyle = Theme.of( + context, + ).textTheme.bodySmall!; + + return GestureDetector( + onTap: () => _onTabTapped(index), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 5.0, + ), + child: GenericSquircle( + borderRadius: 10, + child: Container( + key: _tabKeys[index], + padding: const EdgeInsets.symmetric( + vertical: 9.0, + horizontal: 8.0, + ), + color: isSelected + ? Theme.of(context).colorScheme.tertiary + .withValues(alpha: 0.25) + : Colors.transparent, + child: DefaultTextStyle( + style: textStyle.copyWith( + color: widget.tabEnabled[index] + ? (isSelected + ? Theme.of( + context, + ).colorScheme.primary + : Colors.black) + : Colors.grey, + ), + child: widget.tabs[index], + ), + ), ), - child: tab, ), - ), - ), + ); + }), ), ); - }).toList(), - ), - ), - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 32, - child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Theme.of(context).scaffoldBackgroundColor, - Theme.of( - context, - ).scaffoldBackgroundColor.withAlpha(0), - ], - ), - ), - ), - ), - ), - Positioned( - right: 0, - top: 0, - bottom: 0, - width: 32, - child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerRight, - end: Alignment.centerLeft, - colors: [ - Theme.of(context).scaffoldBackgroundColor, - Theme.of( - context, - ).scaffoldBackgroundColor.withAlpha(0), - ], - ), - ), - ), + }, ), - ), - ], + _buildGradients(context), + ], + ), ), ), Expanded( - child: ScrollablePositionedList.builder( - padding: const EdgeInsets.only(bottom: 88), - itemCount: widget.content.length, - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - initialScrollIndex: _currentIndex, - itemBuilder: (context, index) { - return widget.content[index]; - }, + child: RepaintBoundary( + child: ScrollablePositionedList.builder( + padding: const EdgeInsets.only(bottom: 88), + itemCount: widget.tabs.length, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + initialScrollIndex: widget.initialTab, + itemBuilder: (context, index) { + if (widget.contentBuilder != null) { + return widget.contentBuilder!(context, index); + } + return widget.content![index]; + }, + ), ), ), ], ); } + + Widget _buildGradients(BuildContext context) { + final bgColor = Theme.of(context).scaffoldBackgroundColor; + return IgnorePointer( + child: Stack( + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 32, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [bgColor, bgColor.withAlpha(0)], + ), + ), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + width: 32, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [bgColor, bgColor.withAlpha(0)], + ), + ), + ), + ), + ], + ), + ); + } }