From 5b5f62b39c10a6ba19db26dc9b43921742b031a6 Mon Sep 17 00:00:00 2001 From: TechnoMarc3 Date: Sat, 4 Oct 2025 14:52:55 +0200 Subject: [PATCH 1/6] Fixed #isPhone in MainActivity.kt, since some tablets were identified as phones. Also added better landscape views for home_screen.dart and campus_screen.dart to match an organized view for tablets. --- .../de/tum/in/tumcampus/MainActivity.kt | 4 +-- android/app/src/main/res/values/dimens.xml | 2 +- lib/campusComponent/screen/campus_screen.dart | 29 ++++++++++++++----- lib/homeComponent/screen/home_screen.dart | 18 +++++++----- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/de/tum/in/tumcampus/MainActivity.kt b/android/app/src/main/kotlin/de/tum/in/tumcampus/MainActivity.kt index da99a231..e0993657 100644 --- a/android/app/src/main/kotlin/de/tum/in/tumcampus/MainActivity.kt +++ b/android/app/src/main/kotlin/de/tum/in/tumcampus/MainActivity.kt @@ -21,6 +21,6 @@ class MainActivity : FlutterActivity() { fun isPhone(context: Context): Boolean { val resources = context.resources val configuration = resources.configuration - val screenWidthDp = configuration.screenWidthDp - return screenWidthDp <= resources.getDimension(R.dimen.min_tablet_width_dp) + val screenWidthDp = configuration.smallestScreenWidthDp + return screenWidthDp <= resources.getInteger(R.integer.min_tablet_width_dp) } \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml index 250f171b..c39c1960 100644 --- a/android/app/src/main/res/values/dimens.xml +++ b/android/app/src/main/res/values/dimens.xml @@ -4,5 +4,5 @@ 8dp 4dp 2dp - 600dp + 600 \ No newline at end of file diff --git a/lib/campusComponent/screen/campus_screen.dart b/lib/campusComponent/screen/campus_screen.dart index d7f70766..25045b0d 100644 --- a/lib/campusComponent/screen/campus_screen.dart +++ b/lib/campusComponent/screen/campus_screen.dart @@ -13,14 +13,27 @@ class CampusScreen extends StatelessWidget { if (orientation == Orientation.portrait) { return body(); } else { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - Expanded(flex: 2, child: body()), - const Spacer(), - ], - ); + return SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Row(children: [ + Expanded(child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column(children: [ + Text('News', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 10), + NewsWidgetView(), + ],),)), + + const VerticalDivider(width: 0), + Expanded(child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column(children: [ + StudentClubWidgetView(), + const SizedBox(height: 10), + MovieWidgetView(), + ],),)), ] + )); } }, ); diff --git a/lib/homeComponent/screen/home_screen.dart b/lib/homeComponent/screen/home_screen.dart index ba67ffdc..d1d7e29e 100644 --- a/lib/homeComponent/screen/home_screen.dart +++ b/lib/homeComponent/screen/home_screen.dart @@ -21,13 +21,17 @@ class _HomeScreenState extends ConsumerState { if (orientation == Orientation.portrait) { return _widgetScrollView(); } else { - return Row( - children: [ - const Spacer(), - Expanded(flex: 2, child: _widgetScrollView()), - const Spacer(), - ], - ); + return SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Row(children: [ + Expanded(child: ContactScreen()), + const VerticalDivider(width: 0), + Expanded(child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.vertical, + child: WidgetScreen(),)), ] + )); } }, ); From 30afe72806d596113c183ecb858faf1309b656ba Mon Sep 17 00:00:00 2001 From: TechnoMarc3 Date: Mon, 20 Oct 2025 23:21:43 +0200 Subject: [PATCH 2/6] feat: Add Moodle Integration This commit introduces a comprehensive Moodle integration, allowing users to view their courses and course content directly within the app. - **Moodle Tab & Course View:** Adds a new "Moodle" tab to the main navigation. Users can see a list of their recent Moodle courses and tap on them to view details. - **Course Content Display:** Course sections and their content (HTML, PDFs, external links) are now accessible. HTML content is converted to Markdown for native rendering, and links open in an in-app browser. - **Shibboleth Authentication:** Implements a headless WebView to automate Shibboleth login, creating a seamless authentication experience. User credentials are encrypted and stored locally. - **Password Management:** Introduces a new "Security & Passwords" section in settings for users to manage their TUM Online password, which is required for Moodle login. - **File & PDF Handling:** Enables downloading and opening of files (like PDFs) from within the Moodle course view. --- android/app/src/main/AndroidManifest.xml | 3 + assets/images/logos/Moodle.png | Bin 0 -> 18993 bytes assets/translations/de.json | 1 + assets/translations/en.json | 1 + lib/base/enums/shortcut_item.dart | 3 + lib/base/networking/protocols/api.dart | 1 + lib/base/routing/router.dart | 64 +++ lib/base/routing/routes.dart | 4 + .../view/contactCard/contact_card_view.dart | 2 + lib/moodleComponent/model/moodle_course.dart | 193 +++++++ lib/moodleComponent/model/moodle_section.dart | 249 +++++++++ lib/moodleComponent/model/moodle_user.dart | 130 +++++ .../networking/apis/MoodleApi.dart | 134 +++++ .../service/shibboleth_session_generator.dart | 97 ++++ .../view/moodle_course_viewmodel.dart | 499 ++++++++++++++++++ .../view/moodle_viewmodel.dart | 178 +++++++ lib/navigation_service.dart | 18 + .../viewModels/onboarding_viewmodel.dart | 37 ++ .../views/password_view.dart | 194 +++++++ .../views/general_settings_view.dart | 14 + .../views/settings_view.dart | 3 +- .../views/student_card_view.dart | 2 + pubspec.lock | 196 ++++++- pubspec.yaml | 13 +- 24 files changed, 2032 insertions(+), 4 deletions(-) create mode 100644 assets/images/logos/Moodle.png create mode 100644 lib/moodleComponent/model/moodle_course.dart create mode 100644 lib/moodleComponent/model/moodle_section.dart create mode 100644 lib/moodleComponent/model/moodle_user.dart create mode 100644 lib/moodleComponent/networking/apis/MoodleApi.dart create mode 100644 lib/moodleComponent/service/shibboleth_session_generator.dart create mode 100644 lib/moodleComponent/view/moodle_course_viewmodel.dart create mode 100644 lib/moodleComponent/view/moodle_viewmodel.dart create mode 100644 lib/onboardingComponent/views/password_view.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3f7645fd..4e797e9e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + diff --git a/assets/images/logos/Moodle.png b/assets/images/logos/Moodle.png new file mode 100644 index 0000000000000000000000000000000000000000..f99da172afe4c76bdaf4bca396df7418b2824544 GIT binary patch literal 18993 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 ZW^xCOR~=6m3lac)qF1R`o?JoZ{{xS*+KK=G literal 0 HcmV?d00001 diff --git a/assets/translations/de.json b/assets/translations/de.json index 8948f7ad..d4211719 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -230,6 +230,7 @@ "tumOnlineDegraded": "TUMonline Services sind derzeit beeinträchtigt!", "tumOnlineMaintenance": "TUMonline Services werden derzeit gewartet!", "campus": "Campus", + "moodle": "Moodle", "studies": "Studium", "suggested": "Interessante {}", "more": "Mehr", diff --git a/assets/translations/en.json b/assets/translations/en.json index 09570c12..55ee3b54 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -230,6 +230,7 @@ "tumOnlineDegraded": "TUMonline Services are currently degraded!", "tumOnlineMaintenance": "TUMonline Services are currently under maintenance!", "campus": "Campus", + "moodle" : "Moodle", "studies": "Studies", "suggested": "Suggested {}", "more": "More", diff --git a/lib/base/enums/shortcut_item.dart b/lib/base/enums/shortcut_item.dart index 010d808f..b9cd31a0 100644 --- a/lib/base/enums/shortcut_item.dart +++ b/lib/base/enums/shortcut_item.dart @@ -8,6 +8,7 @@ enum ShortcutItemType { studyRooms(en: "Study Rooms", de: "Lernräume"), calendar(en: "Calendar", de: "Kalendar"), studies(en: "Studies", de: "Studium"), + moodle(en: "Moodle", de: "Moodle"), roomSearch(en: "Room Search", de: "Raumsuche"); final String en; @@ -47,6 +48,8 @@ extension Routing on ShortcutItemType { return routes.calendar; case ShortcutItemType.studies: return routes.studies; + case ShortcutItemType.moodle: + return routes.moodle; case ShortcutItemType.roomSearch: return routes.roomSearch; } diff --git a/lib/base/networking/protocols/api.dart b/lib/base/networking/protocols/api.dart index cc86bf80..65937745 100644 --- a/lib/base/networking/protocols/api.dart +++ b/lib/base/networking/protocols/api.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart' as dio; abstract class Api { static String tumToken = ""; + static String tumId = ""; String get domain; diff --git a/lib/base/routing/router.dart b/lib/base/routing/router.dart index fa07c7a2..5dc6ae36 100644 --- a/lib/base/routing/router.dart +++ b/lib/base/routing/router.dart @@ -14,6 +14,8 @@ import 'package:campus_flutter/homeComponent/view/departure/departures_details_v import 'package:campus_flutter/feedbackComponent/views/feedback_form_view.dart'; import 'package:campus_flutter/feedbackComponent/views/feedback_success_view.dart'; import 'package:campus_flutter/homeComponent/screen/home_screen.dart'; +import 'package:campus_flutter/moodleComponent/view/moodle_course_viewmodel.dart'; +import 'package:campus_flutter/moodleComponent/view/moodle_viewmodel.dart'; import 'package:campus_flutter/navigaTumComponent/model/navigatum_roomfinder_map.dart'; import 'package:campus_flutter/navigaTumComponent/views/navigatum_room_view.dart'; import 'package:campus_flutter/onboardingComponent/views/confirm_view.dart'; @@ -21,6 +23,7 @@ import 'package:campus_flutter/onboardingComponent/views/location_permissions_vi import 'package:campus_flutter/onboardingComponent/views/login_view.dart'; import 'package:campus_flutter/main.dart'; import 'package:campus_flutter/navigation.dart'; +import 'package:campus_flutter/onboardingComponent/views/password_view.dart'; import 'package:campus_flutter/onboardingComponent/views/permission_check_view.dart'; import 'package:campus_flutter/personComponent/views/person_details_view.dart'; import 'package:campus_flutter/placesComponent/model/cafeterias/cafeteria.dart'; @@ -38,6 +41,7 @@ import 'package:campus_flutter/settingsComponent/views/settings_scaffold.dart'; import 'package:campus_flutter/studiesComponent/model/lecture.dart'; import 'package:campus_flutter/studiesComponent/screen/studies_screen.dart'; import 'package:campus_flutter/studiesComponent/view/lectureDetail/lecture_details_view.dart'; +import 'package:flutter/cupertino.dart' show Text; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -54,6 +58,11 @@ final _router = GoRouter( builder: (context, state) => PermissionCheckView(isSettingsView: (state.extra as bool?) ?? false), ), + GoRoute( + path: safetyArea, + builder: (context, state) => + PasswordView(), + ), GoRoute( path: locationPermission, builder: (context, state) => const LocationPermissionView(), @@ -97,6 +106,61 @@ final _router = GoRouter( ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: moodle, + pageBuilder: (context, state) + + => const NoTransitionPage(child: MoodleViewModel()) + ), + GoRoute( + path: "$moodle/viewCourse", + pageBuilder: (context, state) { + final args = state.extra as MoodleCourseArguments?; + + if (args == null) { + return const NoTransitionPage(child: Text("Ein Fehler ist aufgetreten")); + } + + return NoTransitionPage( + child: MoodleCourseViewModel( + args.session, // 1. Argument + args.api, // 2. Argument + args.course // 3. Argument + ) + ); + } + ), + GoRoute( + path: '/webviewPage', + pageBuilder: (context, state) { + final Map? args = state.extra as Map?; + + if (args == null || args['url'] == null) { + return const NoTransitionPage(child: Text("Ein Fehler ist aufgetreten")); + } + + return NoTransitionPage( + child: WebViewPage( + url: args['url'] as String, + // Keine Cookie-Argumente mehr nötig + ), + ); + }, + ), + + GoRoute( + path: '/pdf-viewer', + builder: (context, state) { + + return PdfViewScreen( + stringPathFuture: state.extra as Future, + ); + }, + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( diff --git a/lib/base/routing/routes.dart b/lib/base/routing/routes.dart index 7bdae67d..93f36999 100644 --- a/lib/base/routing/routes.dart +++ b/lib/base/routing/routes.dart @@ -2,6 +2,7 @@ const onboarding = "/onboarding"; const confirm = "/confirm"; const locationPermission = "/locationPermission"; const permissionCheck = "/permissionCheck"; +const safetyArea = "/safetyArea"; /// Home tab const home = "/"; @@ -42,6 +43,9 @@ const search = "/search"; const roomSearch = "/roomSearch"; const personSearch = "/personSearch"; +/// Moodle +const moodle = "/moodle"; + /// General const navigaTum = "/navigaTum"; const personDetails = "/personDetails"; diff --git a/lib/homeComponent/view/contactCard/contact_card_view.dart b/lib/homeComponent/view/contactCard/contact_card_view.dart index cb2cf722..e02b4ad6 100644 --- a/lib/homeComponent/view/contactCard/contact_card_view.dart +++ b/lib/homeComponent/view/contactCard/contact_card_view.dart @@ -1,6 +1,7 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:campus_flutter/base/enums/device.dart'; import 'package:campus_flutter/base/extensions/base_64_decode_image_data.dart'; +import 'package:campus_flutter/base/networking/protocols/api.dart'; import 'package:campus_flutter/base/services/device_type_service.dart'; import 'package:campus_flutter/base/util/delayed_loading_indicator.dart'; import 'package:campus_flutter/homeComponent/view/contactCard/contact_card_loading_view.dart'; @@ -39,6 +40,7 @@ class _ContactCardViewState extends ConsumerState { stream: ref.watch(profileDetailsViewModel).personDetails, builder: (context, snapshot) { if (snapshot.hasData || snapshot.hasError) { + Api.tumId = widget.profile.tumID!; return InkWell( onTap: () => NavigationService.openStudentCardSheet(context), child: contactInfo(snapshot.data, widget.profile), diff --git a/lib/moodleComponent/model/moodle_course.dart b/lib/moodleComponent/model/moodle_course.dart new file mode 100644 index 00000000..76969eaf --- /dev/null +++ b/lib/moodleComponent/model/moodle_course.dart @@ -0,0 +1,193 @@ + +import 'dart:convert'; + +import 'package:campus_flutter/base/routing/routes.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_section.dart'; +import 'package:campus_flutter/moodleComponent/networking/apis/MoodleApi.dart'; +import 'package:campus_flutter/moodleComponent/service/shibboleth_session_generator.dart'; +import 'package:campus_flutter/moodleComponent/view/moodle_course_viewmodel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:xml/xml.dart' as xml; + +class MoodleCourse{ + final int id; + final String fullname; + final String shortname; + final String idnumber; + final String summary; + final int summaryFormat; + final int startDate; + final int endDate; + final bool visible; + final bool showActivityDates; + final bool showCompletionConditions; + final String pdfExportFont; + final String fullnameDisplay; + final String viewUrl; + final String courseImage; + final int progress; + final bool hasProgress; + final bool isFavourite; + final bool hidden; + final int timeAccess; + final bool showShortname; + final String courseCategory; + MoodleCourseState? state; + + MoodleCourse({ + required this.id, + required this.fullname, + required this.shortname, + required this.idnumber, + required this.summary, + required this.summaryFormat, + required this.startDate, + required this.endDate, + required this.visible, + required this.showActivityDates, + required this.showCompletionConditions, + required this.pdfExportFont, + required this.fullnameDisplay, + required this.viewUrl, + required this.courseImage, + required this.progress, + required this.hasProgress, + required this.isFavourite, + required this.hidden, + required this.timeAccess, + required this.showShortname, + required this.courseCategory, + }); + + Future fetchState(MoodleApi api) async { + state = await api.getCourseStateForCourse(this); + } + + + + Widget createImage() { + if (courseImage.isEmpty) { + return const Icon(Icons.book, size: 40); + } else { + if(courseImage.contains("base64")) { + //base64 image + final base64String = courseImage.split(',').last; + var converted = utf8.decode(base64.decode(base64String)); + converted = converted.replaceAll("100%", "280px"); + return SvgPicture.string( + converted, + width: 250, + height: 250, + fit: BoxFit.cover, + placeholderBuilder: (context) => const Icon(Icons.book, size: 40), + ); + + } else { + return Image.network( + courseImage, + width: 250, + height: 250, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.book, size: 40); + }, + ); + } + } + } + + //to and from json + factory MoodleCourse.fromJson(Map json) { + return MoodleCourse( + id: json['id'], + fullname: json['fullname'], + shortname: json['shortname'], + idnumber: json['idnumber'], + summary: json['summary'], + summaryFormat: json['summaryformat'], + startDate: json['startdate'], + endDate: json['enddate'], + visible: json['visible'], + showActivityDates: json['showactivitydates'], + showCompletionConditions: json['showcompletionconditions']??false, + pdfExportFont: json['pdfexportfont'], + fullnameDisplay: json['fullnamedisplay'], + viewUrl: json['viewurl'], + courseImage: json['courseimage'], + progress: json['progress'], + hasProgress: json['hasprogress'], + isFavourite: json['isfavourite'], + hidden: json['hidden'], + timeAccess: json['timeaccess'], + showShortname: json['showshortname'], + courseCategory: json['coursecategory'], + ); + } + + //to string + @override + String toString() { + return 'MoodleCourse{id: $id, fullname: $fullname}'; + } + + Widget build(BuildContext context, {bool withArrowForward = true, bool withArrowBackward = false, ShibbolethSession? session, MoodleApi? api}) { + + //create a smooth design + return SizedBox( + height: MediaQuery.of(context).size.height*0.8, + width: MediaQuery.of(context).size.width * 0.25, + child: + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + elevation: 5, + child: InkWell( + borderRadius: BorderRadius.circular(15.0), + onTap: () { + if (withArrowForward) { + final arguments = MoodleCourseArguments( + session: session!, + api: api!, + course: this, + ); + + context.push("$moodle/viewCourse", extra: arguments); + }else if(withArrowBackward) { + context.pop(); + } + }, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + fullname, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + createImage(), + const SizedBox(height: 10), + Text( + courseCategory, + style: Theme.of(context).textTheme.titleSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + withArrowForward ? Icon(Icons.arrow_forward, color: Theme.of(context).colorScheme.primary): withArrowBackward ? Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary): const SizedBox.shrink(), + ], + ), + ), + ), + )); + } +} diff --git a/lib/moodleComponent/model/moodle_section.dart b/lib/moodleComponent/model/moodle_section.dart new file mode 100644 index 00000000..1ac70efa --- /dev/null +++ b/lib/moodleComponent/model/moodle_section.dart @@ -0,0 +1,249 @@ +// --- Supporting Class: MoodleCourseDetails --- +import 'package:flutter/cupertino.dart'; + +class MoodleCourseDetails { + final String id; + final int numSections; + final List sectionList; + final bool editMode; + final String highlighted; + final String maxSections; + final String baseurl; + final String stateKey; + final String maxBytes; + final String maxBytesText; + + MoodleCourseDetails({ + required this.id, + required this.numSections, + required this.sectionList, + required this.editMode, + required this.highlighted, + required this.maxSections, + required this.baseurl, + required this.stateKey, + required this.maxBytes, + required this.maxBytesText, + }); + + factory MoodleCourseDetails.fromJson(Map json) { + return MoodleCourseDetails( + id: json['id'] as String, + numSections: int.parse(json['numsections'].toString()), // JSON field is string, but should be int + sectionList: List.from(json['sectionlist'] as List), + editMode: json['editmode'] as bool, + highlighted: json['highlighted'] as String, + maxSections: json['maxsections'] as String, + baseurl: json['baseurl'] as String, + stateKey: json['statekey'] as String, + maxBytes: json['maxbytes'] as String, + maxBytesText: json['maxbytestext'] as String, + ); + } +} + +// --- Supporting Class: MoodleSection --- +class MoodleSection { + + final List monthNamesDe = [ + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' + ]; + final List monthNamesEn = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + final String id; + final int section; + final int number; + final String title; + final bool hasSummary; + final String? rawTitle; + final List cmList; + final bool visible; + final String sectionUrl; + final bool current; + final bool indexCollapsed; + final bool contentCollapsed; + final bool hasRestrictions; + final bool bulkEditable; + final String? component; + final String? itemId; + final String? parentSectionId; + + MoodleSection({ + required this.id, + required this.section, + required this.number, + required this.title, + required this.hasSummary, + this.rawTitle, + required this.cmList, + required this.visible, + required this.sectionUrl, + required this.current, + required this.indexCollapsed, + required this.contentCollapsed, + required this.hasRestrictions, + required this.bulkEditable, + this.component, + this.itemId, + this.parentSectionId, + }); + + bool isCurrentlyRelevant() { + if(!title.contains("-")) { + return false; + } + var splitTitle = title.split("-"); + try { + String startString = splitTitle[0].trim(); + String endString = splitTitle[1].trim(); + for (int i = 0; i < monthNamesDe.length; i++) { + startString = startString.replaceAll(monthNamesDe[i], (i + 1).toString().padLeft(2, '0')); + endString = endString.replaceAll(monthNamesDe[i], (i + 1).toString().padLeft(2, '0')); + startString = startString.replaceAll(monthNamesEn[i], (i + 1).toString().padLeft(2, '0')); + endString = endString.replaceAll(monthNamesEn[i], (i + 1).toString().padLeft(2, '0')); + } + startString = "${startString.replaceAll(" ", "")}.${DateTime.now().year}".replaceAll(".", "-"); + endString = "${endString.replaceAll(" ", "")}.${DateTime.now().year}".replaceAll(".", "-"); + var startParts = startString.split("-"); + startString = "${startParts[2]}-${startParts[1]}-${startParts[0]}"; + var endParts = endString.split("-"); + endString = "${endParts[2]}-${endParts[1]}-${endParts[0]}"; + debugPrint("Parsed Date String: $startString, $endString for title: $title"); + DateTime parsedStartDate = DateTime.parse(startString); + DateTime parsedEndDate = DateTime.parse(endString); + return (parsedStartDate.isBefore(DateTime.now()) && parsedEndDate.isAfter(DateTime.now())); + } catch (e) { + return false; + } + } + + factory MoodleSection.fromJson(Map json) { + return MoodleSection( + id: json['id'] as String, + section: json['section'] as int, + number: json['number'] as int, + title: json['title'] as String, + hasSummary: json['hassummary'] as bool, + rawTitle: json['rawtitle'] as String?, + cmList: List.from(json['cmlist'] as List), + visible: json['visible'] as bool, + sectionUrl: json['sectionurl'] as String, + current: json['current'] as bool, + indexCollapsed: json['indexcollapsed'] as bool, + contentCollapsed: json['contentcollapsed'] as bool, + hasRestrictions: json['hasrestrictions'] as bool, + bulkEditable: json['bulkeditable'] as bool, + component: json['component'] as String?, + itemId: json['itemid'] as String?, + parentSectionId: json['parentsectionid'] as String?, + ); + } +} + +// --- Supporting Class: MoodleCm (Course Module) --- +class MoodleCm { + final String id; + final String anchor; + final String name; + final bool visible; + final bool stealth; + final String sectionId; + final int sectionNumber; + final bool userVisible; + final bool hasCmRestrictions; + final String modname; + final int indent; + final int groupMode; + final String module; + final String plugin; + final bool hasDelegatedSection; + final bool accessVisible; + final String? url; + final bool isTrackedUser; + final bool allowStealth; + + MoodleCm({ + required this.id, + required this.anchor, + required this.name, + required this.visible, + required this.stealth, + required this.sectionId, + required this.sectionNumber, + required this.userVisible, + required this.hasCmRestrictions, + required this.modname, + required this.indent, + required this.groupMode, + required this.module, + required this.plugin, + required this.hasDelegatedSection, + required this.accessVisible, + this.url, + required this.isTrackedUser, + required this.allowStealth, + }); + + factory MoodleCm.fromJson(Map json) { + return MoodleCm( + id: json['id'] as String, + anchor: json['anchor'] as String, + name: json['name'] as String, + visible: json['visible'] as bool, + stealth: json['stealth'] as bool, + sectionId: json['sectionid'] as String, + sectionNumber: json['sectionnumber'] as int, + userVisible: json['uservisible'] as bool, + hasCmRestrictions: json['hascmrestrictions'] as bool, + modname: json['modname'] as String, + indent: json['indent'] as int, + groupMode: int.parse(json['groupmode'].toString()), // groupmode can be string + module: json['module'] as String, + plugin: json['plugin'] as String, + hasDelegatedSection: json['hasdelegatedsection'] as bool, + accessVisible: json['accessvisible'] as bool, + url: json['url'] as String?, + isTrackedUser: json['istrackeduser'] as bool, + allowStealth: json['allowstealth'] as bool, + ); + } +} + +// =============================================== +// --- Hauptklasse: MoodleCourseState --- +// =============================================== +class MoodleCourseState { + final MoodleCourseDetails course; + final List section; + final List cm; + + MoodleCourseState({ + required this.course, + required this.section, + required this.cm, + }); + + factory MoodleCourseState.fromJson(Map json) { + final MoodleCourseDetails course = MoodleCourseDetails.fromJson(json['course'] as Map); + + final List sectionJson = json['section'] as List; + final List section = sectionJson + .map((s) => MoodleSection.fromJson(s as Map)) + .toList(); + + final List cmJson = json['cm'] as List; + final List cm = cmJson + .map((c) => MoodleCm.fromJson(c as Map)) + .toList(); + + return MoodleCourseState( + course: course, + section: section, + cm: cm, + ); + } +} \ No newline at end of file diff --git a/lib/moodleComponent/model/moodle_user.dart b/lib/moodleComponent/model/moodle_user.dart new file mode 100644 index 00000000..02426a14 --- /dev/null +++ b/lib/moodleComponent/model/moodle_user.dart @@ -0,0 +1,130 @@ +// Supporting class for the preferences list +import 'package:flutter/cupertino.dart'; + +class Preference { + final String name; + final String? value; // Value can sometimes be a number in string form or a complex JSON string + + Preference({ + required this.name, + this.value, + }); + + factory Preference.fromJson(Map json) { + return Preference( + name: json['name'] as String, + // Value can be String, int, or null, so we convert it to String? + value: json['value']?.toString(), + ); + } +} + +// Class representing the Moodle User +class MoodleUser { + final int id; + final String username; + final String fullname; + final String email; + final String department; + final String idnumber; + final String auth; + final bool suspended; + final bool confirmed; + final String lang; + final String theme; + final String timezone; + final int mailformat; + final int trackforums; + final String description; + final int descriptionformat; + final String profileimageurlsmall; + final String profileimageurl; + final List preferences; + + MoodleUser({ + required this.id, + required this.username, + required this.fullname, + required this.email, + required this.department, + required this.idnumber, + required this.auth, + required this.suspended, + required this.confirmed, + required this.lang, + required this.theme, + required this.timezone, + required this.mailformat, + required this.trackforums, + required this.description, + required this.descriptionformat, + required this.profileimageurlsmall, + required this.profileimageurl, + required this.preferences, + }); + + // Factory constructor to create a MoodleUser instance from a JSON map + factory MoodleUser.fromJson(Map data) { + debugPrint("Parsing MoodleUser from JSON: $data"); + var json = (data.entries.last.value as List).first; + // Cast the preferences list of maps into a list of Preference objects + final List preferencesJson = json['preferences'] as List; + final List preferences = preferencesJson + .map((p) => Preference.fromJson(p as Map)) + .toList(); + + return MoodleUser( + id: json['id'] as int, + username: json['username'] as String, + fullname: json['fullname'] as String, + email: json['email'] as String, + department: json['department'] as String, + idnumber: json['idnumber'] as String, + auth: json['auth'] as String, + suspended: json['suspended'] as bool, + confirmed: json['confirmed'] as bool, + lang: json['lang'] as String, + theme: json['theme'] as String, + timezone: json['timezone'] as String, + mailformat: json['mailformat'] as int, + trackforums: json['trackforums'] as int, + description: json['description'] as String, + descriptionformat: json['descriptionformat'] as int, + profileimageurlsmall: json['profileimageurlsmall'] as String, + profileimageurl: json['profileimageurl'] as String, + preferences: preferences, + ); + } + + //toString method for easier debugging + @override + String toString() { + //with all fields + return 'MoodleUser{id: $id, username: $username, fullname: $fullname, email: $email, department: $department, idnumber: $idnumber, auth: $auth, suspended: $suspended, confirmed: $confirmed, lang: $lang, theme: $theme, timezone: $timezone, mailformat: $mailformat, trackforums: $trackforums, description: $description, descriptionformat: $descriptionformat, profileimageurlsmall: $profileimageurlsmall, profileimageurl: $profileimageurl, preferences: $preferences}'; + } +} + +// Class representing the root of the JSON array structure +// Note: The provided JSON is a list containing a single map (the response). +class MoodleUserResponse { + final bool error; + final List data; + + MoodleUserResponse({ + required this.error, + required this.data, + }); + + factory MoodleUserResponse.fromJson(Map json) { + // Cast the data list of maps into a list of MoodleUser objects + final List dataJson = json['data'] as List; + final List data = dataJson + .map((u) => MoodleUser.fromJson(u as Map)) + .toList(); + + return MoodleUserResponse( + error: json['error'] as bool, + data: data, + ); + } +} diff --git a/lib/moodleComponent/networking/apis/MoodleApi.dart b/lib/moodleComponent/networking/apis/MoodleApi.dart new file mode 100644 index 00000000..085a18f4 --- /dev/null +++ b/lib/moodleComponent/networking/apis/MoodleApi.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:campus_flutter/base/networking/protocols/api.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_course.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_section.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_user.dart'; +import 'package:campus_flutter/moodleComponent/service/shibboleth_session_generator.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; + +class MoodleApi extends Api{ + + ShibbolethSession session; + + + MoodleApi(this.session); + + @override + // TODO: implement domain + String get domain => "moodle.tum.de/lib/ajax/service.php"; + + @override + bool get needsAuth => true; + + @override + Map get parameters => { + "sesskey": session.sessionId, + }; + + @override + String get path => ""; + + @override + String get slug => "XML_DM_REQUEST"; + + Future> getCourses(MoodleUser user) async { + //new dio instance is needed to avoid cookie conflicts + Dio dio = Dio(); + dio.options.headers.addAll({ + "Cookie": session.cookies.entries.map((e) => "${e.key}=${e.value}").toList().join("; "), + "Content-Type": "application/json", + "Accept": "application/json, text/javascript, *; q=0.01", + "X-Requested-With": "XMLHttpRequest", + }); + var data = await dio.post("https://www.moodle.tum.de/lib/ajax/service.php?sesskey=${session.sessionId.toString()}&info=core_course_get_recent_courses", data: + jsonEncode([ + { + "index": 0, + "methodname": "core_course_get_recent_courses", + "args": {"limit": 10, "userid": user.id} + } + ])); + var coursesList = (data.data as List).first.entries.last.value as List; + return coursesList.map((e) => MoodleCourse.fromJson(e)).toList(); + } + + //userName is the username of tum online access +Future getMoodleUser(String userName) async{ + Dio dio = Dio(); + dio.options.headers.addAll({ + "Cookie": session.cookies.entries.map((e) => "${e.key}=${e.value}").toList().join("; "), + "Content-Type": "application/json", + "Accept": "application/json, text/javascript, *; q=0.01", + "X-Requested-With": "XMLHttpRequest", + }); + var data = await dio.post("https://www.moodle.tum.de/lib/ajax/service.php?sesskey=${session.sessionId.toString()}&info=core_course_get_recent_courses", data: + jsonEncode([ + { + "index": 0, + "methodname": "core_user_get_users_by_field", + "args": {"field": "username", "values": [ + userName + ]} + } + ])); + var userMap = (data.data as List).first as Map; + return MoodleUser.fromJson(userMap); +} + +Future getCourseStateForCourse(MoodleCourse course) async { + Dio dio = Dio(); + dio.options.headers.addAll({ + "Cookie": session.cookies.entries + .map((e) => "${e.key}=${e.value}") + .toList() + .join("; "), + "Content-Type": "application/json", + "Accept": "application/json, text/javascript, *; q=0.01", + "X-Requested-With": "XMLHttpRequest", + }); + var data = await dio.post( + "https://www.moodle.tum.de/lib/ajax/service.php?sesskey=${session + .sessionId.toString()}&info=core_courseformat_get_state", data: + jsonEncode([ + { + "index": 0, + "methodname": "core_courseformat_get_state", + "args": {"courseid": course.id} + } + ])); + var courseStateMap = (data.data as List).first as Map; + var jsonData = courseStateMap["data"]; + + return MoodleCourseState.fromJson(jsonDecode(jsonData)); +} + +Future loadHtmlDataForMoodleModule(String cmId) async{ + Dio dio = Dio(); + dio.options.headers.addAll({ + "Cookie": session.cookies.entries + .map((e) => "${e.key}=${e.value}") + .toList() + .join("; "), + "Content-Type": "application/json", + "Accept": "application/json, text/javascript, *; q=0.01", + "X-Requested-With": "XMLHttpRequest", + }); + var data = await dio.post( + "https://www.moodle.tum.de/lib/ajax/service.php?sesskey=${session + .sessionId.toString()}&info=core_course_get_module", data: + jsonEncode([ + { + "index": 0, + "methodname": "core_course_get_module", + "args": {"id": cmId} + } + ])); + var courseStateMap = (data.data as List).first as Map; + var jsonData = courseStateMap["data"]; + + return jsonData; +} + +} \ No newline at end of file diff --git a/lib/moodleComponent/service/shibboleth_session_generator.dart b/lib/moodleComponent/service/shibboleth_session_generator.dart new file mode 100644 index 00000000..a243debd --- /dev/null +++ b/lib/moodleComponent/service/shibboleth_session_generator.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +class ShibbolethSessionGenerator { + + Future generateSession( + String userName, String password) { + ShibbolethSession session = ShibbolethSession(); + Completer helperCompleter = Completer(); + HeadlessInAppWebView webView = HeadlessInAppWebView( + initialUrlRequest: URLRequest( + url: WebUri( + 'https://www.moodle.tum.de/'), + ), + onTitleChanged: (controller, title) async { + if(title!.startsWith("Startseite")) { + + await controller.evaluateJavascript(source: """ + function cycle() { + document.querySelector("a.btn.btn-primary").click(); + } + setTimeout(cycle, 1500); + """); + }else if(title.startsWith("TUM")) { + await controller.evaluateJavascript(source: """ + + function cycle() { + + function fillInput(input, value) { + + input.focus(); + + input.value = value; + + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + + input.blur(); +} + fillInput(document.getElementById("username"), "$userName"); + fillInput(document.getElementById("password"), "$password"); + + setTimeout(() => {document.querySelector("button[type='submit']").click();}, 500); + + } + + cycle(); + """); + } else { + + final tumCookies = await CookieManager.instance() + .getCookies(url: WebUri("https://login.tum.de")); + final moodleCookies = await CookieManager.instance() + .getCookies(url: WebUri("https://www.moodle.tum.de")); + final examMoodleCookies = await CookieManager.instance() + .getCookies(url: WebUri("https://exam.moodle.tum.de")); + + final expiration = tumCookies.first.expiresDate != null ? DateTime.fromMillisecondsSinceEpoch(tumCookies.first.expiresDate!): DateTime.now().add( + const Duration(hours: 8), + ); + session.expiration = expiration; + session.cookies = { + for (var cookie in tumCookies) cookie.name: cookie.value, + for (var cookie in moodleCookies) cookie.name: cookie.value, + for (var cookie in examMoodleCookies) cookie.name: cookie.value, + }; + } + }, + onLoadError: (controller, url, code, message) { + throw Exception("Failed to load page: $message"); + }, + onLoadResource: (InAppWebViewController controller, LoadedResource resource) { + if(resource.url!.toString().startsWith("https://www.moodle.tum.de/lib/ajax/service.php")) { + var sesskey = resource.url!.queryParameters["sesskey"]; + session.sessionId = sesskey!; + debugPrint("Shibboleth session generated with sesskey: ${session.sessionId}"); + if(!helperCompleter.isCompleted) { + helperCompleter.complete(session); + } + } + }, + ); + webView.run(); + return helperCompleter.future; + } + +} + +class ShibbolethSession { + late String userId; + late String sessionId; + Map cookies = {}; + late DateTime expiration; + + ShibbolethSession(); +} \ No newline at end of file diff --git a/lib/moodleComponent/view/moodle_course_viewmodel.dart b/lib/moodleComponent/view/moodle_course_viewmodel.dart new file mode 100644 index 00000000..9cd96e43 --- /dev/null +++ b/lib/moodleComponent/view/moodle_course_viewmodel.dart @@ -0,0 +1,499 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:campus_flutter/moodleComponent/model/moodle_course.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_section.dart'; +import 'package:campus_flutter/moodleComponent/networking/apis/MoodleApi.dart'; +import 'package:campus_flutter/moodleComponent/service/shibboleth_session_generator.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:html2md/html2md.dart' as html2md; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MoodleCourseViewModel extends ConsumerStatefulWidget { + final MoodleCourse course; + final MoodleApi api; + final ShibbolethSession session; + const MoodleCourseViewModel(this.session, this.api, this.course, {super.key}); + + @override + ConsumerState createState() => + _MoodleCourseViewModelState(); +} + +class _MoodleCourseViewModelState extends ConsumerState { + late Future _future; + Widget? sectionSelection; + int currentIndex = -1; + + @override + void initState() { + super.initState(); + _future = connectToMoodle(); + } + + Future connectToMoodle() async { + return widget.course.fetchState(widget.api); + } + + @override + Widget build(BuildContext context) { + return widget.course.state == null + ? FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Row( + children: [ + widget.course.build(context, withArrowForward: false, withArrowBackward: true), + const Center(child: CupertinoActivityIndicator()), + ], + ); + } else if (snapshot.hasError) { + debugPrintStack(stackTrace: snapshot.stackTrace); + return Center(child: Text("Error: ${snapshot.error}")); + } else { + return Row( + children: [ + widget.course.build(context, withArrowForward: false, withArrowBackward: true), + const SizedBox(width: 10), + buildCourseContent(), + sectionSelection ?? Container(), + ], + ); + } + }, + ) + : Row( + children: [ + widget.course.build(context, withArrowForward: false, withArrowBackward: true), + buildCourseContent(), + sectionSelection ?? Container(), + ], + ); + } + + Widget buildCourseContent() { + return SizedBox( + width: MediaQuery.of(context).size.width * 0.2, + child: ListView.builder( + itemBuilder: (context, index) { + final content = widget.course.state!.section[index]; + return Card( + shape: content.isCurrentlyRelevant() ? StadiumBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.primaryContainer, + width: 2.0, + ), + ):null, + color: currentIndex == index + ? Theme.of(context).colorScheme.primaryContainer + : null, + child: ListTile( + title: Text( + content.title, + style: Theme.of(context).textTheme.titleSmall, + textAlign: TextAlign.center, + ), + onTap: () { + setState(() { + currentIndex = index; + sectionSelection = buildSectionWidgetForSection(content); + }); + }, + ), + ); + }, + itemCount: widget.course.state!.section.length, + scrollDirection: Axis.vertical, + ), + ); + } + + Widget buildSectionWidgetForSection(MoodleSection section) { + var cmList = section.cmList; + return FutureBuilder( + future: getCmListContents(cmList), + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return CircularProgressIndicator(); + } else if (snap.hasError) { + return Text("Es ist ein Fehler aufgetreten: ${snap.error}"); + } else { + return SizedBox( + width: MediaQuery.of(context).size.width * 0.45, + height: MediaQuery.of(context).size.height * 0.8, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: snap.data! + .map( + (htmlContent) => Column( + children: buildFinalWidgetsFromSegments( + segmentHtmlContent(htmlContent), + ), + ), + ) + .toList(), + ), + ), + ); + } + }, + ); + } + + final RegExp imgTagRegex = RegExp( + '(]*src\s*=\s*["\']([^"\']*)["\'][^>]*>)', + caseSensitive: false, + ); + + List segmentHtmlContent(String fullHtml) { + var split = fullHtml.split(""); + segments.removeAt(i); + segments.insert(i, ""); + if (subvalue.length > 1) { + segments.insert(i + 1, subvalue.sublist(1).join(">")); + } else { + segments.insert(i + 1, ""); + } + } + return segments; + } + + String? extractImageUrl(String imgTag) { + final Match? srcMatch = RegExp( + 'src\s*=\s*["\']([^"\']*)["\']', + caseSensitive: false, + ).firstMatch(imgTag); + return srcMatch?.group(1); + } + + List buildFinalWidgetsFromSegments(List segments) { + final List widgets = []; + + for (final segment in segments) { + if (segment.startsWith('')) { + final String? imageUrl = extractImageUrl(segment); + if (imageUrl != null) { + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: SvgPicture.network( + imageUrl, + colorFilter: ColorFilter.mode( + Colors.deepOrange, + BlendMode.srcIn, + ), + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.broken_image, color: Colors.red), + ), + ), + ); + } + } else { + final String md = html2md.convert(segment); + widgets.add( + GptMarkdown( + md, + linkBuilder: + ( + BuildContext context, + InlineSpan text, + String url, + TextStyle style, + ) { + return GestureDetector( + onTap: () { + showLinkConfirmationDialog(context, url); + }, + child: Text.rich( + text, + style: style.copyWith( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ); + }, + ), + ); + } + } + return widgets; + } + + Future showLinkConfirmationDialog( + BuildContext context, + String url, + ) async { + return showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.open_in_new, color: Colors.blueAccent), + SizedBox(width: 10), + Text( + 'Link öffnen', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), + ), + ], + ), + + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Möchtest du den folgenden externen Link öffnen?', + style: TextStyle(color: Colors.grey[700]), + ), + + const SizedBox(height: 5), + Text( + url, + style: const TextStyle( + color: Colors.blue, + fontStyle: FontStyle.italic, + decoration: TextDecoration.underline, + ), + ), + ], + ), + + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(false); + }, + child: const Text('Abbrechen'), + ), + + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + Navigator.of(dialogContext).pop(true); + dialogContext.push( + '/webviewPage', + extra: { + 'url': url, + } + ); + + }, + icon: const Icon(Icons.launch, size: 18), + label: const Text('Öffnen'), + ), + ], + ); + }, + ); + } + + Future> getCmListContents(List cmList) async { + List contents = []; + for (var cmId in cmList) { + contents.add(await widget.api.loadHtmlDataForMoodleModule(cmId)); + } + return contents; + } +} + +class MoodleCourseArguments { + final ShibbolethSession session; + final MoodleApi api; + final MoodleCourse course; + + MoodleCourseArguments({ + required this.session, + required this.api, + required this.course, + }); +} + +class WebViewPage extends StatefulWidget { + final String url; + + // Der Konstruktor benötigt nur noch die URL + const WebViewPage({ + required this.url, + super.key, + }); + + @override + State createState() => _WebViewPageState(); +} + +class _WebViewPageState extends State { + final GlobalKey webViewKey = GlobalKey(); + @override + void initState() { + super.initState(); + } + + + + @override + Widget build(BuildContext context) { + return InAppWebView( + key: webViewKey, + initialUrlRequest: URLRequest(url: WebUri(widget.url)), + onDownloadStartRequest: (controller, downloadStartRequest) async { + final String url = downloadStartRequest.url.toString(); + final String fileName = downloadStartRequest.suggestedFilename ?? url.split('/').last; + + final cookieManager = CookieManager.instance(); + final cookies = await cookieManager.getCookies(url: WebUri(url)); + final cookieHeader = cookies.map((c) => '${c.name}=${c.value}').join('; '); + + try { + ScaffoldMessenger.of(context).showSnackBar( + + SnackBar(content: Text('Download von "$fileName" gestartet...'), duration: Duration(seconds: 1),) + ); + await downloadAndOpenFile(url, fileName, cookieHeader); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Datei wird geöffnet...!')) + ); + + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download fehlgeschlagen: ${e.toString()}')) + ); + } + + return null; + }, + shouldOverrideUrlLoading: (controller, navigationAction) async { + final Uri? url = navigationAction.request.url; + + if (url != null) { + + final String urlString = url.toString(); + if (urlString.toLowerCase().endsWith(".pdf")) { + final cookieManager = CookieManager.instance(); + final cookies = await cookieManager.getCookies(url: WebUri(url.toString())); + final cookieHeader = cookies.map((c) => '${c.name}=${c.value}').join('; '); + final String fileName = urlString.split("/").last; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download von "$fileName" gestartet. Bitte warten...'), duration: Duration(seconds: 1),) + ); + context.pushReplacement("/pdf-viewer", extra: downloadAndOpenFile(urlString, fileName, cookieHeader)); + return NavigationActionPolicy.CANCEL; + } + } + + return NavigationActionPolicy.ALLOW; + }, + ); + } + + Future downloadAndOpenFile(String url, String fileName, String cookieHeader) async { + + final Directory dir = await getApplicationDocumentsDirectory(); + + + final String savePath = '${dir.path}/$fileName'; + var file = File(savePath); + if(file.existsSync()) { + OpenFilex.open(savePath); + return savePath; + } + + final Dio dio = Dio(); + + await dio.download( + url, + savePath, + options: Options( + headers: { + 'Cookie': cookieHeader, + }, + followRedirects: true, + ), + onReceiveProgress: (received, total) { + if (total != -1) { + } + }, + ); + OpenFilex.open(savePath); + debugPrint('Datei erfolgreich gespeichert unter: $savePath'); + return savePath; + } +} + +class PdfViewScreen extends StatelessWidget { + final Future stringPathFuture; + const PdfViewScreen({ + super.key, + required this.stringPathFuture + }); + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('PDF Dokument')), + body: Center(child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FutureBuilder(future: stringPathFuture, builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text("Fehler beim Laden des PDFs: ${snapshot.error}"); + } else { + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + OpenFilex.open(snapshot.data!); + + }, + icon: const Icon(Icons.settings, size: 18), + label: const Text('Öffnen'), + ); + } + }), + Text("Das PDF-Dokument wird geladen... Bitte warten") + + ], + )) + ); + } +} \ No newline at end of file diff --git a/lib/moodleComponent/view/moodle_viewmodel.dart b/lib/moodleComponent/view/moodle_viewmodel.dart new file mode 100644 index 00000000..b20b0a1e --- /dev/null +++ b/lib/moodleComponent/view/moodle_viewmodel.dart @@ -0,0 +1,178 @@ +import 'package:campus_flutter/base/networking/protocols/api.dart'; +import 'package:campus_flutter/base/routing/routes.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_course.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_user.dart'; +import 'package:campus_flutter/moodleComponent/networking/apis/MoodleApi.dart'; +import 'package:campus_flutter/moodleComponent/service/shibboleth_session_generator.dart'; +import 'package:campus_flutter/onboardingComponent/viewModels/onboarding_viewmodel.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +class MoodleViewModel extends ConsumerStatefulWidget { + const MoodleViewModel({super.key}); + + @override + ConsumerState createState()=> _MoodleViewModelState(); + +} + +class _MoodleViewModelState extends ConsumerState { + + MoodleApi? api; + ShibbolethSession? session; + late Future _future; + var moodleCourses = []; + + @override + void initState() { + super.initState(); + _future = connectToMoodle(); + } + + Future connectToMoodle() async { + var username = Api.tumId; + var password = await ref.read(onboardingViewModel).getPassword(); + ShibbolethSession session = await ShibbolethSessionGenerator().generateSession(username, password).timeout(Duration(seconds: 15), onTimeout: () { + throw WrongTumPasswordSetException(); + }); + try{ + this.session = session; + api = MoodleApi(session); + MoodleUser user = await api!.getMoodleUser(username); + var courses = await api!.getCourses(user); + moodleCourses = courses;} + catch(e) { + throw WrongTumPasswordSetException(); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder(future: _future, builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CupertinoActivityIndicator()); + } else if (snapshot.hasError) { + if(snapshot.error is NoTumPasswordSetException) { + return Center(child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Kein TUM Password gesetzt. Bitte setze dein Passwort in den Einstellungen."), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + context.push( + safetyArea + ); + + }, + icon: const Icon(Icons.settings, size: 18), + label: const Text('Öffnen'), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async{ + await CookieManager.instance().deleteAllCookies(); + setState(() { + session = null; + api = null; + _future = connectToMoodle(); + }); + + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Erneut versuchen'), + ), + ], + )); + }else if(snapshot.error is WrongTumPasswordSetException) { + return Center(child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Das gesetzte TUM Password ist möglicherweise falsch. Bitte setze dein Passwort in den Einstellungen."), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + context.push( + safetyArea + ); + + }, + icon: const Icon(Icons.settings, size: 18), + label: const Text('Öffnen'), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async { + await CookieManager.instance().deleteAllCookies(); + setState(() { + session = null; + api = null; + _future = connectToMoodle(); + }); + + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Erneut versuchen'), + ), + ], + )); + } + debugPrintStack(stackTrace: snapshot.stackTrace); + return Center(child: Text("Error: ${snapshot.error}")); + } else { + return ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: moodleCourses.length, + itemBuilder: (context, index) => SizedBox( + width: 300, + height: 200, + child: moodleCourses[index].build(context, session: session!, api: api!) + ) + ); + } + }); + } +} \ No newline at end of file diff --git a/lib/navigation_service.dart b/lib/navigation_service.dart index eac3f977..4bbeab52 100644 --- a/lib/navigation_service.dart +++ b/lib/navigation_service.dart @@ -49,6 +49,11 @@ class NavigationService { style: Theme.of(context).textTheme.titleLarge, ); case 4: + return Text( + context.tr("moodle"), + style: Theme.of(context).textTheme.titleLarge, + ); + case 5: return Text( context.tr("places"), style: Theme.of(context).textTheme.titleLarge, @@ -164,6 +169,19 @@ class NavigationService { selectedIcon: const Icon(Icons.campaign), label: context.tr("campus"), ), + NavigationDestination( + icon: Image.asset( + 'assets/images/logos/Moodle.png', + fit: BoxFit.cover, + height: 20, + ), + selectedIcon: Image.asset( + 'assets/images/logos/Moodle.png', + fit: BoxFit.cover, + height: 20, + ), + label: context.tr("moodle"), + ), NavigationDestination( icon: const Icon(Icons.place_outlined), selectedIcon: const Icon(Icons.place), diff --git a/lib/onboardingComponent/viewModels/onboarding_viewmodel.dart b/lib/onboardingComponent/viewModels/onboarding_viewmodel.dart index 038e962d..d7822df8 100644 --- a/lib/onboardingComponent/viewModels/onboarding_viewmodel.dart +++ b/lib/onboardingComponent/viewModels/onboarding_viewmodel.dart @@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart'; import 'package:home_widget/home_widget.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:xor_encryption/xor_encryption.dart'; final onboardingViewModel = Provider((ref) => OnboardingViewModel()); @@ -34,6 +35,9 @@ class OnboardingViewModel { final TextEditingController textEditingController1 = TextEditingController(); final TextEditingController textEditingController2 = TextEditingController(); final TextEditingController textEditingController3 = TextEditingController(); + + final TextEditingController tumOnlinePasswordController = + TextEditingController(); void clearTextFields() { textEditingController1.clear(); @@ -76,6 +80,24 @@ class OnboardingViewModel { tumIdValid.add(true); } + + void savePassword(String password) { + var xorPassword = XorCipher().encryptData(password, Api.tumToken); + _storage.write(key: "password", value: xorPassword); + } + + void clearPassword() { + _storage.delete(key: "password"); + } + + Future getPassword() async { + var xorPassword = await _storage.read(key: "password"); + if (xorPassword != null) { + return XorCipher().encryptData(xorPassword, Api.tumToken); + } else { + throw NoTumPasswordSetException(); + } + } Future checkLogin() async { return _storage @@ -157,6 +179,7 @@ class OnboardingViewModel { ref.invalidate(studentCardViewModel); await getIt().clearCache(); await _storage.delete(key: "token"); + await _storage.delete(key: "password"); await HomeWidget.saveWidgetData("calendar", null); await HomeWidget.saveWidgetData("calendar_save", null); await HomeWidget.updateWidget( @@ -168,3 +191,17 @@ class OnboardingViewModel { credentials.add(Credentials.none); } } + +class NoTumPasswordSetException implements Exception { + final String message; + NoTumPasswordSetException([this.message = "No TUM Online password set"]); + @override + String toString() => "NoTumPasswordSetException: $message"; +} + +class WrongTumPasswordSetException implements Exception { + final String message; + WrongTumPasswordSetException([this.message = "Wrong TUM Online password set"]); + @override + String toString() => "WrongumPasswordSetException: $message"; +} diff --git a/lib/onboardingComponent/views/password_view.dart b/lib/onboardingComponent/views/password_view.dart new file mode 100644 index 00000000..318696f5 --- /dev/null +++ b/lib/onboardingComponent/views/password_view.dart @@ -0,0 +1,194 @@ +import 'package:campus_flutter/base/networking/protocols/api.dart'; +import 'package:campus_flutter/base/util/custom_back_button.dart'; +import 'package:campus_flutter/base/routing/routes.dart'; +import 'package:campus_flutter/calendarComponent/services/calendar_service.dart'; +import 'package:campus_flutter/onboardingComponent/viewModels/onboarding_viewmodel.dart'; +import 'package:campus_flutter/personComponent/services/profile_service.dart'; +import 'package:campus_flutter/studentCardComponent/viewModel/student_card_viewmodel.dart'; +import 'package:campus_flutter/studiesComponent/service/grade_service.dart'; +import 'package:campus_flutter/studiesComponent/service/lecture_service.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +class PasswordView extends ConsumerWidget { + var text = ""; + PasswordView({super.key}); + + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backgroundColor = + Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).canvasColor + : Colors.white; + return Scaffold( + backgroundColor: backgroundColor, + appBar: AppBar( + leading: const CustomBackButton(), + title: Text(context.tr("checkPermissions")), + backgroundColor: backgroundColor, + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + Expanded( + flex: 0, + child: Text( + "Setze hier deine Login-Daten für Moodle. Deine Daten werden verschlüsselt gespeichert.", + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ), + const Spacer(), + _tumIdTextFields(context, ref), + const Spacer(), + _tumPasswordField(context, ref), + const Spacer(), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.greenAccent, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + CookieManager.instance().deleteAllCookies(); + ref.read(onboardingViewModel).savePassword(text); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.tr("passwordSaved")), + backgroundColor: Colors.greenAccent, + ), + ); + }, + icon: const Icon(Icons.save, size: 18), + label: const Text('Passwort speichern'), + ), + const Spacer(), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + CookieManager.instance().deleteAllCookies(); + ref.read(onboardingViewModel).clearPassword(); + ref.read(onboardingViewModel).tumOnlinePasswordController.clear(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.tr("passwordReset")), + ), + ); + }, + icon: const Icon(Icons.delete, size: 18), + label: const Text('Passwort zurücksetzen'), + ), + const Spacer(flex: 3), + ], + ), + ), + ); + } + + Widget _tumPasswordField(BuildContext context, WidgetRef ref) { + return TextField( + decoration: InputDecoration( + hintText: context.tr("password"), + border: const OutlineInputBorder(), + ), + obscureText: true, + controller: ref.read(onboardingViewModel).tumOnlinePasswordController, + onChanged: (text) { + this.text = text; + }, + enableSuggestions: false, + autocorrect: false, + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } + + Widget _tumIdTextFields(BuildContext context, WidgetRef ref) { + return Row( + children: [ + const Spacer(), + Expanded( + child: _loginTextField( + "go", + TextInputType.text, + 2, + TextEditingController(text: Api.tumId[0] + Api.tumId[1]), + ref, context + ), + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 4.0)), + Expanded( + child: _loginTextField( + "42", + TextInputType.number, + 2, + TextEditingController(text: Api.tumId[2] + Api.tumId[3]), + ref, context + ), + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 4.0)), + Expanded( + child: _loginTextField( + "tum", + TextInputType.text, + 3, + TextEditingController(text: Api.tumId.substring(4, 7)), + ref, context + ), + ), + const Spacer(), + ], + ); + } + + Widget _loginTextField( + String hintText, + TextInputType keyboardType, + int maxLength, + TextEditingController controller, + WidgetRef ref, + BuildContext context + ) { + return TextField( + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: MediaQuery.platformBrightnessOf(context) == Brightness.dark + ? Colors.grey.shade700 + : Colors.grey.shade400, + ), + border: const OutlineInputBorder(), + ), + keyboardType: keyboardType, + inputFormatters: [LengthLimitingTextInputFormatter(maxLength)], + controller: controller, + onChanged: null, + enableSuggestions: false, + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } + + +} \ No newline at end of file diff --git a/lib/settingsComponent/views/general_settings_view.dart b/lib/settingsComponent/views/general_settings_view.dart index bcb9d350..ccf76cdc 100644 --- a/lib/settingsComponent/views/general_settings_view.dart +++ b/lib/settingsComponent/views/general_settings_view.dart @@ -20,6 +20,7 @@ class GeneralSettingsView extends ConsumerWidget { child: SeparatedList.widgets( widgets: [ _tokenPermission(context), + _safetyArea(context), _localeSelection(context, ref), _moreSettings(context), ], @@ -41,6 +42,19 @@ class GeneralSettingsView extends ConsumerWidget { ); } + Widget _safetyArea(BuildContext context) { + return ListTile( + dense: true, + leading: Icon(Icons.privacy_tip_outlined, size: 20, color: context.primaryColor), + title: Text( + "Sicherheit & Passwörter", + style: Theme.of(context).textTheme.bodyMedium, + ), + trailing: const Icon(Icons.arrow_forward_ios, size: 15), + onTap: () => context.push(safetyArea, extra: true), + ); + } + Widget _localeSelection(BuildContext context, WidgetRef ref) { return ListTile( dense: true, diff --git a/lib/settingsComponent/views/settings_view.dart b/lib/settingsComponent/views/settings_view.dart index c42c92ea..b2a6843e 100644 --- a/lib/settingsComponent/views/settings_view.dart +++ b/lib/settingsComponent/views/settings_view.dart @@ -28,12 +28,13 @@ class SettingsView extends ConsumerWidget { return Row( children: [ const Expanded( - child: Column( + child: SingleChildScrollView(child: Column( children: [ GeneralSettingsView(), AppearanceSettingsView(), CalendarSettingsView(), ], + ) ), ), Expanded( diff --git a/lib/studentCardComponent/views/student_card_view.dart b/lib/studentCardComponent/views/student_card_view.dart index 916da802..0e65c8fd 100644 --- a/lib/studentCardComponent/views/student_card_view.dart +++ b/lib/studentCardComponent/views/student_card_view.dart @@ -1,6 +1,7 @@ import 'package:campus_flutter/base/enums/error_handling_view_type.dart'; import 'package:campus_flutter/base/errorHandling/error_handling_router.dart'; import 'package:campus_flutter/base/extensions/context.dart'; +import 'package:campus_flutter/base/networking/protocols/api.dart'; import 'package:campus_flutter/base/util/card_with_padding.dart'; import 'package:campus_flutter/base/util/delayed_loading_indicator.dart'; import 'package:campus_flutter/base/util/last_updated_text.dart'; @@ -23,6 +24,7 @@ class StudentCardView extends ConsumerWidget { if (snapshot.hasData) { if (snapshot.data!.isNotEmpty) { var data = snapshot.data!.first; + Api.tumId = data.studyID; final lastFetched = ref .read(studentCardViewModel) .lastFetched diff --git a/pubspec.lock b/pubspec.lock index 18435373..e2876396 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -471,6 +471,70 @@ packages: url: "https://github.com/mchome/flutter_colorpicker.git" source: git version: "1.1.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_linkify: dependency: "direct main" description: @@ -493,6 +557,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_math_fork: + dependency: transitive + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" flutter_native_splash: dependency: "direct main" description: @@ -577,10 +649,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -751,6 +823,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + gpt_markdown: + dependency: "direct main" + description: + name: gpt_markdown + sha256: "68d5337c8a00fc03a37dbddf84a6fd90401c30e99b6baf497ef9522a81fc34ee" + url: "https://pub.dev" + source: hosted + version: "1.1.2" graphs: dependency: transitive description: @@ -792,6 +872,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + html2md: + dependency: "direct main" + description: + name: html2md + sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036" + url: "https://pub.dev" + source: hosted + version: "1.3.2" http: dependency: transitive description: @@ -968,6 +1056,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" node_preamble: dependency: transitive description: @@ -984,6 +1080,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_config: dependency: transitive description: @@ -1168,6 +1272,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1541,6 +1653,30 @@ packages: url: "https://pub.dev" source: hosted version: "30.1.39" + syncfusion_flutter_pdf: + dependency: transitive + description: + name: syncfusion_flutter_pdf + sha256: c2f3e4e5febeaf68e84c9bcfe591088bd3b993acadad61b11c052eac3c8b1c7a + url: "https://pub.dev" + source: hosted + version: "30.1.39" + syncfusion_flutter_pdfviewer: + dependency: "direct main" + description: + name: syncfusion_flutter_pdfviewer + sha256: b06590a752c9e66273179a531a9cbf7fca61ca00a3411cfbe8fb1f67b72ec4a6 + url: "https://pub.dev" + source: hosted + version: "30.1.39" + syncfusion_flutter_signaturepad: + dependency: transitive + description: + name: syncfusion_flutter_signaturepad + sha256: d3ff61c7c0e6fcb8e5b712dcc2faa4a98503bb3b958c6e5182dc19e7c64366b8 + url: "https://pub.dev" + source: hosted + version: "30.1.39" syncfusion_localizations: dependency: transitive description: @@ -1549,6 +1685,46 @@ packages: url: "https://pub.dev" source: hosted version: "30.1.39" + syncfusion_pdfviewer_linux: + dependency: transitive + description: + name: syncfusion_pdfviewer_linux + sha256: "3dd5db820e26e6735a9e1c67d5e1f6ac9bb9d522ae284f46e3ce371319f75c5b" + url: "https://pub.dev" + source: hosted + version: "30.2.7" + syncfusion_pdfviewer_macos: + dependency: transitive + description: + name: syncfusion_pdfviewer_macos + sha256: "40b1e3e61366f3951a8b12ba8813c35ae70b1c8d536b2b417d689d2438d9c184" + url: "https://pub.dev" + source: hosted + version: "30.2.7" + syncfusion_pdfviewer_platform_interface: + dependency: transitive + description: + name: syncfusion_pdfviewer_platform_interface + sha256: ae5f4c5b2b7f5703d5ebb646641481dc58ae79fa5e6fd4ea86fa5c65a9556786 + url: "https://pub.dev" + source: hosted + version: "30.2.7" + syncfusion_pdfviewer_web: + dependency: transitive + description: + name: syncfusion_pdfviewer_web + sha256: "8020d0175047e24caf80341587d7b4be424c5f648bc4763c710cf59487ef16e2" + url: "https://pub.dev" + source: hosted + version: "30.2.7" + syncfusion_pdfviewer_windows: + dependency: transitive + description: + name: syncfusion_pdfviewer_windows + sha256: f46813908b065b74d151a71d4a1155fd5f9d8c59a5765cb6165a4949e626702c + url: "https://pub.dev" + source: hosted + version: "30.2.7" synchronized: dependency: transitive description: @@ -1613,6 +1789,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -1862,6 +2046,14 @@ packages: url: "https://github.com/jakobkoerber/xml2json.git" source: git version: "6.2.7" + xor_encryption: + dependency: "direct main" + description: + name: xor_encryption + sha256: "535520498dabddbd1818a694a6c6f9372b331f858bf52d9dccc80a1784a8ddd0" + url: "https://pub.dev" + source: hosted + version: "0.0.5" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c1fba03d..fa65b8f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: # user interface shimmer: ^3.0.0 flutter_staggered_grid_view: ^0.7.0 - flutter_svg: ^2.0.9 + flutter_svg: ^2.2.1 flutter_linkify: ^6.0.0 home_widget: ^0.8.0 auto_size_text: ^3.0.0 @@ -77,6 +77,16 @@ dependencies: easy_logger: ^0.0.2 intl: ^0.20.2 + # shibboleth/moodle + flutter_inappwebview: ^6.1.5 + html2md: ^1.3.2 + gpt_markdown: ^1.1.2 + syncfusion_flutter_pdfviewer: ^30.1.39 + open_filex: ^4.7.0 + + #password in settings_ui: + xor_encryption: ^0.0.5 + dependency_overrides: xml2json: git: @@ -113,6 +123,7 @@ flutter: - assets/images/logos/tum-logo-blue.png - assets/images/logos/tum-logo-blue-text.png - assets/images/logos/tum-logo-rainbow.png + - assets/images/logos/Moodle.png - assets/images/placeholders/portrait_placeholder.png - assets/images/placeholders/movie_placeholder.png - assets/images/placeholders/news_placeholder.png From 7a9b015156f2be3fd4375979e2a9b6fa2481e58e Mon Sep 17 00:00:00 2001 From: TechnoMarc3 Date: Mon, 20 Oct 2025 23:58:02 +0200 Subject: [PATCH 3/6] Improve Moodle Component for Portrait View This commit introduces significant layout improvements for the Moodle component when viewed in portrait orientation. - **`moodle_course_viewmodel.dart`**: Adjusts the course content and section selection layout to a `Column` for a better vertical fit in portrait mode. The course content list now scrolls horizontally. - **`moodle_viewmodel.dart`**: The main course list now scrolls vertically in portrait mode, while retaining horizontal scrolling in landscape. - **`moodle_course.dart`**: The course card widget now displays as a `Row` in portrait view, creating a more compact and readable list item. The course image size is also adjusted based on the device's orientation. --- lib/moodleComponent/model/moodle_course.dart | 51 +++++++++++++++---- .../view/moodle_course_viewmodel.dart | 46 ++++++++++++++--- .../view/moodle_viewmodel.dart | 2 +- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/lib/moodleComponent/model/moodle_course.dart b/lib/moodleComponent/model/moodle_course.dart index 76969eaf..4e2fdd31 100644 --- a/lib/moodleComponent/model/moodle_course.dart +++ b/lib/moodleComponent/model/moodle_course.dart @@ -72,19 +72,24 @@ class MoodleCourse{ - Widget createImage() { + Widget createImage(BuildContext ctx) { + double width = 250; + double height = 250; + if(MediaQuery.of(ctx).orientation == Orientation.portrait) { + width = MediaQuery.of(ctx).size.width * 0.08; + height = MediaQuery.of(ctx).size.width * 0.08; + } if (courseImage.isEmpty) { return const Icon(Icons.book, size: 40); } else { if(courseImage.contains("base64")) { - //base64 image final base64String = courseImage.split(',').last; var converted = utf8.decode(base64.decode(base64String)); converted = converted.replaceAll("100%", "280px"); return SvgPicture.string( converted, - width: 250, - height: 250, + width: width, + height: height, fit: BoxFit.cover, placeholderBuilder: (context) => const Icon(Icons.book, size: 40), ); @@ -92,8 +97,8 @@ class MoodleCourse{ } else { return Image.network( courseImage, - width: 250, - height: 250, + width: width, + height: height, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return const Icon(Icons.book, size: 40); @@ -141,8 +146,8 @@ class MoodleCourse{ //create a smooth design return SizedBox( - height: MediaQuery.of(context).size.height*0.8, - width: MediaQuery.of(context).size.width * 0.25, + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.2 : MediaQuery.of(context).size.height*0.8, + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.25, child: Card( shape: RoundedRectangleBorder( @@ -166,7 +171,33 @@ class MoodleCourse{ }, child: Padding( padding: const EdgeInsets.all(10.0), - child: Column( + child: + MediaQuery.of(context).orientation == Orientation.portrait ? + Row( + children: [ + createImage(context), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fullname, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + Text( + courseCategory, + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + ), + withArrowForward ? Icon(Icons.arrow_forward, color: Theme.of(context).colorScheme.primary): withArrowBackward ? Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.primary): const SizedBox.shrink(), + ], + ) : + Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text( @@ -175,7 +206,7 @@ class MoodleCourse{ textAlign: TextAlign.center, ), const SizedBox(height: 10), - createImage(), + createImage(context), const SizedBox(height: 10), Text( courseCategory, diff --git a/lib/moodleComponent/view/moodle_course_viewmodel.dart b/lib/moodleComponent/view/moodle_course_viewmodel.dart index 9cd96e43..5a573a19 100644 --- a/lib/moodleComponent/view/moodle_course_viewmodel.dart +++ b/lib/moodleComponent/view/moodle_course_viewmodel.dart @@ -53,6 +53,14 @@ class _MoodleCourseViewModelState extends ConsumerState { future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { + if(MediaQuery.of(context).orientation == Orientation.portrait) { + return Column( + children: [ + widget.course.build(context, withArrowForward: false, withArrowBackward: true), + const Center(child: CupertinoActivityIndicator()), + ], + ); + } return Row( children: [ widget.course.build(context, withArrowForward: false, withArrowBackward: true), @@ -63,6 +71,16 @@ class _MoodleCourseViewModelState extends ConsumerState { debugPrintStack(stackTrace: snapshot.stackTrace); return Center(child: Text("Error: ${snapshot.error}")); } else { + if(MediaQuery.of(context).orientation == Orientation.portrait) { + return Column( + children: [ + widget.course.build(context, withArrowForward: false, withArrowBackward: true), + const SizedBox(height: 10), + buildCourseContent(), + sectionSelection ?? Container(), + ], + ); + } return Row( children: [ widget.course.build(context, withArrowForward: false, withArrowBackward: true), @@ -74,7 +92,17 @@ class _MoodleCourseViewModelState extends ConsumerState { } }, ) - : Row( + : + MediaQuery.of(context).orientation == Orientation.portrait ? + Column( + children: [ + widget.course.build(context, withArrowForward: false, withArrowBackward: true), + buildCourseContent(), + sectionSelection ?? Container(), + ], + ) + : + Row( children: [ widget.course.build(context, withArrowForward: false, withArrowBackward: true), buildCourseContent(), @@ -85,7 +113,8 @@ class _MoodleCourseViewModelState extends ConsumerState { Widget buildCourseContent() { return SizedBox( - width: MediaQuery.of(context).size.width * 0.2, + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.2, + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.06 : MediaQuery.of(context).size.height, child: ListView.builder( itemBuilder: (context, index) { final content = widget.course.state!.section[index]; @@ -99,7 +128,10 @@ class _MoodleCourseViewModelState extends ConsumerState { color: currentIndex == index ? Theme.of(context).colorScheme.primaryContainer : null, - child: ListTile( + child: SizedBox( + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width*0.3 : MediaQuery.of(context).size.width * 0.2, + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.06 : MediaQuery.of(context).size.height, + child: ListTile( title: Text( content.title, style: Theme.of(context).textTheme.titleSmall, @@ -112,10 +144,10 @@ class _MoodleCourseViewModelState extends ConsumerState { }); }, ), - ); + )); }, itemCount: widget.course.state!.section.length, - scrollDirection: Axis.vertical, + scrollDirection: MediaQuery.of(context).orientation == Orientation.portrait ? Axis.horizontal : Axis.vertical, ), ); } @@ -131,8 +163,8 @@ class _MoodleCourseViewModelState extends ConsumerState { return Text("Es ist ein Fehler aufgetreten: ${snap.error}"); } else { return SizedBox( - width: MediaQuery.of(context).size.width * 0.45, - height: MediaQuery.of(context).size.height * 0.8, + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.45, + height:MediaQuery.of(context).orientation == Orientation.portrait? MediaQuery.of(context).size.height*0.6: MediaQuery.of(context).size.height * 0.8, child: SingleChildScrollView( scrollDirection: Axis.vertical, child: Column( diff --git a/lib/moodleComponent/view/moodle_viewmodel.dart b/lib/moodleComponent/view/moodle_viewmodel.dart index b20b0a1e..69ed2fef 100644 --- a/lib/moodleComponent/view/moodle_viewmodel.dart +++ b/lib/moodleComponent/view/moodle_viewmodel.dart @@ -164,7 +164,7 @@ class _MoodleViewModelState extends ConsumerState { return Center(child: Text("Error: ${snapshot.error}")); } else { return ListView.builder( - scrollDirection: Axis.horizontal, + scrollDirection: MediaQuery.of(context).orientation == Orientation.landscape ? Axis.horizontal : Axis.vertical, itemCount: moodleCourses.length, itemBuilder: (context, index) => SizedBox( width: 300, From a0f68c14a08b2bd9383ccf3de8c1724e8aa4fc39 Mon Sep 17 00:00:00 2001 From: TechnoMarc3 Date: Tue, 21 Oct 2025 00:02:11 +0200 Subject: [PATCH 4/6] Fix height of Moodle course content list item in landscape mode The height of the `ListTile` for Moodle course content was adjusted for landscape orientation to improve layout. --- lib/moodleComponent/view/moodle_course_viewmodel.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/moodleComponent/view/moodle_course_viewmodel.dart b/lib/moodleComponent/view/moodle_course_viewmodel.dart index 5a573a19..dcf998ef 100644 --- a/lib/moodleComponent/view/moodle_course_viewmodel.dart +++ b/lib/moodleComponent/view/moodle_course_viewmodel.dart @@ -130,7 +130,7 @@ class _MoodleCourseViewModelState extends ConsumerState { : null, child: SizedBox( width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width*0.3 : MediaQuery.of(context).size.width * 0.2, - height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.06 : MediaQuery.of(context).size.height, + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.06 : MediaQuery.of(context).size.height * 0.08, child: ListTile( title: Text( content.title, From 74f17674fdfa889b941bf5d746a8d3e2b9f49029 Mon Sep 17 00:00:00 2001 From: TechnoMarc3 Date: Tue, 21 Oct 2025 00:17:28 +0200 Subject: [PATCH 5/6] Improve Moodle Course View Layout for Portrait and Landscape This commit refactors the UI for the Moodle course and course content views to provide a better layout in both portrait and landscape orientations. Specifically, the sizing of various `SizedBox` and `ListTile` widgets has been adjusted in `moodle_course_viewmodel.dart`, `moodle_viewmodel.dart`, and `moodle_course.dart` to use screen-relative dimensions, improving responsiveness and consistency across different device orientations. --- lib/moodleComponent/model/moodle_course.dart | 2 +- lib/moodleComponent/view/moodle_course_viewmodel.dart | 8 ++++---- lib/moodleComponent/view/moodle_viewmodel.dart | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/moodleComponent/model/moodle_course.dart b/lib/moodleComponent/model/moodle_course.dart index 4e2fdd31..fa9e3cf4 100644 --- a/lib/moodleComponent/model/moodle_course.dart +++ b/lib/moodleComponent/model/moodle_course.dart @@ -146,7 +146,7 @@ class MoodleCourse{ //create a smooth design return SizedBox( - height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.2 : MediaQuery.of(context).size.height*0.8, + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.15 : MediaQuery.of(context).size.height*0.8, width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.25, child: Card( diff --git a/lib/moodleComponent/view/moodle_course_viewmodel.dart b/lib/moodleComponent/view/moodle_course_viewmodel.dart index dcf998ef..9c413cd4 100644 --- a/lib/moodleComponent/view/moodle_course_viewmodel.dart +++ b/lib/moodleComponent/view/moodle_course_viewmodel.dart @@ -114,7 +114,7 @@ class _MoodleCourseViewModelState extends ConsumerState { Widget buildCourseContent() { return SizedBox( width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.2, - height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.06 : MediaQuery.of(context).size.height, + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.07 : MediaQuery.of(context).size.height, child: ListView.builder( itemBuilder: (context, index) { final content = widget.course.state!.section[index]; @@ -129,8 +129,8 @@ class _MoodleCourseViewModelState extends ConsumerState { ? Theme.of(context).colorScheme.primaryContainer : null, child: SizedBox( - width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width*0.3 : MediaQuery.of(context).size.width * 0.2, - height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.06 : MediaQuery.of(context).size.height * 0.08, + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width*0.4 : MediaQuery.of(context).size.width * 0.2, + height: MediaQuery.of(context).size.height * 0.07, child: ListTile( title: Text( content.title, @@ -164,7 +164,7 @@ class _MoodleCourseViewModelState extends ConsumerState { } else { return SizedBox( width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.45, - height:MediaQuery.of(context).orientation == Orientation.portrait? MediaQuery.of(context).size.height*0.6: MediaQuery.of(context).size.height * 0.8, + height:MediaQuery.of(context).orientation == Orientation.portrait? MediaQuery.of(context).size.height*0.55: MediaQuery.of(context).size.height * 0.8, child: SingleChildScrollView( scrollDirection: Axis.vertical, child: Column( diff --git a/lib/moodleComponent/view/moodle_viewmodel.dart b/lib/moodleComponent/view/moodle_viewmodel.dart index 69ed2fef..9e73eb29 100644 --- a/lib/moodleComponent/view/moodle_viewmodel.dart +++ b/lib/moodleComponent/view/moodle_viewmodel.dart @@ -167,9 +167,9 @@ class _MoodleViewModelState extends ConsumerState { scrollDirection: MediaQuery.of(context).orientation == Orientation.landscape ? Axis.horizontal : Axis.vertical, itemCount: moodleCourses.length, itemBuilder: (context, index) => SizedBox( - width: 300, - height: 200, - child: moodleCourses[index].build(context, session: session!, api: api!) + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.15 : MediaQuery.of(context).size.height*0.8, + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.25, + child: moodleCourses[index].build(context, session: session!, api: api!) ) ); } From b778705fc0ec8173891a65bc57f1f13d12e3e93e Mon Sep 17 00:00:00 2001 From: TechnoMarc3 Date: Tue, 21 Oct 2025 11:46:32 +0200 Subject: [PATCH 6/6] Refactor Moodle Connection Logic Moved the Moodle connection logic from `MoodleViewModel` to a shared function, `connectToMoodle`, now located in `home_screen.dart`. This function is now called earlier in the app's lifecycle, specifically after the student card or contact card is loaded, to pre-fetch Moodle courses. This change prevents redundant Moodle API calls by caching the connection future and course data in the central `Api` class. The `MoodleViewModel` now utilizes this cached data, improving performance and showing a loading indicator while waiting for the initial connection. As a result, moodle now loads during the app's startup logic and every relevant data is cached within the Api.dart class. In fact, moodle loading times are drastically. --- lib/base/networking/protocols/api.dart | 7 ++ lib/homeComponent/screen/home_screen.dart | 25 +++++++ .../view/contactCard/contact_card_view.dart | 2 + .../view/moodle_viewmodel.dart | 75 +++++++++++++------ .../views/student_card_view.dart | 2 + 5 files changed, 88 insertions(+), 23 deletions(-) diff --git a/lib/base/networking/protocols/api.dart b/lib/base/networking/protocols/api.dart index 65937745..c3490e74 100644 --- a/lib/base/networking/protocols/api.dart +++ b/lib/base/networking/protocols/api.dart @@ -1,8 +1,15 @@ +import 'package:campus_flutter/moodleComponent/model/moodle_course.dart'; +import 'package:campus_flutter/moodleComponent/networking/apis/MoodleApi.dart'; +import 'package:campus_flutter/moodleComponent/service/shibboleth_session_generator.dart'; import 'package:dio/dio.dart' as dio; abstract class Api { static String tumToken = ""; static String tumId = ""; + static Future>? coursesFuture; + static List courses = []; + static ShibbolethSession? session; + static MoodleApi? moodleApi; String get domain; diff --git a/lib/homeComponent/screen/home_screen.dart b/lib/homeComponent/screen/home_screen.dart index d1d7e29e..18fbf7f4 100644 --- a/lib/homeComponent/screen/home_screen.dart +++ b/lib/homeComponent/screen/home_screen.dart @@ -1,9 +1,34 @@ +import 'package:campus_flutter/base/networking/protocols/api.dart'; import 'package:campus_flutter/base/util/padded_divider.dart'; import 'package:campus_flutter/homeComponent/view/contactCard/contact_view.dart'; import 'package:campus_flutter/homeComponent/view/widget/widget_screen.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_course.dart'; +import 'package:campus_flutter/moodleComponent/model/moodle_user.dart'; +import 'package:campus_flutter/moodleComponent/networking/apis/MoodleApi.dart'; +import 'package:campus_flutter/moodleComponent/service/shibboleth_session_generator.dart'; +import 'package:campus_flutter/onboardingComponent/viewModels/onboarding_viewmodel.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +Future> connectToMoodle(WidgetRef ref) async { + var username = Api.tumId; + var password = await ref.read(onboardingViewModel).getPassword(); + ShibbolethSession session = await ShibbolethSessionGenerator().generateSession(username, password).timeout(Duration(seconds: 15), onTimeout: () { + throw WrongTumPasswordSetException(); + }); + try { + var api = MoodleApi(session); + MoodleUser user = await api.getMoodleUser(username); + Api.courses = await api.getCourses(user); + Api.session = session; + Api.moodleApi = api; + return Api.courses; + } + catch(e) { + throw WrongTumPasswordSetException(); + } +} + class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); diff --git a/lib/homeComponent/view/contactCard/contact_card_view.dart b/lib/homeComponent/view/contactCard/contact_card_view.dart index e02b4ad6..a18c12bc 100644 --- a/lib/homeComponent/view/contactCard/contact_card_view.dart +++ b/lib/homeComponent/view/contactCard/contact_card_view.dart @@ -4,6 +4,7 @@ import 'package:campus_flutter/base/extensions/base_64_decode_image_data.dart'; import 'package:campus_flutter/base/networking/protocols/api.dart'; import 'package:campus_flutter/base/services/device_type_service.dart'; import 'package:campus_flutter/base/util/delayed_loading_indicator.dart'; +import 'package:campus_flutter/homeComponent/screen/home_screen.dart'; import 'package:campus_flutter/homeComponent/view/contactCard/contact_card_loading_view.dart'; import 'package:campus_flutter/navigation_service.dart'; import 'package:campus_flutter/personComponent/model/personDetails/person_details.dart'; @@ -41,6 +42,7 @@ class _ContactCardViewState extends ConsumerState { builder: (context, snapshot) { if (snapshot.hasData || snapshot.hasError) { Api.tumId = widget.profile.tumID!; + Api.coursesFuture ??= connectToMoodle(ref); return InkWell( onTap: () => NavigationService.openStudentCardSheet(context), child: contactInfo(snapshot.data, widget.profile), diff --git a/lib/moodleComponent/view/moodle_viewmodel.dart b/lib/moodleComponent/view/moodle_viewmodel.dart index 9e73eb29..8212ec27 100644 --- a/lib/moodleComponent/view/moodle_viewmodel.dart +++ b/lib/moodleComponent/view/moodle_viewmodel.dart @@ -1,5 +1,6 @@ import 'package:campus_flutter/base/networking/protocols/api.dart'; import 'package:campus_flutter/base/routing/routes.dart'; +import 'package:campus_flutter/homeComponent/screen/home_screen.dart'; import 'package:campus_flutter/moodleComponent/model/moodle_course.dart'; import 'package:campus_flutter/moodleComponent/model/moodle_user.dart'; import 'package:campus_flutter/moodleComponent/networking/apis/MoodleApi.dart'; @@ -19,7 +20,7 @@ class MoodleViewModel extends ConsumerStatefulWidget { } -class _MoodleViewModelState extends ConsumerState { +class _MoodleViewModelState extends ConsumerState{ MoodleApi? api; ShibbolethSession? session; @@ -29,31 +30,35 @@ class _MoodleViewModelState extends ConsumerState { @override void initState() { super.initState(); - _future = connectToMoodle(); + if(Api.courses.isEmpty) { + _future = Api.coursesFuture!.then((value) { + setState(() { + moodleCourses = value; + session = Api.session; + api = Api.moodleApi; + }); + }).catchError((error) { + throw error; + }); + }else { + moodleCourses = Api.courses; + session = Api.session; + api = Api.moodleApi; + } } - Future connectToMoodle() async { - var username = Api.tumId; - var password = await ref.read(onboardingViewModel).getPassword(); - ShibbolethSession session = await ShibbolethSessionGenerator().generateSession(username, password).timeout(Duration(seconds: 15), onTimeout: () { - throw WrongTumPasswordSetException(); - }); - try{ - this.session = session; - api = MoodleApi(session); - MoodleUser user = await api!.getMoodleUser(username); - var courses = await api!.getCourses(user); - moodleCourses = courses;} - catch(e) { - throw WrongTumPasswordSetException(); - } - } @override Widget build(BuildContext context) { - return FutureBuilder(future: _future, builder: (context, snapshot) { + return Api.courses.isEmpty ? FutureBuilder(future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CupertinoActivityIndicator()); + return Center(child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + Text("Verbindung wird hergestellt..."), + ], + )); } else if (snapshot.hasError) { if(snapshot.error is NoTumPasswordSetException) { return Center(child: Column( @@ -98,7 +103,15 @@ class _MoodleViewModelState extends ConsumerState { setState(() { session = null; api = null; - _future = connectToMoodle(); + _future = connectToMoodle(ref).then((value) { + setState(() { + moodleCourses = value; + session = Api.session; + api = Api.moodleApi; + }); + }).catchError((error) { + throw error; + }); }); }, @@ -150,7 +163,15 @@ class _MoodleViewModelState extends ConsumerState { setState(() { session = null; api = null; - _future = connectToMoodle(); + _future = connectToMoodle(ref).then((value) { + setState(() { + moodleCourses = value; + session = Api.session; + api = Api.moodleApi; + }); + }).catchError((error) { + throw error; + }); }); }, @@ -173,6 +194,14 @@ class _MoodleViewModelState extends ConsumerState { ) ); } - }); + }) : ListView.builder( + scrollDirection: MediaQuery.of(context).orientation == Orientation.landscape ? Axis.horizontal : Axis.vertical, + itemCount: moodleCourses.length, + itemBuilder: (context, index) => SizedBox( + height: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.height * 0.15 : MediaQuery.of(context).size.height*0.8, + width: MediaQuery.of(context).orientation == Orientation.portrait ? MediaQuery.of(context).size.width : MediaQuery.of(context).size.width * 0.25, + child: moodleCourses[index].build(context, session: session!, api: api!) + ) + );; } } \ No newline at end of file diff --git a/lib/studentCardComponent/views/student_card_view.dart b/lib/studentCardComponent/views/student_card_view.dart index 0e65c8fd..2bf5fad0 100644 --- a/lib/studentCardComponent/views/student_card_view.dart +++ b/lib/studentCardComponent/views/student_card_view.dart @@ -5,6 +5,7 @@ import 'package:campus_flutter/base/networking/protocols/api.dart'; import 'package:campus_flutter/base/util/card_with_padding.dart'; import 'package:campus_flutter/base/util/delayed_loading_indicator.dart'; import 'package:campus_flutter/base/util/last_updated_text.dart'; +import 'package:campus_flutter/homeComponent/screen/home_screen.dart'; import 'package:campus_flutter/studentCardComponent/viewModel/student_card_viewmodel.dart'; import 'package:campus_flutter/studentCardComponent/views/bar_code_view.dart'; import 'package:campus_flutter/studentCardComponent/views/information_view.dart'; @@ -25,6 +26,7 @@ class StudentCardView extends ConsumerWidget { if (snapshot.data!.isNotEmpty) { var data = snapshot.data!.first; Api.tumId = data.studyID; + Api.coursesFuture ??= connectToMoodle(ref); final lastFetched = ref .read(studentCardViewModel) .lastFetched