diff --git a/.gitignore b/.gitignore index 04c23541d..da15dde4c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ *.bin .flutter-plugins* +packages/uni_ui/pubspec.lock # IDE files .DS_Store diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..c084ad224 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1732758367, + "narHash": "sha256-RzaI1RO0UXqLjydtz3GAXSTzHkpb/lLD1JD8a0W4Wpo=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "fa42b5a5f401aab8a32bd33c9a4de0738180dc59", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..8e67f7ab6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + description = "A very basic flake"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + + pkgs = import nixpkgs { + inherit system; + config = { + allowUnfree = true; + android_sdk.accept_license = true; + }; + }; + + androidComposition = pkgs.androidenv.composeAndroidPackages { + platformVersions = [ "31" "33" "34" ]; + buildToolsVersions = [ "34.0.0" ]; + includeNDK = true; + includeEmulator = true; + includeSystemImages = true; + }; + + androidSdk = androidComposition.androidsdk; + in { + + devShell.${system} = pkgs.mkShell { + packages = with pkgs; [ + flutter + jdk17 + androidSdk + ]; + + shellHook = '' + export ANDROID_HOME="${androidSdk}/libexec/android-sdk" + export JAVA_HOME="${pkgs.jdk17}" + ''; + }; + }; +} diff --git a/packages/uni_app/README.md b/packages/uni_app/README.md index 6d14d29ff..5a24810d8 100644 --- a/packages/uni_app/README.md +++ b/packages/uni_app/README.md @@ -66,7 +66,7 @@ To use the translation import `'package:uni/generated/l10n.dart'` and use `S.of( For this project, we separate the code into *model, *view* and *controller*. By making sure view-only components are clear from the rest of the code, we can assure safe reuse of widgets as well as separated testing and development. -![MVC Scheme](../readme-src/MVC.png "MVC Scheme") +![MVC Scheme](../../readme-src/MVC.png "MVC Scheme") ### Model The *model* represents the entities that are used in the app, including the session, the classes, the exams. They should be generated from the controller's methods and passed to the view. The model should not contain logic, but only the data that is needed to display the information. diff --git a/packages/uni_app/android/app/build.gradle b/packages/uni_app/android/app/build.gradle index 30f18ecdd..96d940781 100644 --- a/packages/uni_app/android/app/build.gradle +++ b/packages/uni_app/android/app/build.gradle @@ -76,6 +76,9 @@ android { "proguard-rules.pro" ) } + debug { + applicationIdSuffix ".dev" + } } } diff --git a/packages/uni_app/android/app/src/debug/res/values/string.xml b/packages/uni_app/android/app/src/debug/res/values/string.xml new file mode 100644 index 000000000..57a1e9693 --- /dev/null +++ b/packages/uni_app/android/app/src/debug/res/values/string.xml @@ -0,0 +1,4 @@ + + + uni (dev) + diff --git a/packages/uni_app/android/app/src/main/AndroidManifest.xml b/packages/uni_app/android/app/src/main/AndroidManifest.xml index fbc9a782b..62f6f057e 100644 --- a/packages/uni_app/android/app/src/main/AndroidManifest.xml +++ b/packages/uni_app/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,11 @@ - + android:usesCleartextTraffic="true" + android:allowBackup="false" + > + + uni + diff --git a/packages/uni_app/lib/controller/background_workers/background_callback.dart b/packages/uni_app/lib/controller/background_workers/background_callback.dart index 1283b05fc..de75ab882 100644 --- a/packages/uni_app/lib/controller/background_workers/background_callback.dart +++ b/packages/uni_app/lib/controller/background_workers/background_callback.dart @@ -1,5 +1,4 @@ import 'package:logger/logger.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/controller/background_workers/notifications.dart'; import 'package:workmanager/workmanager.dart'; @@ -7,8 +6,10 @@ import 'package:workmanager/workmanager.dart'; /// the bool is all functions that are ran by backgroundfetch in iOS /// (they must not take any arguments, not checked) const taskMap = { - 'pt.up.fe.ni.uni.notificationworker': - Tuple2(NotificationManager.updateAndTriggerNotifications, true), + 'pt.up.fe.ni.uni.notificationworker': ( + NotificationManager.updateAndTriggerNotifications, + true + ), }; @pragma('vm:entry-point') @@ -25,16 +26,16 @@ Future workerStartCallback() async { // by the iOS scheduler. if (taskName == Workmanager.iOSBackgroundTask) { taskMap.forEach((key, value) async { - if (value.item2) { + if (value.$2) { Logger().d('''[$key]: Start executing job...'''); - await value.item1(); + await value.$1(); } }); return true; } // try to keep the usage of this function BELOW +-30 seconds // to not be punished by the scheduler in future runs. - await taskMap[taskName]!.item1(); + await taskMap[taskName]!.$1(); } catch (err, st) { Logger().e( 'Error while running $taskName job:', diff --git a/packages/uni_app/lib/controller/background_workers/notifications.dart b/packages/uni_app/lib/controller/background_workers/notifications.dart index 7d1a7a218..795a37904 100644 --- a/packages/uni_app/lib/controller/background_workers/notifications.dart +++ b/packages/uni_app/lib/controller/background_workers/notifications.dart @@ -6,7 +6,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/controller/background_workers/notifications/tuition_notification.dart'; import 'package:uni/controller/local_storage/notification_timeout_storage.dart'; import 'package:uni/controller/local_storage/preferences_controller.dart'; @@ -28,12 +27,12 @@ abstract class Notification { String uniqueID; Duration timeout; - Future> buildNotificationContent(Session session); + Future<(String, String)> buildNotificationContent(Session session); Future shouldDisplay(Session session); void displayNotification( - Tuple2 content, + (String, String) content, FlutterLocalNotificationsPlugin localNotificationsPlugin, ); diff --git a/packages/uni_app/lib/controller/background_workers/notifications/tuition_notification.dart b/packages/uni_app/lib/controller/background_workers/notifications/tuition_notification.dart index 076fe5dff..7cb4bdf6c 100644 --- a/packages/uni_app/lib/controller/background_workers/notifications/tuition_notification.dart +++ b/packages/uni_app/lib/controller/background_workers/notifications/tuition_notification.dart @@ -1,5 +1,4 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/controller/background_workers/notifications.dart'; import 'package:uni/controller/fetchers/fees_fetcher.dart'; import 'package:uni/controller/local_storage/preferences_controller.dart'; @@ -13,7 +12,7 @@ class TuitionNotification extends Notification { late DateTime _dueDate; @override - Future> buildNotificationContent( + Future<(String, String)> buildNotificationContent( Session session, ) async { // We must add one day because the time limit is actually at 23:59 and @@ -21,12 +20,12 @@ class TuitionNotification extends Notification { if (_dueDate.add(const Duration(days: 1)).isBefore(DateTime.now())) { final duration = DateTime.now().difference(_dueDate); if (duration.inDays == 0) { - return const Tuple2( + return const ( '⚠️ Ainda não pagaste as propinas ⚠️', 'O prazo para pagar as propinas acabou ontem', ); } - return Tuple2( + return ( '⚠️ Ainda não pagaste as propinas ⚠️', duration.toFormattedString( 'Já passou {} desde a data limite', @@ -36,12 +35,12 @@ class TuitionNotification extends Notification { } final duration = _dueDate.difference(DateTime.now()); if (duration.inDays == 0) { - return const Tuple2( + return const ( 'O prazo limite para as propinas está a acabar', 'Hoje acaba o prazo para pagamento das propinas!', ); } - return Tuple2( + return ( 'O prazo limite para as propinas está a acabar', duration.toFormattedString( 'Falta {} para a data limite', @@ -72,7 +71,7 @@ class TuitionNotification extends Notification { @override void displayNotification( - Tuple2 content, + (String, String) content, FlutterLocalNotificationsPlugin localNotificationsPlugin, ) { const androidNotificationDetails = AndroidNotificationDetails( @@ -95,8 +94,8 @@ class TuitionNotification extends Notification { localNotificationsPlugin.show( 2, - content.item1, - content.item2, + content.$1, + content.$2, notificationDetails, ); } diff --git a/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart b/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart index f0616bd13..610612dce 100644 --- a/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart +++ b/packages/uni_app/lib/controller/fetchers/library_occupation_fetcher.dart @@ -3,20 +3,19 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/model/entities/library_occupation.dart'; /// Fetch the library occupation from Google Sheets class LibraryOccupationFetcher { String baseUrl = 'https://webapi.affluences.com/api/fillRate?'; - static const List> floorMaxSeats = [ - Tuple2('BruV6IlujdwAe1', 72), - Tuple2('cEhyzJZvC5nHSr', 114), - Tuple2('iceVfgwZWaZRhV', 114), - Tuple2('1yLPz9X0CNsg27', 114), - Tuple2('keu1j5zERlQn90', 40), - Tuple2('bY7K1v43HiAq55', 90), + static const List<(String, int)> floorMaxSeats = [ + ('BruV6IlujdwAe1', 72), + ('cEhyzJZvC5nHSr', 114), + ('iceVfgwZWaZRhV', 114), + ('1yLPz9X0CNsg27', 114), + ('keu1j5zERlQn90', 40), + ('bY7K1v43HiAq55', 90), ]; Future getLibraryOccupation() async { @@ -26,19 +25,19 @@ class LibraryOccupationFetcher { floorMaxSeats.mapIndexed((i, entry) async { final url = Uri.parse(baseUrl).replace( queryParameters: { - 'token': entry.item1, + 'token': entry.$1, }, ); final response = await http.get(url); - final floorOccupation = - processFloorOccupation(response, entry.item2, i); + final floorOccupation = processFloorOccupation(response, entry.$2, i); libraryOccupation.addFloor(floorOccupation); }), ); + libraryOccupation.sortFloors(); return libraryOccupation; } diff --git a/packages/uni_app/lib/controller/local_storage/database/app_user_database.dart b/packages/uni_app/lib/controller/local_storage/database/app_user_database.dart index 51be2348b..0c25b204d 100644 --- a/packages/uni_app/lib/controller/local_storage/database/app_user_database.dart +++ b/packages/uni_app/lib/controller/local_storage/database/app_user_database.dart @@ -23,7 +23,7 @@ class AppUserDataDatabase extends AppDatabase { for (final keymap in data.keymapValues()) { await insertInDatabase( 'userdata', - {'name': keymap.item1, 'value': keymap.item2}, + {'name': keymap.$1, 'value': keymap.$2}, ); } } diff --git a/packages/uni_app/lib/controller/parsers/parser_restaurants.dart b/packages/uni_app/lib/controller/parsers/parser_restaurants.dart index a4c543f70..11d6be6d4 100644 --- a/packages/uni_app/lib/controller/parsers/parser_restaurants.dart +++ b/packages/uni_app/lib/controller/parsers/parser_restaurants.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:intl/intl.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/model/entities/meal.dart'; import 'package:uni/model/entities/restaurant.dart'; import 'package:uni/model/utils/day_of_week.dart'; @@ -18,7 +17,7 @@ List getRestaurantsFromHtml(Response response) { final restaurantsTuple = restaurantsHtml.map((restaurantHtml) { final name = restaurantHtml.text; final ref = restaurantHtml.attributes['href']?.replaceAll('#', ''); - return Tuple2(ref ?? '', name); + return (ref ?? '', name); }).toList(); // Get restaurant meals and create the Restaurant class @@ -26,7 +25,7 @@ List getRestaurantsFromHtml(Response response) { final meals = []; final referenceA = - document.querySelector('a[name="${restaurantTuple.item1}"]'); + document.querySelector('a[name="${restaurantTuple.$1}"]'); var next = referenceA?.nextElementSibling; final format = DateFormat('d-M-y'); @@ -70,8 +69,8 @@ List getRestaurantsFromHtml(Response response) { } return Restaurant( null, - restaurantTuple.item2, - restaurantTuple.item1, + restaurantTuple.$2, + restaurantTuple.$1, meals: meals, ); }).toList(); diff --git a/packages/uni_app/lib/main.dart b/packages/uni_app/lib/main.dart index 410b31835..8ab6fe912 100644 --- a/packages/uni_app/lib/main.dart +++ b/packages/uni_app/lib/main.dart @@ -31,7 +31,9 @@ import 'package:uni/model/providers/startup/profile_provider.dart'; import 'package:uni/model/providers/startup/session_provider.dart'; import 'package:uni/model/providers/state_providers.dart'; import 'package:uni/utils/navigation_items.dart'; +import 'package:uni/view/about/about.dart'; import 'package:uni/view/academic_path/academic_path.dart'; +import 'package:uni/view/bug_report/bug_report.dart'; import 'package:uni/view/bus_stop_next_arrivals/bus_stop_next_arrivals.dart'; import 'package:uni/view/calendar/calendar.dart'; import 'package:uni/view/common_widgets/page_transition.dart'; @@ -305,12 +307,24 @@ class ApplicationState extends State { settings: settings, ), '/${NavigationItem.navProfile.route}': - MaterialPageRoute( - builder: (__) => const ProfilePageView(), + PageTransition.makePageTransition( + page: const ProfilePageView(), + settings: settings, ), '/${NavigationItem.navSettings.route}': - MaterialPageRoute( - builder: (_) => const SettingsPage(), + PageTransition.makePageTransition( + page: const SettingsPage(), + settings: settings, + ), + '/${NavigationItem.navBugreport.route}': + PageTransition.makePageTransition( + page: const BugReportPageView(), + settings: settings, + ), + '/${NavigationItem.navAboutus.route}': + PageTransition.makePageTransition( + page: const AboutPageView(), + settings: settings, ), }; return transitions[settings.name]; diff --git a/packages/uni_app/lib/model/converters/tuple_converter.dart b/packages/uni_app/lib/model/converters/tuple_converter.dart index 49cb64ded..2c1b6ca69 100644 --- a/packages/uni_app/lib/model/converters/tuple_converter.dart +++ b/packages/uni_app/lib/model/converters/tuple_converter.dart @@ -1,22 +1,21 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:tuple/tuple.dart'; -class TupleConverter extends JsonConverter?, String?> { +class TupleConverter extends JsonConverter<(String, String)?, String?> { const TupleConverter(); @override - Tuple2? fromJson(String? json) { + (String, String)? fromJson(String? json) { if (json == null) { return null; } - return Tuple2('', json); + return ('', json); } @override - String? toJson(Tuple2? object) { + String? toJson((String, String)? object) { if (object == null) { return null; } - return object.item2; + return object.$2; } } diff --git a/packages/uni_app/lib/model/entities/bug_report.dart b/packages/uni_app/lib/model/entities/bug_report.dart index 8667e4945..e2f5276a8 100644 --- a/packages/uni_app/lib/model/entities/bug_report.dart +++ b/packages/uni_app/lib/model/entities/bug_report.dart @@ -1,6 +1,5 @@ // Stores information about Bug Report import 'package:json_annotation/json_annotation.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/model/converters/tuple_converter.dart'; part '../../generated/model/entities/bug_report.g.dart'; @@ -16,7 +15,7 @@ class BugReport { final String title; final String text; final String email; - final Tuple2? bugLabel; + final (String, String)? bugLabel; final List faculties; Map toJson() => _$BugReportToJson(this); diff --git a/packages/uni_app/lib/model/entities/library_occupation.dart b/packages/uni_app/lib/model/entities/library_occupation.dart index 9931652df..cdf692efd 100644 --- a/packages/uni_app/lib/model/entities/library_occupation.dart +++ b/packages/uni_app/lib/model/entities/library_occupation.dart @@ -37,6 +37,10 @@ class LibraryOccupation { return floors[number - 1]; } + void sortFloors() { + floors.sort((a, b) => a.number.compareTo(b.number)); + } + Map toJson() => _$LibraryOccupationToJson(this); } diff --git a/packages/uni_app/lib/model/entities/login_exceptions.dart b/packages/uni_app/lib/model/entities/login_exceptions.dart deleted file mode 100644 index d701801d4..000000000 --- a/packages/uni_app/lib/model/entities/login_exceptions.dart +++ /dev/null @@ -1,11 +0,0 @@ -class ExpiredCredentialsException implements Exception { - ExpiredCredentialsException(); -} - -class InternetStatusException implements Exception { - InternetStatusException(); -} - -class WrongCredentialsException implements Exception { - WrongCredentialsException(); -} diff --git a/packages/uni_app/lib/model/entities/profile.dart b/packages/uni_app/lib/model/entities/profile.dart index 125bf6fc8..29f487d11 100644 --- a/packages/uni_app/lib/model/entities/profile.dart +++ b/packages/uni_app/lib/model/entities/profile.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/model/entities/course.dart'; import 'package:uni/model/entities/course_units/course_unit.dart'; @@ -43,13 +42,13 @@ class Profile { /// Returns a list with two tuples: the first tuple contains the user's name /// and the other one contains the user's email. - List> keymapValues() { + List<(String, String)> keymapValues() { return [ - Tuple2('name', name), - Tuple2('email', email), - Tuple2('printBalance', printBalance), - Tuple2('feesBalance', feesBalance), - Tuple2( + ('name', name), + ('email', email), + ('printBalance', printBalance), + ('feesBalance', feesBalance), + ( 'feesLimit', feesLimit != null ? feesLimit!.toIso8601String() : '', ), diff --git a/packages/uni_app/lib/model/providers/lazy/course_units_info_provider.dart b/packages/uni_app/lib/model/providers/lazy/course_units_info_provider.dart index 37665631a..eb4d7ea92 100644 --- a/packages/uni_app/lib/model/providers/lazy/course_units_info_provider.dart +++ b/packages/uni_app/lib/model/providers/lazy/course_units_info_provider.dart @@ -1,6 +1,5 @@ import 'dart:collection'; -import 'package:tuple/tuple.dart'; import 'package:uni/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart'; import 'package:uni/model/entities/course_units/course_unit.dart'; import 'package:uni/model/entities/course_units/course_unit_class.dart'; @@ -15,24 +14,24 @@ typedef ClassesMap = Map>; typedef FilesMap = Map>; class CourseUnitsInfoProvider - extends StateProviderNotifier> { + extends StateProviderNotifier<(SheetsMap, ClassesMap, FilesMap)> { CourseUnitsInfoProvider() : super( cacheDuration: null, // Const constructor is not allowed here because of the // need for mutable maps // ignore: prefer_const_constructors - initialState: Tuple3({}, {}, {}), + initialState: ({}, {}, {}), ); UnmodifiableMapView get courseUnitsSheets => - UnmodifiableMapView(state!.item1); + UnmodifiableMapView(state!.$1); UnmodifiableMapView> - get courseUnitsClasses => UnmodifiableMapView(state!.item2); + get courseUnitsClasses => UnmodifiableMapView(state!.$2); UnmodifiableMapView> - get courseUnitsFiles => UnmodifiableMapView(state!.item3); + get courseUnitsFiles => UnmodifiableMapView(state!.$3); Future fetchCourseUnitSheet( CourseUnit courseUnit, @@ -43,7 +42,7 @@ class CourseUnitsInfoProvider return; } - state!.item1[courseUnit] = + state!.$1[courseUnit] = await CourseUnitsInfoFetcher().fetchSheet(session, occurrId); } @@ -56,7 +55,7 @@ class CourseUnitsInfoProvider return; } - state!.item2[courseUnit] = await CourseUnitsInfoFetcher() + state!.$2[courseUnit] = await CourseUnitsInfoFetcher() .fetchCourseUnitClasses(session, occurrId); notifyListeners(); } @@ -70,22 +69,30 @@ class CourseUnitsInfoProvider return; } - state!.item3[courseUnit] = + state!.$3[courseUnit] = await CourseUnitsInfoFetcher().fetchCourseUnitFiles(session, occurrId); notifyListeners(); } @override - Future> loadFromRemote( + Future<(SheetsMap, ClassesMap, FilesMap)> loadFromRemote( StateProviders stateProviders, ) async { - return const Tuple3({}, {}, {}); + return ( + {}, + >{}, + >{} + ); } @override - Future> loadFromStorage( + Future<(SheetsMap, ClassesMap, FilesMap)> loadFromStorage( StateProviders stateProviders, ) async { - return const Tuple3({}, {}, {}); + return ( + {}, + >{}, + >{} + ); } } diff --git a/packages/uni_app/lib/model/providers/startup/profile_provider.dart b/packages/uni_app/lib/model/providers/startup/profile_provider.dart index ad4ef1b02..2266aa581 100644 --- a/packages/uni_app/lib/model/providers/startup/profile_provider.dart +++ b/packages/uni_app/lib/model/providers/startup/profile_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:tuple/tuple.dart'; import 'package:uni/controller/fetchers/course_units_fetcher/all_course_units_fetcher.dart'; import 'package:uni/controller/fetchers/course_units_fetcher/current_course_units_fetcher.dart'; import 'package:uni/controller/fetchers/fees_fetcher.dart'; @@ -60,12 +59,12 @@ class ProfileProvider extends StateProviderNotifier { ]); final profile = futures[0] as Profile?; final courseUnits = futures[1] as List?; - final userBalanceAndFeesLimit = futures[2]! as Tuple2; + final userBalanceAndFeesLimit = futures[2]! as (String, DateTime?); final printBalance = futures[3]! as String; profile! - ..feesBalance = userBalanceAndFeesLimit.item1 - ..feesLimit = userBalanceAndFeesLimit.item2 + ..feesBalance = userBalanceAndFeesLimit.$1 + ..feesLimit = userBalanceAndFeesLimit.$2 ..printBalance = printBalance; if (courseUnits != null) { @@ -93,7 +92,7 @@ class ProfileProvider extends StateProviderNotifier { return db.courseUnits(); } - Future> fetchUserFeesBalanceAndLimit( + Future<(String, DateTime?)> fetchUserFeesBalanceAndLimit( Session session, ) async { final response = await FeesFetcher().getUserFeesResponse(session); @@ -101,7 +100,7 @@ class ProfileProvider extends StateProviderNotifier { final feesBalance = parseFeesBalance(response); final feesLimit = parseFeesNextLimit(response); - return Tuple2(feesBalance, feesLimit); + return (feesBalance, feesLimit); } Future fetchUserPrintBalance(Session session) async { diff --git a/packages/uni_app/lib/model/providers/state_provider_notifier.dart b/packages/uni_app/lib/model/providers/state_provider_notifier.dart index 4043391d6..ab2cfade4 100644 --- a/packages/uni_app/lib/model/providers/state_provider_notifier.dart +++ b/packages/uni_app/lib/model/providers/state_provider_notifier.dart @@ -168,9 +168,9 @@ abstract class StateProviderNotifier extends ChangeNotifier { if (!context.mounted || _state != null) { return; } - await _loadFromStorage(context).then((value) { + await _loadFromStorage(context).then((value) async { if (context.mounted) { - _loadFromRemoteFromContext(context); + await _loadFromRemoteFromContext(context); } }); }, diff --git a/packages/uni_app/lib/session/exception.dart b/packages/uni_app/lib/session/exception.dart index 712ceae75..e1fb9232f 100644 --- a/packages/uni_app/lib/session/exception.dart +++ b/packages/uni_app/lib/session/exception.dart @@ -1,4 +1,5 @@ enum AuthenticationExceptionType { + internetError, wrongCredentials, expiredCredentials, other, diff --git a/packages/uni_app/lib/session/flows/credentials/request.dart b/packages/uni_app/lib/session/flows/credentials/request.dart index 727935b29..f5b0edbd7 100644 --- a/packages/uni_app/lib/session/flows/credentials/request.dart +++ b/packages/uni_app/lib/session/flows/credentials/request.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:http/http.dart' as http; import 'package:uni/controller/fetchers/faculties_fetcher.dart'; import 'package:uni/session/exception.dart'; @@ -35,11 +36,20 @@ class CredentialsSessionRequest extends SessionRequest { 'Failed to authenticate user', AuthenticationExceptionType.expiredCredentials, ); - } else { + } else if (failureReason == LoginFailureReason.internetError) { + throw const AuthenticationException( + 'Failed to authenticate user', + AuthenticationExceptionType.internetError, + ); + } else if (failureReason == LoginFailureReason.wrongCredentials) { throw const AuthenticationException( 'Failed to authenticate user', AuthenticationExceptionType.wrongCredentials, ); + } else { + throw const AuthenticationException( + 'Failed to authenticate user', + ); } } @@ -63,11 +73,16 @@ class CredentialsSessionRequest extends SessionRequest { final api = SigarraApi(); const tempFaculty = 'feup'; - final loginResponse = await api.authentication.login.call( - username: username, - password: password, - options: FacultyRequestOptions(faculty: tempFaculty, client: httpClient), - ); + final loginResponse = await api.authentication + .login( + username: username, + password: password, + options: FacultyRequestOptions( + faculty: tempFaculty, + client: httpClient, + ), + ) + .call(); if (!loginResponse.success) { return null; @@ -88,11 +103,13 @@ class CredentialsSessionRequest extends SessionRequest { http.Client httpClient, ) async { final html = SigarraHtml(); - final response = await html.authentication.login.call( - username: username, - password: password, - options: FacultyRequestOptions(client: httpClient), - ); + final response = await html.authentication + .login( + username: username, + password: password, + options: FacultyRequestOptions(client: httpClient), + ) + .call(); final error = response.asFailed(); return error.reason; diff --git a/packages/uni_app/lib/session/flows/federated/client.dart b/packages/uni_app/lib/session/flows/federated/client.dart new file mode 100644 index 000000000..3079210b1 --- /dev/null +++ b/packages/uni_app/lib/session/flows/federated/client.dart @@ -0,0 +1,17 @@ +import 'package:http/http.dart' as http; +import 'package:uni/http/client/timeout.dart'; + +class FederatedDefaultClient extends http.BaseClient { + FederatedDefaultClient() + : inner = TimeoutClient( + http.Client(), + timeout: const Duration(seconds: 5), + ); + + final http.Client inner; + + @override + Future send(http.BaseRequest request) { + return inner.send(request); + } +} diff --git a/packages/uni_app/lib/session/flows/federated/initiator.dart b/packages/uni_app/lib/session/flows/federated/initiator.dart index 1e7387a0d..11896d702 100644 --- a/packages/uni_app/lib/session/flows/federated/initiator.dart +++ b/packages/uni_app/lib/session/flows/federated/initiator.dart @@ -1,6 +1,11 @@ +import 'dart:async'; + import 'package:http/http.dart' as http; import 'package:openid_client/openid_client.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uni/session/exception.dart'; import 'package:uni/session/flows/base/initiator.dart'; +import 'package:uni/session/flows/federated/client.dart'; import 'package:uni/session/flows/federated/request.dart'; class FederatedSessionInitiator extends SessionInitiator { @@ -14,11 +19,30 @@ class FederatedSessionInitiator extends SessionInitiator { final String clientId; final Future Function(Flow flow) performAuthentication; + Future _handleOpenIdExceptions( + Future future, { + required AuthenticationException onError, + }) { + T reportExceptionAndFail(E error, StackTrace st) { + unawaited(Sentry.captureException(error, stackTrace: st)); + throw onError; + } + + return future + .onError(reportExceptionAndFail) + .onError(reportExceptionAndFail); + } + @override Future initiate([http.Client? httpClient]) async { - final issuer = await Issuer.discover(realm); - final client = Client(issuer, clientId, httpClient: httpClient); + httpClient ??= FederatedDefaultClient(); + final issuer = await _handleOpenIdExceptions( + Issuer.discover(realm, httpClient: httpClient), + onError: const AuthenticationException('Failed to discover OIDC issuer'), + ); + + final client = Client(issuer, clientId, httpClient: httpClient); final flow = Flow.authorizationCodeWithPKCE( client, scopes: [ @@ -32,7 +56,10 @@ class FederatedSessionInitiator extends SessionInitiator { ); final uri = await performAuthentication(flow); - final credential = await flow.callback(uri.queryParameters); + final credential = await _handleOpenIdExceptions( + flow.callback(uri.queryParameters), + onError: const AuthenticationException('Failed to execute flow callback'), + ); return FederatedSessionRequest(credential: credential); } diff --git a/packages/uni_app/lib/session/flows/federated/request.dart b/packages/uni_app/lib/session/flows/federated/request.dart index 99728aa55..f7478b4ac 100644 --- a/packages/uni_app/lib/session/flows/federated/request.dart +++ b/packages/uni_app/lib/session/flows/federated/request.dart @@ -1,10 +1,15 @@ +import 'dart:async'; + import 'package:http/http.dart' as http; import 'package:openid_client/openid_client.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:uni/controller/fetchers/faculties_fetcher.dart'; import 'package:uni/session/exception.dart'; import 'package:uni/session/flows/base/request.dart'; +import 'package:uni/session/flows/federated/client.dart'; import 'package:uni/session/flows/federated/session.dart'; import 'package:uni/sigarra/endpoints/oidc/oidc.dart'; +import 'package:uni/sigarra/endpoints/oidc/token/response.dart'; import 'package:uni/sigarra/options.dart'; class FederatedSessionUserInfo { @@ -38,18 +43,26 @@ class FederatedSessionRequest extends SessionRequest { final Credential credential; + TokenFailedResponse _reportExceptionAndFail( + E error, + StackTrace st, + ) { + unawaited(Sentry.captureException(error, stackTrace: st)); + return const TokenFailedResponse(); + } + @override Future perform([http.Client? httpClient]) async { - final client = httpClient ?? http.Client(); + httpClient ??= FederatedDefaultClient(); - final authorizedClient = credential.createHttpClient(client); + final authorizedClient = credential.createHttpClient(httpClient); final oidc = SigarraOidc(); - final response = await oidc.token.call( - options: BaseRequestOptions( - client: authorizedClient, - ), - ); + final response = await oidc + .token(options: SigarraRequestOptions(client: authorizedClient)) + .call() + .onError(_reportExceptionAndFail) + .onError(_reportExceptionAndFail); if (!response.success) { throw const AuthenticationException('Failed to get OIDC token'); @@ -65,7 +78,7 @@ class FederatedSessionRequest extends SessionRequest { credential: credential, ); - final faculties = await getStudentFaculties(tempSession, client); + final faculties = await getStudentFaculties(tempSession, httpClient); return FederatedSession( username: userInfo.username, diff --git a/packages/uni_app/lib/sigarra/endpoint.dart b/packages/uni_app/lib/sigarra/endpoint.dart new file mode 100644 index 000000000..5f082748e --- /dev/null +++ b/packages/uni_app/lib/sigarra/endpoint.dart @@ -0,0 +1,7 @@ +import 'package:uni/sigarra/response.dart'; + +abstract class Endpoint { + const Endpoint(); + + Future call(); +} diff --git a/packages/uni_app/lib/sigarra/endpoints/api/authentication/authentication.dart b/packages/uni_app/lib/sigarra/endpoints/api/authentication/authentication.dart index c112747a5..422710365 100644 --- a/packages/uni_app/lib/sigarra/endpoints/api/authentication/authentication.dart +++ b/packages/uni_app/lib/sigarra/endpoints/api/authentication/authentication.dart @@ -1,7 +1,5 @@ import 'package:uni/sigarra/endpoints/api/authentication/login/login.dart'; -import 'package:uni/utils/lazy.dart'; class SigarraApiAuthentication { - final _login = Lazy(() => const Login()); - Login get login => _login.value; + final login = Login.new; } diff --git a/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/login.dart b/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/login.dart index a8e179203..0fbef6516 100644 --- a/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/login.dart +++ b/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/login.dart @@ -2,19 +2,25 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:uni/http/utils.dart'; +import 'package:uni/sigarra/endpoint.dart'; import 'package:uni/sigarra/endpoints/api/authentication/login/json.dart'; import 'package:uni/sigarra/endpoints/api/authentication/login/response.dart'; import 'package:uni/sigarra/options.dart'; -class Login { - const Login(); +class Login extends Endpoint { + const Login({ + required this.username, + required this.password, + this.options, + }); - Future call({ - required String username, - required String password, - required FacultyRequestOptions? options, - }) async { - options = options ?? FacultyRequestOptions(); + final String username; + final String password; + final FacultyRequestOptions? options; + + @override + Future call() async { + final options = this.options ?? FacultyRequestOptions(); final loginUrl = options.baseUrl.resolve('mob_val_geral.autentica'); diff --git a/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/response.dart b/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/response.dart index d91d38d63..74b945345 100644 --- a/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/response.dart +++ b/packages/uni_app/lib/sigarra/endpoints/api/authentication/login/response.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:uni/sigarra/response.dart'; -abstract class LoginResponse extends SigarraResponse { +abstract class LoginResponse extends EndpointResponse { const LoginResponse({required super.success}); LoginSuccessfulResponse asSuccessful() => this as LoginSuccessfulResponse; diff --git a/packages/uni_app/lib/sigarra/endpoints/html/authentication/authentication.dart b/packages/uni_app/lib/sigarra/endpoints/html/authentication/authentication.dart index 6fe7810fa..797baea13 100644 --- a/packages/uni_app/lib/sigarra/endpoints/html/authentication/authentication.dart +++ b/packages/uni_app/lib/sigarra/endpoints/html/authentication/authentication.dart @@ -1,11 +1,7 @@ import 'package:uni/sigarra/endpoints/html/authentication/login/login.dart'; import 'package:uni/sigarra/endpoints/html/authentication/logout/logout.dart'; -import 'package:uni/utils/lazy.dart'; class SigarraHtmlAuthentication { - final _logout = Lazy(() => const Logout()); - Logout get logout => _logout.value; - - final _login = Lazy(() => const Login()); - Login get login => _login.value; + final logout = Logout.new; + final login = Login.new; } diff --git a/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/login.dart b/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/login.dart index a2760a4e2..728a125e9 100644 --- a/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/login.dart +++ b/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/login.dart @@ -4,18 +4,24 @@ import 'package:html/dom.dart' as html; import 'package:html/parser.dart' as html_parser; import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uni/sigarra/endpoint.dart'; import 'package:uni/sigarra/endpoints/html/authentication/login/response.dart'; import 'package:uni/sigarra/options.dart'; -class Login { - const Login(); +class Login extends Endpoint { + const Login({ + required this.username, + required this.password, + this.options, + }); - Future call({ - required String username, - required String password, - FacultyRequestOptions? options, - }) async { - options = options ?? FacultyRequestOptions(); + final String username; + final String password; + final FacultyRequestOptions? options; + + @override + Future call() async { + final options = this.options ?? FacultyRequestOptions(); final loginUrl = options.baseUrl.resolve('vld_validacao.validacao'); diff --git a/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/response.dart b/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/response.dart index 4399da48f..1a3562b2f 100644 --- a/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/response.dart +++ b/packages/uni_app/lib/sigarra/endpoints/html/authentication/login/response.dart @@ -1,6 +1,6 @@ import 'package:uni/sigarra/response.dart'; -class LoginResponse extends SigarraResponse { +class LoginResponse extends EndpointResponse { const LoginResponse({required super.success}); LoginFailedResponse asFailed() => this as LoginFailedResponse; @@ -8,6 +8,7 @@ class LoginResponse extends SigarraResponse { enum LoginFailureReason { serverError, + internetError, wrongCredentials, expiredCredentials, blockedAccount, diff --git a/packages/uni_app/lib/sigarra/endpoints/html/authentication/logout/logout.dart b/packages/uni_app/lib/sigarra/endpoints/html/authentication/logout/logout.dart index f52fa1039..94237f82c 100644 --- a/packages/uni_app/lib/sigarra/endpoints/html/authentication/logout/logout.dart +++ b/packages/uni_app/lib/sigarra/endpoints/html/authentication/logout/logout.dart @@ -1,17 +1,21 @@ +import 'package:uni/sigarra/endpoint.dart'; import 'package:uni/sigarra/options.dart'; import 'package:uni/sigarra/response.dart'; -class Logout { - const Logout(); +class Logout extends Endpoint { + const Logout({ + this.options, + }); - Future call({ - FacultyRequestOptions? options, - }) async { - options = options ?? FacultyRequestOptions(); + final FacultyRequestOptions? options; + + @override + Future call() async { + final options = this.options ?? FacultyRequestOptions(); final logoutUrl = options.baseUrl.resolve('vld_validacao.sair'); final response = await options.client.get(logoutUrl); - return SigarraResponse(success: response.statusCode == 200); + return EndpointResponse(success: response.statusCode == 200); } } diff --git a/packages/uni_app/lib/sigarra/endpoints/oidc/oidc.dart b/packages/uni_app/lib/sigarra/endpoints/oidc/oidc.dart index 82201ffe3..c60fd65df 100644 --- a/packages/uni_app/lib/sigarra/endpoints/oidc/oidc.dart +++ b/packages/uni_app/lib/sigarra/endpoints/oidc/oidc.dart @@ -1,7 +1,5 @@ import 'package:uni/sigarra/endpoints/oidc/token/token.dart'; -import 'package:uni/utils/lazy.dart'; class SigarraOidc { - final _token = Lazy(Token.new); - Token get token => _token.value; + final token = Token.new; } diff --git a/packages/uni_app/lib/sigarra/endpoints/oidc/token/response.dart b/packages/uni_app/lib/sigarra/endpoints/oidc/token/response.dart index b549e321d..0edcfdf50 100644 --- a/packages/uni_app/lib/sigarra/endpoints/oidc/token/response.dart +++ b/packages/uni_app/lib/sigarra/endpoints/oidc/token/response.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:uni/sigarra/response.dart'; -class TokenResponse extends SigarraResponse { +class TokenResponse extends EndpointResponse { const TokenResponse({required super.success}); TokenSuccessfulResponse asSuccessful() => this as TokenSuccessfulResponse; diff --git a/packages/uni_app/lib/sigarra/endpoints/oidc/token/token.dart b/packages/uni_app/lib/sigarra/endpoints/oidc/token/token.dart index 309dd1de2..500efb41d 100644 --- a/packages/uni_app/lib/sigarra/endpoints/oidc/token/token.dart +++ b/packages/uni_app/lib/sigarra/endpoints/oidc/token/token.dart @@ -1,17 +1,19 @@ import 'package:uni/http/utils.dart'; +import 'package:uni/sigarra/endpoint.dart'; import 'package:uni/sigarra/endpoints/oidc/token/response.dart'; import 'package:uni/sigarra/options.dart'; -class Token { - const Token(); +class Token extends Endpoint { + const Token({this.options}); + + final SigarraRequestOptions? options; /// Returns the cookies for SIGARRA using the OIDC token. /// /// The client must be authorized to make this request. - Future call({ - BaseRequestOptions? options, - }) async { - options = options ?? BaseRequestOptions(); + @override + Future call() async { + final options = this.options ?? SigarraRequestOptions(); final tokenUrl = options.baseUrl.resolve('auth/oidc/token'); final response = await options.client.get( diff --git a/packages/uni_app/lib/sigarra/options.dart b/packages/uni_app/lib/sigarra/options.dart index 1e6f063ef..75e8e94d2 100644 --- a/packages/uni_app/lib/sigarra/options.dart +++ b/packages/uni_app/lib/sigarra/options.dart @@ -1,22 +1,34 @@ import 'package:http/http.dart' as http; -class BaseRequestOptions { +abstract class BaseRequestOptions { BaseRequestOptions({http.Client? client}) : client = client ?? http.Client(); final http.Client client; - Uri get baseUrl => Uri(scheme: 'https', host: 'sigarra.up.pt'); + Uri get baseUrl; BaseRequestOptions copyWith({ http.Client? client, + }); +} + +class SigarraRequestOptions extends BaseRequestOptions { + SigarraRequestOptions({super.client}); + + @override + Uri get baseUrl => Uri(scheme: 'https', host: 'sigarra.up.pt'); + + @override + SigarraRequestOptions copyWith({ + http.Client? client, }) { - return BaseRequestOptions( + return SigarraRequestOptions( client: client ?? this.client, ); } } -class FacultyRequestOptions extends BaseRequestOptions { +class FacultyRequestOptions extends SigarraRequestOptions { FacultyRequestOptions({ this.faculty = 'up', this.language = 'pt', diff --git a/packages/uni_app/lib/sigarra/response.dart b/packages/uni_app/lib/sigarra/response.dart index cfe91f221..aa7109021 100644 --- a/packages/uni_app/lib/sigarra/response.dart +++ b/packages/uni_app/lib/sigarra/response.dart @@ -1,5 +1,5 @@ -class SigarraResponse { - const SigarraResponse({required this.success}); +class EndpointResponse { + const EndpointResponse({required this.success}); final bool success; } diff --git a/packages/uni_app/lib/utils/navigation_items.dart b/packages/uni_app/lib/utils/navigation_items.dart index 2cc31d02c..a31ff7b55 100644 --- a/packages/uni_app/lib/utils/navigation_items.dart +++ b/packages/uni_app/lib/utils/navigation_items.dart @@ -13,7 +13,9 @@ enum NavigationItem { navProfile('perfil'), navSettings('definicoes'), navTransports('transportes'), - navLogin('login'); + navLogin('login'), + navBugreport('bug_report'), + navAboutus('sobre_nos'); const NavigationItem(this.route, {this.faculties}); diff --git a/packages/uni_app/lib/view/bug_report/widgets/form.dart b/packages/uni_app/lib/view/bug_report/widgets/form.dart index 7f9257e61..82b3c87e8 100644 --- a/packages/uni_app/lib/view/bug_report/widgets/form.dart +++ b/packages/uni_app/lib/view/bug_report/widgets/form.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:tuple/tuple.dart'; import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/app_locale.dart'; import 'package:uni/model/entities/bug_report.dart'; @@ -32,15 +31,15 @@ class BugReportFormState extends State { static final _formKey = GlobalKey(); - final Map> bugDescriptions = { - 0: const Tuple2('Detalhe visual', 'Visual detail'), - 1: const Tuple2('Erro', 'Error'), - 2: const Tuple2('Sugestão de funcionalidade', 'Suggestion'), - 3: const Tuple2( + final Map bugDescriptions = { + 0: const ('Detalhe visual', 'Visual detail'), + 1: const ('Erro', 'Error'), + 2: const ('Sugestão de funcionalidade', 'Suggestion'), + 3: const ( 'Comportamento inesperado', 'Unexpected behaviour', ), - 4: const Tuple2('Outro', 'Other'), + 4: ('Outro', 'Other'), }; List> bugList = []; @@ -65,9 +64,9 @@ class BugReportFormState extends State { () { switch (locale) { case AppLocale.pt: - return entry.value.item1; + return entry.value.$1; case AppLocale.en: - return entry.value.item2; + return entry.value.$2; } }(), ), @@ -321,4 +320,12 @@ class BugReportFormState extends State { _isConsentGiven = false; }); } + + @override + void dispose() { + titleController.dispose(); + descriptionController.dispose(); + emailController.dispose(); + super.dispose(); + } } diff --git a/packages/uni_app/lib/view/common_widgets/last_update_timestamp.dart b/packages/uni_app/lib/view/common_widgets/last_update_timestamp.dart index 403fb1e19..56e9b8789 100644 --- a/packages/uni_app/lib/view/common_widgets/last_update_timestamp.dart +++ b/packages/uni_app/lib/view/common_widgets/last_update_timestamp.dart @@ -18,11 +18,11 @@ class LastUpdateTimeStamp> class _LastUpdateTimeStampState> extends State { DateTime currentTime = DateTime.now(); - + Timer? timer; @override void initState() { super.initState(); - Timer.periodic( + timer = Timer.periodic( const Duration(seconds: 60), (timer) { if (mounted) { @@ -34,6 +34,12 @@ class _LastUpdateTimeStampState> ); } + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Consumer( diff --git a/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart b/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart index 5454f6e3e..da241f604 100644 --- a/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart +++ b/packages/uni_app/lib/view/home/widgets/exit_app_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:uni/generated/l10n.dart'; /// Manages the app section displayed when the user presses the back button @@ -34,9 +35,9 @@ class BackButtonExitWrapper extends StatelessWidget { child: Text(S.of(context).no), ), ElevatedButton( - onPressed: () { + onPressed: () async { userActionCompleter.complete(true); - Navigator.of(context).pop(false); + await SystemNavigator.pop(); }, child: Text(S.of(context).yes), ), diff --git a/packages/uni_app/lib/view/home/widgets/schedule_card.dart b/packages/uni_app/lib/view/home/widgets/schedule_card.dart index 411051471..32edc2752 100644 --- a/packages/uni_app/lib/view/home/widgets/schedule_card.dart +++ b/packages/uni_app/lib/view/home/widgets/schedule_card.dart @@ -49,6 +49,9 @@ class ScheduleCard extends GenericCard { ), ), contentLoadingWidget: const ScheduleCardShimmer().build(context), + mapper: (lectures) => lectures + .where((lecture) => lecture.endTime.isAfter(DateTime.now())) + .toList(), ); } @@ -71,16 +74,8 @@ class ScheduleCard extends GenericCard { for (final dayLectures in lecturesByDay.sublist(0, min(2, lecturesByDay.length))) { final day = dayLectures.key; - final lectures = dayLectures.value - .where( - (element) => - // Hide finished lectures from today - element.startTime.weekday != DateTime.now().weekday || - element.endTime.isAfter(DateTime.now()), - ) - .toList(); - - if (lectures.isEmpty) { + + if (dayLectures.value.isEmpty) { continue; } @@ -91,11 +86,11 @@ class ScheduleCard extends GenericCard { ), ); - for (final lecture in lectures) { + for (final lecture in dayLectures.value) { rows.add(createRowFromLecture(context, lecture)); } - if (lectures.length >= 2) { + if (dayLectures.value.length >= 2) { break; } } diff --git a/packages/uni_app/lib/view/lazy_consumer.dart b/packages/uni_app/lib/view/lazy_consumer.dart index 49e9f39b6..795e249e2 100644 --- a/packages/uni_app/lib/view/lazy_consumer.dart +++ b/packages/uni_app/lib/view/lazy_consumer.dart @@ -28,6 +28,7 @@ class LazyConsumer, T2> required this.hasContent, required this.onNullContent, this.contentLoadingWidget, + this.mapper, super.key, }); @@ -35,6 +36,9 @@ class LazyConsumer, T2> final bool Function(T2) hasContent; final Widget onNullContent; final Widget? contentLoadingWidget; + final T2 Function(T2)? mapper; + + static T2 _defaultMapper(T2 value) => value; @override Widget build(BuildContext context) { @@ -89,8 +93,11 @@ class LazyConsumer, T2> } Widget requestDependantWidget(BuildContext context, T1 provider) { - final showContent = - provider.state != null && hasContent(provider.state as T2); + final mappedState = provider.state != null + ? (mapper ?? _defaultMapper)(provider.state as T2) + : null; + + final showContent = provider.state != null && hasContent(mappedState as T2); if (provider.requestStatus == RequestStatus.busy && !showContent) { return loadingWidget(context); @@ -99,7 +106,7 @@ class LazyConsumer, T2> } return showContent - ? builder(context, provider.state as T2) + ? builder(context, mappedState) : Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), diff --git a/packages/uni_app/lib/view/locations/locations.dart b/packages/uni_app/lib/view/locations/locations.dart index 0d8dd55b7..8051e5d1d 100644 --- a/packages/uni_app/lib/view/locations/locations.dart +++ b/packages/uni_app/lib/view/locations/locations.dart @@ -39,6 +39,12 @@ class LocationsPageState extends SecondaryPageViewState @override String? getTitle() => S.of(context).nav_title(NavigationItem.navLocations.route); + + @override + void dispose() { + scrollViewController?.dispose(); + super.dispose(); + } } class LocationsPageView extends StatefulWidget { diff --git a/packages/uni_app/lib/view/login/login.dart b/packages/uni_app/lib/view/login/login.dart index cab5cfcb2..9c1cbb45a 100644 --- a/packages/uni_app/lib/view/login/login.dart +++ b/packages/uni_app/lib/view/login/login.dart @@ -9,9 +9,9 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:uni/app_links/uni_app_links.dart'; import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; -import 'package:uni/model/entities/login_exceptions.dart'; import 'package:uni/model/providers/startup/session_provider.dart'; import 'package:uni/model/providers/state_providers.dart'; +import 'package:uni/session/exception.dart'; import 'package:uni/session/flows/credentials/initiator.dart'; import 'package:uni/session/flows/federated/initiator.dart'; import 'package:uni/utils/constants.dart'; @@ -99,36 +99,51 @@ class LoginPageViewState extends State _loggingIn = false; }); } - } catch (err, st) { + } on AuthenticationException catch (err, st) { setState(() { _loggingIn = false; }); - if (err is ExpiredCredentialsException) { - _updatePasswordDialog(); - } else if (err is InternetStatusException) { - if (mounted) { - unawaited( - ToastMessage.warning( - context, - S.of(context).internet_status_exception, - ), - ); - } - } else if (err is WrongCredentialsException) { - if (mounted) { - unawaited( - ToastMessage.error( - context, - S.of(context).wrong_credentials_exception, - ), - ); - } - } else { - Logger().e(err, stackTrace: st); - unawaited(Sentry.captureException(err, stackTrace: st)); - if (mounted) { - unawaited(ToastMessage.error(context, S.of(context).failed_login)); - } + + switch (err.type) { + case AuthenticationExceptionType.expiredCredentials: + _updatePasswordDialog(); + case AuthenticationExceptionType.internetError: + if (mounted) { + unawaited( + ToastMessage.warning( + context, + S.of(context).internet_status_exception, + ), + ); + } + case AuthenticationExceptionType.wrongCredentials: + if (mounted) { + unawaited( + ToastMessage.error( + context, + S.of(context).wrong_credentials_exception, + ), + ); + } + default: + Logger().e(err, stackTrace: st); + unawaited(Sentry.captureException(err, stackTrace: st)); + if (mounted) { + unawaited( + ToastMessage.error(context, S.of(context).failed_login), + ); + } + } + } + // Handles other unexpected exceptions + catch (err, st) { + setState(() { + _loggingIn = false; + }); + Logger().e(err, stackTrace: st); + unawaited(Sentry.captureException(err, stackTrace: st)); + if (mounted) { + unawaited(ToastMessage.error(context, S.of(context).failed_login)); } } } diff --git a/packages/uni_app/lib/view/restaurant/restaurant_page_view.dart b/packages/uni_app/lib/view/restaurant/restaurant_page_view.dart index f78a06d07..edd58ac12 100644 --- a/packages/uni_app/lib/view/restaurant/restaurant_page_view.dart +++ b/packages/uni_app/lib/view/restaurant/restaurant_page_view.dart @@ -29,12 +29,18 @@ class _RestaurantPageViewState extends GeneralPageViewState void initState() { super.initState(); final weekDay = DateTime.now().weekday; - super.initState(); tabController = TabController(vsync: this, length: DayOfWeek.values.length); tabController.animateTo(tabController.index + (weekDay - 1)); scrollViewController = ScrollController(); } + @override + void dispose() { + tabController.dispose(); + scrollViewController.dispose(); + super.dispose(); + } + @override String? getTitle() => S.of(context).nav_title(NavigationItem.navRestaurants.route); diff --git a/packages/uni_app/lib/view/settings/settings.dart b/packages/uni_app/lib/view/settings/settings.dart index ba48f5ab7..169d70b8e 100644 --- a/packages/uni_app/lib/view/settings/settings.dart +++ b/packages/uni_app/lib/view/settings/settings.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/generated/l10n.dart'; -import 'package:uni/view/about/about.dart'; -import 'package:uni/view/bug_report/bug_report.dart'; +import 'package:uni/utils/navigation_items.dart'; import 'package:uni/view/common_widgets/pages_layouts/secondary/secondary.dart'; import 'package:uni/view/settings/widgets/locale_switch_button.dart'; import 'package:uni/view/settings/widgets/notifications_dialog.dart'; @@ -55,11 +54,9 @@ class SettingsPageState extends SecondaryPageViewState { title: Text(S.of(context).report_error_suggestion), trailing: const Icon(Icons.arrow_forward_ios), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => const BugReportPageView(), - ), + '/${NavigationItem.navBugreport.route}', ); }, ), @@ -67,11 +64,9 @@ class SettingsPageState extends SecondaryPageViewState { title: Text(S.of(context).about), trailing: const Icon(Icons.arrow_forward_ios), onTap: () { - Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => const AboutPageView(), - ), + '/${NavigationItem.navAboutus.route}', ); }, ), diff --git a/packages/uni_app/pubspec.lock b/packages/uni_app/pubspec.lock index 04778b73c..15cc3de07 100644 --- a/packages/uni_app/pubspec.lock +++ b/packages/uni_app/pubspec.lock @@ -1147,6 +1147,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" sentry: dependency: transitive description: @@ -1464,14 +1472,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" - tuple: - dependency: "direct main" - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" typed_data: dependency: transitive description: diff --git a/packages/uni_app/pubspec.yaml b/packages/uni_app/pubspec.yaml index bcaec9cbb..8787d23a0 100644 --- a/packages/uni_app/pubspec.yaml +++ b/packages/uni_app/pubspec.yaml @@ -65,7 +65,6 @@ dependencies: sqflite: ^2.0.3 synchronized: ^3.0.0 timelines: ^0.1.0 - tuple: ^2.0.0 ua_client_hints: ^1.3.1 uni_ui: path: ../uni_ui diff --git a/packages/uni_ui/lib/cards/course_grade_card.dart b/packages/uni_ui/lib/cards/course_grade_card.dart new file mode 100644 index 000000000..845d61715 --- /dev/null +++ b/packages/uni_ui/lib/cards/course_grade_card.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:uni_ui/cards/generic_card.dart'; + +class CourseGradeCard extends StatelessWidget { + const CourseGradeCard( + {required this.courseName, + required this.ects, + required this.grade, + super.key}); + + final String courseName; + final double ects; + final double grade; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return GenericCard( + key: key, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.35, + height: MediaQuery.of(context).size.height * 0.09, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + courseName, + style: theme.textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("${ects} ECTS", style: theme.textTheme.bodyLarge), + Text("${grade.toInt()}", style: theme.textTheme.bodyLarge) + ], + ) + ], + ), + )); + } +} diff --git a/packages/uni_ui/lib/cards/exam_card.dart b/packages/uni_ui/lib/cards/exam_card.dart new file mode 100644 index 000000000..a6f291c1a --- /dev/null +++ b/packages/uni_ui/lib/cards/exam_card.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:uni_ui/cards/generic_card.dart'; +import 'package:uni_ui/theme.dart'; + +class ExamCard extends StatelessWidget { + const ExamCard({ + super.key, + required this.name, + required this.acronym, + required this.rooms, + required this.type, + this.startTime, + this.isInvisible = false, + this.showIcon = true, + this.iconAction, + }); + + final String name; + final String acronym; + final List rooms; + final String type; + final String? startTime; + final bool isInvisible; + final bool showIcon; + final Function()? iconAction; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: isInvisible ? 0.6 : 1.0, + child: GenericCard( + key: key, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + acronym, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineMedium!, + ), + const SizedBox(width: 8), + Badge( + label: Text(type), + backgroundColor: BadgeColors.er, + textColor: Theme.of(context).colorScheme.surface, + ), + ], + ), + Text( + name, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge!, + ), + const SizedBox(height: 5), + Row( + children: [ + PhosphorIcon( + PhosphorIcons.clock(PhosphorIconsStyle.duotone), + color: Theme.of(context).iconTheme.color, + size: 20, + ), + const SizedBox(width: 4), + Text( + startTime ?? "--:--", + style: Theme.of(context).textTheme.bodyMedium!, + ), + const SizedBox(width: 8), + if (!rooms.isEmpty) + PhosphorIcon( + PhosphorIcons.mapPin(PhosphorIconsStyle.duotone), + color: Theme.of(context).iconTheme.color, + size: 20, + ), + const SizedBox(width: 4), + Expanded( + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.black, Colors.transparent], + stops: [0.8, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Text( + rooms.join(" "), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ), + ], + ) + ], + ), + ), + if (showIcon) + IconButton( + onPressed: iconAction ?? () {}, + icon: PhosphorIcon( + isInvisible + ? PhosphorIcons.eye(PhosphorIconsStyle.duotone) + : PhosphorIcons.eyeSlash(PhosphorIconsStyle.duotone), + color: Theme.of(context).iconTheme.color, + size: 35, + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/uni_ui/lib/generic_card.dart b/packages/uni_ui/lib/cards/generic_card.dart similarity index 100% rename from packages/uni_ui/lib/generic_card.dart rename to packages/uni_ui/lib/cards/generic_card.dart diff --git a/packages/uni_ui/lib/cards/service_card.dart b/packages/uni_ui/lib/cards/service_card.dart new file mode 100644 index 000000000..c002a7a86 --- /dev/null +++ b/packages/uni_ui/lib/cards/service_card.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:uni_ui/cards/generic_card.dart'; + +class ServiceCard extends StatelessWidget { + const ServiceCard({ + super.key, + required this.name, + required this.openingHours, + }); + + final String name; + final List openingHours; + + @override + Widget build(BuildContext context) { + return GenericCard( + key: key, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + name, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineMedium!, + ), + ], + ), + const SizedBox(height: 5), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + PhosphorIcon( + PhosphorIcons.clock(PhosphorIconsStyle.duotone), + color: Theme.of(context).textTheme.bodyMedium!.color, + size: 20, + ), + const SizedBox(width: 5), + Column( + children: openingHours.map((hour) { + return Text( + hour, + style: Theme.of(context).textTheme.bodySmall, + ); + }).toList(), + ) + ], + ), + ], + ), + ); + } +} diff --git a/packages/uni_ui/lib/main.dart b/packages/uni_ui/lib/main.dart new file mode 100644 index 000000000..fc1d906a2 --- /dev/null +++ b/packages/uni_ui/lib/main.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:uni_ui/timeline/timeline.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + home: Scaffold( + appBar: AppBar( + title: Text('Timeline Example'), + ), + body: Timeline( + content: [ + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 1', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.green[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 2', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut ' + 'odit aut fugit, sed quia consequuntur magni dolores eos qui ' + 'ratione voluptatem sequi nesciunt.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, ' + 'consectetur, adipisci velit, sed quia non numquam eius modi ' + 'tempora incidunt ut labore et dolore magnam aliquam quaerat ' + 'voluptatem.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Ut enim ad minima veniam, quis nostrum exercitationem ullam ' + 'corporis suscipit laboriosam, nisi ut aliquid ex ea commodi ' + 'consequatur?', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.blue[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 3', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Quis autem vel eum iure reprehenderit qui in ea voluptate ' + 'velit esse quam nihil molestiae consequatur, vel illum qui ' + 'dolorem eum fugiat quo voluptas nulla pariatur?', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'But I must explain to you how all this mistaken idea of ' + 'denouncing pleasure and praising pain was born and I will ' + 'give you a complete account of the system, and expound the ' + 'actual teachings of the great explorer of the truth.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Nor again is there anyone who loves or pursues or desires to ' + 'obtain pain of itself, because it is pain, but because ' + 'occasionally circumstances occur in which toil and pain can ' + 'procure him some great pleasure.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 4', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 5', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 6', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 7', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + ], + tabs: [ + Column( + children: [ + Text('Mon'), + Text('1'), + ], + ), + Column( + children: [ + Text('Tue'), + Text('2'), + ], + ), + Column( + children: [ + Text('Wed'), + Text('3'), + ], + ), + Column( + children: [ + Text('Thu'), + Text('4'), + ], + ), + Column( + children: [ + Text('Fri'), + Text('5'), + ], + ), + Column( + children: [ + Text('Sat'), + Text('6'), + ], + ), + Column( + children: [ + Text('Sun'), + Text('7'), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/uni_ui/lib/navbar/bottom_navbar.dart b/packages/uni_ui/lib/navbar/bottom_navbar.dart new file mode 100644 index 000000000..da84cc14e --- /dev/null +++ b/packages/uni_ui/lib/navbar/bottom_navbar.dart @@ -0,0 +1,108 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; +import 'package:uni_ui/navbar/bottom_navbar_item.dart'; + +class _BottomNavbarContainer extends StatelessWidget { + _BottomNavbarContainer({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + height: 80, + margin: EdgeInsets.only(left: 20, right: 20, bottom: 20), + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: SmoothRectangleBorder( + borderRadius: SmoothBorderRadius( + cornerRadius: 20, + cornerSmoothing: 1, + )), + shadows: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow.withAlpha(0x7f), + blurRadius: 5, + offset: Offset(0, 3), + ), + ], + ), + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 20, + cornerSmoothing: 1, + ), + child: Container( + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [ + Theme.of(context).colorScheme.tertiary.withAlpha(0x3f), + Colors.transparent, + ], + center: Alignment(-0.5, -1.1), + radius: 2.5, + ), + ), + child: Container( + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [ + Theme.of(context).colorScheme.tertiary.withAlpha(0x3f), + Colors.transparent, + ], + center: Alignment.bottomRight, + radius: 2.5, + )), + child: child, + ), + ), + ), + ); + } +} + +class BottomNavbar extends StatefulWidget { + BottomNavbar({super.key, required this.items}); + + final List items; + + @override + _BottomNavbarState createState() => _BottomNavbarState(); +} + +class _BottomNavbarState extends State { + void _refresh() { + setState(() {}); + } + + void _onTap(int index) { + widget.items[index].onTap(); + _refresh(); + } + + @override + Widget build(BuildContext context) { + return _BottomNavbarContainer( + child: Theme( + data: Theme.of(context).copyWith( + splashColor: Theme.of(context).colorScheme.tertiary.withAlpha(0x1f), + highlightColor: Colors.transparent, + ), + child: BottomNavigationBar( + onTap: _onTap, + backgroundColor: Colors.transparent, + elevation: 0, + iconSize: 32, + type: BottomNavigationBarType.fixed, + items: widget.items + .map((item) => item.toBottomNavigationBarItem(context)) + .toList(), + selectedFontSize: 0, + unselectedFontSize: 0, + showSelectedLabels: false, + showUnselectedLabels: false, + ), + ), + ); + } +} diff --git a/packages/uni_ui/lib/navbar/bottom_navbar_item.dart b/packages/uni_ui/lib/navbar/bottom_navbar_item.dart new file mode 100644 index 000000000..e34db91bf --- /dev/null +++ b/packages/uni_ui/lib/navbar/bottom_navbar_item.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +abstract class NavbarDefaultIcons { + static final home = PhosphorIcons.house(PhosphorIconsStyle.duotone); + static final academic = + PhosphorIcons.graduationCap(PhosphorIconsStyle.duotone); + static final restaurant = PhosphorIcons.forkKnife(PhosphorIconsStyle.duotone); + static final faculty = PhosphorIcons.buildings(PhosphorIconsStyle.duotone); + static final map = PhosphorIcons.mapTrifold(PhosphorIconsStyle.duotone); +} + +class BottomNavbarItem { + BottomNavbarItem({ + required this.icon, + required this.isSelected, + required this.onTap, + }); + + final IconData icon; + final bool Function() isSelected; + final void Function() onTap; + + BottomNavigationBarItem toBottomNavigationBarItem(BuildContext context) { + return BottomNavigationBarItem( + icon: Container( + padding: EdgeInsets.all(6), + decoration: isSelected() + ? BoxDecoration( + color: Theme.of(context).colorScheme.tertiary.withAlpha(0x2f), + borderRadius: BorderRadius.circular(10), + ) + : null, + child: PhosphorIcon( + icon, + color: Theme.of(context).colorScheme.secondary, + ), + ), + label: '', + ); + } +} diff --git a/packages/uni_ui/lib/theme.dart b/packages/uni_ui/lib/theme.dart index 2c2fd7686..431d4d1e8 100644 --- a/packages/uni_ui/lib/theme.dart +++ b/packages/uni_ui/lib/theme.dart @@ -14,6 +14,7 @@ const _textTheme = TextTheme( displayLarge: TextStyle(fontSize: 40, fontWeight: FontWeight.w400), displayMedium: TextStyle(fontSize: 32, fontWeight: FontWeight.w400), displaySmall: TextStyle(fontSize: 28, fontWeight: FontWeight.w400), + headlineLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.w300), headlineMedium: TextStyle(fontSize: 24, fontWeight: FontWeight.w300), headlineSmall: TextStyle(fontSize: 20, fontWeight: FontWeight.w400), titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w300), @@ -24,9 +25,24 @@ const _textTheme = TextTheme( bodySmall: TextStyle(fontSize: 13, fontWeight: FontWeight.w400), ); +var _lightTextTheme = TextTheme( + displayLarge: _textTheme.displayLarge!, + displayMedium: _textTheme.displayMedium!, + displaySmall: _textTheme.displaySmall!, + headlineLarge: _textTheme.headlineLarge!, + headlineMedium: _textTheme.headlineMedium!.copyWith(color: darkRed), + headlineSmall: _textTheme.headlineSmall!, + titleLarge: _textTheme.titleLarge!.copyWith(color: darkRed), + titleMedium: _textTheme.titleMedium!, + titleSmall: _textTheme.titleSmall!, + bodyLarge: _textTheme.bodyLarge!, + bodyMedium: _textTheme.bodyMedium!, + bodySmall: _textTheme.bodySmall!, +); + ThemeData lightTheme = ThemeData( useMaterial3: true, - textTheme: _textTheme, + textTheme: _lightTextTheme, colorScheme: ColorScheme.fromSeed( seedColor: darkRed, surface: mildWhite, @@ -48,3 +64,10 @@ ThemeData lightTheme = ThemeData( secondaryHeaderColor: normalGray, iconTheme: const IconThemeData(color: darkRed), ); + +class BadgeColors { + static const mt = Color(0xFF7ca5b8); + static const en = Color(0xFF769c87); + static const er = Color(0xFFab4d39); + static const ee = Color(0xFFfbc11f); +} diff --git a/packages/uni_ui/lib/timeline/timeline.dart b/packages/uni_ui/lib/timeline/timeline.dart new file mode 100644 index 000000000..2c4590927 --- /dev/null +++ b/packages/uni_ui/lib/timeline/timeline.dart @@ -0,0 +1,141 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class Timeline extends StatefulWidget { + const Timeline({ + required this.tabs, + required this.content, + super.key, + }); + + final List tabs; + final List content; + + @override + State createState() => _TimelineState(); +} + +class _TimelineState extends State { + int _currentIndex = 0; + final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + final ScrollController _tabScrollController = ScrollController(); + final List _tabKeys = []; + + @override + void initState() { + super.initState(); + + _tabKeys.addAll(List.generate(widget.tabs.length, (index) => GlobalKey())); + + _itemPositionsListener.itemPositions.addListener(() { + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isNotEmpty) { + final firstVisibleIndex = positions + .where((ItemPosition position) => position.itemLeadingEdge >= 0) + .reduce((ItemPosition current, ItemPosition next) => + current.itemLeadingEdge < next.itemLeadingEdge ? current : next) + .index; + + if (_currentIndex != firstVisibleIndex) { + setState(() { + _currentIndex = firstVisibleIndex; + }); + + _scrollToCenterTab(firstVisibleIndex); + } + } + }); + } + + @override + void dispose() { + _tabScrollController.dispose(); + super.dispose(); + } + + void _onTabTapped(int index) { + _itemScrollController.scrollTo( + index: index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + _scrollToCenterTab(index); + } + + void _scrollToCenterTab(int index) { + final screenWidth = MediaQuery.of(context).size.width; + final RenderBox tabBox = + _tabKeys[index].currentContext!.findRenderObject() as RenderBox; + + final tabWidth = tabBox.size.width; + final offset = (_tabScrollController.offset + + tabBox.localToGlobal(Offset.zero).dx + + (tabWidth / 2) - + (screenWidth / 2)) + .clamp( + 0.0, + _tabScrollController.position.maxScrollExtent, + ); + + _tabScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _tabScrollController, + child: Row( + children: widget.tabs.asMap().entries.map((entry) { + int index = entry.key; + Widget tab = entry.value; + return GestureDetector( + onTap: () => _onTabTapped(index), + child: Padding( + padding: const EdgeInsets.all(7.0), + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 10, + cornerSmoothing: 1, + ), + child: Container( + key: _tabKeys[index], + padding: const EdgeInsets.symmetric( + vertical: 10.0, horizontal: 15.0), + color: _currentIndex == index + ? Theme.of(context) + .colorScheme + .tertiary + .withOpacity(0.25) + : Colors.transparent, + child: tab, + ), + ), + ), + ); + }).toList(), + ), + ), + Expanded( + child: ScrollablePositionedList.builder( + itemCount: widget.content.length, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + itemBuilder: (context, index) { + return widget.content[index]; + }, + ), + ), + ], + ); + } +} diff --git a/packages/uni_ui/pubspec.lock b/packages/uni_ui/pubspec.lock index 1f09ca040..20675b329 100644 --- a/packages/uni_ui/pubspec.lock +++ b/packages/uni_ui/pubspec.lock @@ -403,6 +403,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" shelf: dependency: transitive description: diff --git a/packages/uni_ui/pubspec.yaml b/packages/uni_ui/pubspec.yaml index 71d7cbb74..dbfd71135 100644 --- a/packages/uni_ui/pubspec.yaml +++ b/packages/uni_ui/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter phosphor_flutter: ^2.1.0 + scrollable_positioned_list: ^0.3.5 dev_dependencies: custom_lint: ^0.6.4