diff --git a/uni/lib/controller/cleanup.dart b/uni/lib/controller/cleanup.dart index 71c975533..8b8c519db 100644 --- a/uni/lib/controller/cleanup.dart +++ b/uni/lib/controller/cleanup.dart @@ -41,3 +41,38 @@ Future cleanupStoredData(BuildContext context) async { directory.deleteSync(recursive: true); } } + +Future cleanupCachedFiles() async { + final lastCleanupDate = PreferencesController.getLastCleanUpDate(); + final daysSinceLastCleanup = + DateTime.now().difference(lastCleanupDate).inDays; + + if (daysSinceLastCleanup < 14) { + return; + } + + final toCleanDirectory = await getApplicationDocumentsDirectory(); + final threshold = DateTime.now().subtract(const Duration(days: 30)); + final directories = toCleanDirectory.listSync(followLinks: false); + + for (final directory in directories) { + if (directory is Directory) { + final files = directory.listSync(recursive: true, followLinks: false); + + final oldFiles = files.where((file) { + try { + final fileDate = File(file.path).lastModifiedSync(); + return fileDate.isBefore(threshold); + } catch (e) { + return false; + } + }); + + for (final file in oldFiles) { + await File(file.path).delete(); + } + } + } + + await PreferencesController.setLastCleanUpDate(DateTime.now()); +} diff --git a/uni/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart b/uni/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart index 608a23f6c..1c54029e3 100644 --- a/uni/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart +++ b/uni/lib/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart @@ -3,6 +3,7 @@ import 'package:uni/controller/fetchers/session_dependant_fetcher.dart'; import 'package:uni/controller/networking/network_router.dart'; import 'package:uni/controller/parsers/parser_course_unit_info.dart'; import 'package:uni/model/entities/course_units/course_unit_class.dart'; +import 'package:uni/model/entities/course_units/course_unit_directory.dart'; import 'package:uni/model/entities/course_units/course_unit_sheet.dart'; import 'package:uni/model/entities/session.dart'; @@ -26,6 +27,27 @@ class CourseUnitsInfoFetcher implements SessionDependantFetcher { return parseCourseUnitSheet(response); } + Future> fetchCourseUnitFiles( + Session session, + int occurId, + ) async { + final url = '${getEndpoints(session)[0]}mob_ucurr_geral.conteudos'; + final response = await NetworkRouter.getWithCookies( + url, + { + 'pv_ocorrencia_id': occurId.toString(), + }, + session, + ); + return parseFiles(response, session); + } + + Future getDownloadLink( + Session session, + ) async { + return '${getEndpoints(session)[0]}conteudos_service.conteudos_cont'; + } + Future> fetchCourseUnitClasses( Session session, int occurrId, diff --git a/uni/lib/controller/local_storage/file_offline_storage.dart b/uni/lib/controller/local_storage/file_offline_storage.dart index e99580840..dca50a64a 100644 --- a/uni/lib/controller/local_storage/file_offline_storage.dart +++ b/uni/lib/controller/local_storage/file_offline_storage.dart @@ -10,7 +10,7 @@ import 'package:uni/model/entities/session.dart'; /// The offline image storage location on the device. Future get _localPath async { - final directory = await getTemporaryDirectory(); + final directory = await getApplicationDocumentsDirectory(); return directory.path; } @@ -66,9 +66,11 @@ Future _downloadAndSaveFile( Session? session, Map? headers, ) async { + final header = headers ?? {}; + final response = session == null ? await http.get(url.toUri(), headers: headers) - : await NetworkRouter.getWithCookies(url, {}, session); + : await NetworkRouter.getWithCookies(url, header, session); if (response.statusCode == 200) { return File(filePath).writeAsBytes(response.bodyBytes); diff --git a/uni/lib/controller/local_storage/preferences_controller.dart b/uni/lib/controller/local_storage/preferences_controller.dart index 57b1bf801..69b2fe3bb 100644 --- a/uni/lib/controller/local_storage/preferences_controller.dart +++ b/uni/lib/controller/local_storage/preferences_controller.dart @@ -33,6 +33,7 @@ class PreferencesController { static const String _isDataCollectionBannerViewedKey = 'data_collection_banner'; static const String _locale = 'app_locale'; + static const String _lastCacheCleanUpDate = 'last_clean'; static const String _favoriteCards = 'favorite_cards'; static final List _defaultFavoriteCards = [ FavoriteWidgetType.schedule, @@ -143,6 +144,16 @@ class PreferencesController { ); } + static Future setLastCleanUpDate(DateTime date) async { + await prefs.setString(_lastCacheCleanUpDate, date.toString()); + } + + static DateTime getLastCleanUpDate() { + final date = + prefs.getString(_lastCacheCleanUpDate) ?? DateTime.now().toString(); + return DateTime.parse(date); + } + /// Deletes the user's student number and password. static Future removePersistentUserInfo() async { await prefs.remove(_userNumber); diff --git a/uni/lib/controller/parsers/parser_course_unit_info.dart b/uni/lib/controller/parsers/parser_course_unit_info.dart index 6ef395a2f..ad8751b19 100644 --- a/uni/lib/controller/parsers/parser_course_unit_info.dart +++ b/uni/lib/controller/parsers/parser_course_unit_info.dart @@ -1,7 +1,46 @@ +import 'dart:convert'; + import 'package:html/parser.dart'; import 'package:http/http.dart' as http; +import 'package:uni/controller/fetchers/course_units_fetcher/course_units_info_fetcher.dart'; import 'package:uni/model/entities/course_units/course_unit_class.dart'; +import 'package:uni/model/entities/course_units/course_unit_directory.dart'; +import 'package:uni/model/entities/course_units/course_unit_file.dart'; import 'package:uni/model/entities/course_units/course_unit_sheet.dart'; +import 'package:uni/model/entities/session.dart'; + +Future> parseFiles( + http.Response response, + Session session, +) async { + final json = jsonDecode(response.body) as List; + final dirs = []; + if (json.isEmpty) return []; + + for (var item in json) { + item = item as Map; + final files = []; + for (final file in item['ficheiros'] as List) { + if (file is Map) { + final fileName = file['nome']; + final fileDate = file['data_actualizacao']; + final fileCode = file['codigo'].toString(); + final format = file['filename'] + .toString() + .substring(file['filename'].toString().indexOf('.')); + final url = await CourseUnitsInfoFetcher().getDownloadLink(session); + final courseUnitFile = CourseUnitFile( + '${fileName}_$fileDate$format', + url, + fileCode, + ); + files.add(courseUnitFile); + } + } + dirs.add(CourseUnitFileDirectory(item['nome'].toString(), files)); + } + return dirs; +} Future parseCourseUnitSheet(http.Response response) async { final document = parse(response.body); diff --git a/uni/lib/generated/intl/messages_en.dart b/uni/lib/generated/intl/messages_en.dart index b9bc65be7..de7261274 100644 --- a/uni/lib/generated/intl/messages_en.dart +++ b/uni/lib/generated/intl/messages_en.dart @@ -114,6 +114,8 @@ class MessageLookup extends MessageLookupByLibrary { "D. Beatriz\'s stationery store"), "dona_bia_building": MessageLookupByLibrary.simpleMessage( "Floor -1 of building B (B-142)"), + "download_error": + MessageLookupByLibrary.simpleMessage("Error downloading the file"), "ects": MessageLookupByLibrary.simpleMessage("ECTS performed: "), "edit_off": MessageLookupByLibrary.simpleMessage("Edit"), "edit_on": MessageLookupByLibrary.simpleMessage("Finish editing"), @@ -130,6 +132,7 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Deadline for next fee:"), "fee_notification": MessageLookupByLibrary.simpleMessage("Fee deadline"), + "files": MessageLookupByLibrary.simpleMessage("Files"), "first_year_registration": MessageLookupByLibrary.simpleMessage( "Year of first registration: "), "floor": MessageLookupByLibrary.simpleMessage("Floor"), @@ -165,6 +168,8 @@ class MessageLookup extends MessageLookupByLibrary { "nav_title": m2, "news": MessageLookupByLibrary.simpleMessage("News"), "no": MessageLookupByLibrary.simpleMessage("No"), + "no_app": MessageLookupByLibrary.simpleMessage( + "No app found to open the file"), "no_bus": MessageLookupByLibrary.simpleMessage("Don\'t miss any bus!"), "no_bus_stops": MessageLookupByLibrary.simpleMessage("No configured stops"), @@ -187,6 +192,8 @@ class MessageLookup extends MessageLookupByLibrary { "Looks like you are on vacation!"), "no_favorite_restaurants": MessageLookupByLibrary.simpleMessage("No favorite restaurants"), + "no_files_found": + MessageLookupByLibrary.simpleMessage("No files found"), "no_info": MessageLookupByLibrary.simpleMessage( "There is no information to display"), "no_library_info": MessageLookupByLibrary.simpleMessage( @@ -214,12 +221,16 @@ class MessageLookup extends MessageLookupByLibrary { "occurrence_type": MessageLookupByLibrary.simpleMessage("Type of occurrence"), "of_month": MessageLookupByLibrary.simpleMessage("of"), + "open_error": + MessageLookupByLibrary.simpleMessage("Error opening the file"), "other_links": MessageLookupByLibrary.simpleMessage("Other links"), "pass_change_request": MessageLookupByLibrary.simpleMessage( "For security reasons, passwords must be changed periodically."), "password": MessageLookupByLibrary.simpleMessage("password"), "pendent_references": MessageLookupByLibrary.simpleMessage("Pending references"), + "permission_denied": + MessageLookupByLibrary.simpleMessage("Permission denied"), "personal_assistance": MessageLookupByLibrary.simpleMessage("Face-to-face assistance"), "press_again": @@ -253,6 +264,8 @@ class MessageLookup extends MessageLookupByLibrary { "student_number": MessageLookupByLibrary.simpleMessage("student number"), "success": MessageLookupByLibrary.simpleMessage("Sent with success"), + "successful_open": + MessageLookupByLibrary.simpleMessage("File opened successfully"), "tele_assistance": MessageLookupByLibrary.simpleMessage("Telephone assistance"), "tele_personal_assistance": MessageLookupByLibrary.simpleMessage( diff --git a/uni/lib/generated/intl/messages_pt_PT.dart b/uni/lib/generated/intl/messages_pt_PT.dart index a87b06cf8..76da6d9ff 100644 --- a/uni/lib/generated/intl/messages_pt_PT.dart +++ b/uni/lib/generated/intl/messages_pt_PT.dart @@ -113,6 +113,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Papelaria D. Beatriz"), "dona_bia_building": MessageLookupByLibrary.simpleMessage( "Piso -1 do edifício B (B-142)"), + "download_error": MessageLookupByLibrary.simpleMessage( + "Erro ao descarregar o ficheiro"), "ects": MessageLookupByLibrary.simpleMessage("ECTS realizados: "), "edit_off": MessageLookupByLibrary.simpleMessage("Editar"), "edit_on": MessageLookupByLibrary.simpleMessage("Concluir edição"), @@ -129,6 +131,7 @@ class MessageLookup extends MessageLookupByLibrary { "Data limite próxima prestação:"), "fee_notification": MessageLookupByLibrary.simpleMessage("Data limite de propina"), + "files": MessageLookupByLibrary.simpleMessage("Ficheiros"), "first_year_registration": MessageLookupByLibrary.simpleMessage("Ano da primeira inscrição: "), "floor": MessageLookupByLibrary.simpleMessage("Piso"), @@ -152,7 +155,7 @@ class MessageLookup extends MessageLookupByLibrary { "library_occupation": MessageLookupByLibrary.simpleMessage("Ocupação da Biblioteca"), "load_error": MessageLookupByLibrary.simpleMessage( - "Aconteceu um erro ao carregar os dados"), + "Erro ao carregar a informação"), "loading_terms": MessageLookupByLibrary.simpleMessage( "Carregando os Termos e Condições..."), "login": MessageLookupByLibrary.simpleMessage("Entrar"), @@ -165,6 +168,8 @@ class MessageLookup extends MessageLookupByLibrary { "nav_title": m2, "news": MessageLookupByLibrary.simpleMessage("Notícias"), "no": MessageLookupByLibrary.simpleMessage("Não"), + "no_app": MessageLookupByLibrary.simpleMessage( + "Nenhuma aplicação encontrada para abrir o ficheiro"), "no_bus": MessageLookupByLibrary.simpleMessage( "Não percas nenhum autocarro!"), "no_bus_stops": MessageLookupByLibrary.simpleMessage( @@ -189,6 +194,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Parece que estás de férias!"), "no_favorite_restaurants": MessageLookupByLibrary.simpleMessage("Sem restaurantes favoritos"), + "no_files_found": + MessageLookupByLibrary.simpleMessage("Nenhum ficheiro encontrado"), "no_info": MessageLookupByLibrary.simpleMessage( "Não existem informações para apresentar"), "no_library_info": @@ -216,12 +223,16 @@ class MessageLookup extends MessageLookupByLibrary { "occurrence_type": MessageLookupByLibrary.simpleMessage("Tipo de ocorrência"), "of_month": MessageLookupByLibrary.simpleMessage("de"), + "open_error": + MessageLookupByLibrary.simpleMessage("Erro ao abrir o ficheiro"), "other_links": MessageLookupByLibrary.simpleMessage("Outros links"), "pass_change_request": MessageLookupByLibrary.simpleMessage( "Por razões de segurança, as palavras-passe têm de ser alteradas periodicamente."), "password": MessageLookupByLibrary.simpleMessage("palavra-passe"), "pendent_references": MessageLookupByLibrary.simpleMessage("Referências pendentes"), + "permission_denied": + MessageLookupByLibrary.simpleMessage("Sem permissão"), "personal_assistance": MessageLookupByLibrary.simpleMessage("Atendimento presencial"), "press_again": MessageLookupByLibrary.simpleMessage( @@ -255,6 +266,8 @@ class MessageLookup extends MessageLookupByLibrary { "student_number": MessageLookupByLibrary.simpleMessage("número de estudante"), "success": MessageLookupByLibrary.simpleMessage("Enviado com sucesso"), + "successful_open": + MessageLookupByLibrary.simpleMessage("Ficheiro aberto com sucesso"), "tele_assistance": MessageLookupByLibrary.simpleMessage("Atendimento telefónico"), "tele_personal_assistance": MessageLookupByLibrary.simpleMessage( diff --git a/uni/lib/generated/l10n.dart b/uni/lib/generated/l10n.dart index ec1dde692..30773b6a0 100644 --- a/uni/lib/generated/l10n.dart +++ b/uni/lib/generated/l10n.dart @@ -763,11 +763,11 @@ class S { ); } - /// `Error loading the information` - String get load_error { + /// `Error downloading the file` + String get download_error { return Intl.message( - 'Error loading the information', - name: 'load_error', + 'Error downloading the file', + name: 'download_error', desc: '', args: [], ); @@ -1148,6 +1148,16 @@ class S { ); } + /// `No files found` + String get no_files_found { + return Intl.message( + 'No files found', + name: 'no_files_found', + desc: '', + args: [], + ); + } + /// `Other links` String get other_links { return Intl.message( @@ -1218,6 +1228,56 @@ class S { ); } + /// `File opened successfully` + String get successful_open { + return Intl.message( + 'File opened successfully', + name: 'successful_open', + desc: '', + args: [], + ); + } + + /// `Permission denied` + String get permission_denied { + return Intl.message( + 'Permission denied', + name: 'permission_denied', + desc: '', + args: [], + ); + } + + /// `Error opening the file` + String get open_error { + return Intl.message( + 'Error opening the file', + name: 'open_error', + desc: '', + args: [], + ); + } + + /// `No app found to open the file` + String get no_app { + return Intl.message( + 'No app found to open the file', + name: 'no_app', + desc: '', + args: [], + ); + } + + /// `Error loading the information` + String get load_error { + return Intl.message( + 'Error loading the information', + name: 'load_error', + desc: '', + args: [], + ); + } + /// `Prints` String get prints { return Intl.message( @@ -1298,6 +1358,16 @@ class S { ); } + /// `Files` + String get files { + return Intl.message( + 'Files', + name: 'files', + desc: '', + args: [], + ); + } + /// `School Calendar` String get school_calendar { return Intl.message( diff --git a/uni/lib/l10n/intl_en.arb b/uni/lib/l10n/intl_en.arb index 76532d54c..bf8d2121c 100644 --- a/uni/lib/l10n/intl_en.arb +++ b/uni/lib/l10n/intl_en.arb @@ -150,8 +150,8 @@ }, "library_occupation": "Library Occupation", "@library_occupation": {}, - "load_error": "Error loading the information", - "@load_error": {}, + "download_error": "Error downloading the file", + "@download_error": {}, "loading_terms": "Loading Terms and Conditions...", "@loading_terms": {}, "login": "Login", @@ -224,6 +224,8 @@ "@of_month": {}, "no_link": "We couldn't open the link", "@no_link": {}, + "no_files_found": "No files found", + "@no_files_found": {}, "other_links": "Other links", "@other_links": {}, "pass_change_request": "For security reasons, passwords must be changed periodically.", @@ -238,6 +240,16 @@ "@press_again": {}, "print": "Print", "@print": {}, + "successful_open": "File opened successfully", + "@successful_open": {}, + "permission_denied": "Permission denied", + "@permission_denied": {}, + "open_error": "Error opening the file", + "@open_error": {}, + "no_app": "No app found to open the file", + "@no_app": {}, + "load_error": "Error loading the information", + "@load_error": {}, "prints": "Prints", "@prints": {}, "problem_id": "Brief identification of the problem", @@ -254,6 +266,8 @@ "@restaurant_main_page": {}, "room": "Room", "@room": {}, + "files": "Files", + "@files": {}, "school_calendar": "School Calendar", "@school_calendar": {}, "semester": "Semester", diff --git a/uni/lib/l10n/intl_pt_PT.arb b/uni/lib/l10n/intl_pt_PT.arb index 6bbf7e942..56dbd3945 100644 --- a/uni/lib/l10n/intl_pt_PT.arb +++ b/uni/lib/l10n/intl_pt_PT.arb @@ -150,10 +150,20 @@ "time": {} } }, + "load_error": "Erro ao carregar a informação", + "@load_error": {}, "library_occupation": "Ocupação da Biblioteca", "@library_occupation": {}, - "load_error": "Aconteceu um erro ao carregar os dados", - "@load_error": {}, + "download_error": "Erro ao descarregar o ficheiro", + "@download_error": {}, + "successful_open": "Ficheiro aberto com sucesso", + "@successful_open": {}, + "permission_denied": "Sem permissão", + "@permission_denied": {}, + "open_error": "Erro ao abrir o ficheiro", + "@open_error": {}, + "no_app": "Nenhuma aplicação encontrada para abrir o ficheiro", + "@no_app": {}, "loading_terms": "Carregando os Termos e Condições...", "@loading_terms": {}, "login": "Entrar", @@ -224,6 +234,8 @@ "@of_month": {}, "no_link": "Não conseguimos abrir o link", "@no_link": {}, + "no_files_found": "Nenhum ficheiro encontrado", + "@no_files_found": {}, "other_links": "Outros links", "@other_links": {}, "pass_change_request": "Por razões de segurança, as palavras-passe têm de ser alteradas periodicamente.", @@ -254,6 +266,8 @@ "@restaurant_main_page": {}, "room": "Sala", "@room": {}, + "files": "Ficheiros", + "@files": {}, "school_calendar": "Calendário Escolar", "@school_calendar": {}, "semester": "Semestre", diff --git a/uni/lib/main.dart b/uni/lib/main.dart index a63b38cc4..a21e099b4 100644 --- a/uni/lib/main.dart +++ b/uni/lib/main.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uni/controller/background_workers/background_callback.dart'; +import 'package:uni/controller/cleanup.dart'; import 'package:uni/controller/fetchers/terms_and_conditions_fetcher.dart'; import 'package:uni/controller/local_storage/preferences_controller.dart'; import 'package:uni/generated/l10n.dart'; @@ -82,6 +83,8 @@ Future main() async { ReferenceProvider(), ); + unawaited(cleanupCachedFiles()); + // Initialize WorkManager for background tasks await Workmanager().initialize( workerStartCallback, diff --git a/uni/lib/model/entities/course_units/course_unit_directory.dart b/uni/lib/model/entities/course_units/course_unit_directory.dart new file mode 100644 index 000000000..d14fa6b13 --- /dev/null +++ b/uni/lib/model/entities/course_units/course_unit_directory.dart @@ -0,0 +1,8 @@ +import 'package:uni/model/entities/course_units/course_unit_file.dart'; + +class CourseUnitFileDirectory { + CourseUnitFileDirectory(this.folderName, this.files); + + final String folderName; + final List files; +} diff --git a/uni/lib/model/entities/course_units/course_unit_file.dart b/uni/lib/model/entities/course_units/course_unit_file.dart new file mode 100644 index 000000000..e7076df1d --- /dev/null +++ b/uni/lib/model/entities/course_units/course_unit_file.dart @@ -0,0 +1,10 @@ +class CourseUnitFile { + CourseUnitFile( + this.name, + this.url, + this.fileCode, + ); + String fileCode; + String name; + String url; +} diff --git a/uni/lib/model/providers/lazy/course_units_info_provider.dart b/uni/lib/model/providers/lazy/course_units_info_provider.dart index 5de088822..bdd0ea13c 100644 --- a/uni/lib/model/providers/lazy/course_units_info_provider.dart +++ b/uni/lib/model/providers/lazy/course_units_info_provider.dart @@ -4,6 +4,7 @@ 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'; +import 'package:uni/model/entities/course_units/course_unit_directory.dart'; import 'package:uni/model/entities/course_units/course_unit_sheet.dart'; import 'package:uni/model/entities/session.dart'; import 'package:uni/model/providers/state_provider_notifier.dart'; @@ -11,16 +12,17 @@ import 'package:uni/model/providers/state_providers.dart'; typedef SheetsMap = Map; typedef ClassesMap = Map>; +typedef FilesMap = Map>; class CourseUnitsInfoProvider - extends StateProviderNotifier> { + extends StateProviderNotifier> { CourseUnitsInfoProvider() : super( cacheDuration: null, // Const constructor is not allowed here because of the // need for mutable maps // ignore: prefer_const_constructors - initialState: Tuple2({}, {}), + initialState: Tuple3({}, {}, {}), ); UnmodifiableMapView get courseUnitsSheets => @@ -29,6 +31,9 @@ class CourseUnitsInfoProvider UnmodifiableMapView> get courseUnitsClasses => UnmodifiableMapView(state!.item2); + UnmodifiableMapView> + get courseUnitsFiles => UnmodifiableMapView(state!.item3); + Future fetchCourseUnitSheet( CourseUnit courseUnit, Session session, @@ -46,17 +51,25 @@ class CourseUnitsInfoProvider .fetchCourseUnitClasses(session, courseUnit.occurrId); } + Future fetchCourseUnitFiles( + CourseUnit courseUnit, + Session session, + ) async { + state!.item3[courseUnit] = await CourseUnitsInfoFetcher() + .fetchCourseUnitFiles(session, courseUnit.occurrId); + } + @override - Future> loadFromRemote( + Future> loadFromRemote( StateProviders stateProviders, ) async { - return const Tuple2({}, {}); + return const Tuple3({}, {}, {}); } @override - Future> loadFromStorage( + Future> loadFromStorage( StateProviders stateProviders, ) async { - return const Tuple2({}, {}); + return const Tuple3({}, {}, {}); } } diff --git a/uni/lib/view/common_widgets/pulse_animation.dart b/uni/lib/view/common_widgets/pulse_animation.dart new file mode 100644 index 000000000..45476c9b2 --- /dev/null +++ b/uni/lib/view/common_widgets/pulse_animation.dart @@ -0,0 +1,25 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +class PulseAnimation extends StatelessWidget { + const PulseAnimation({ + required this.child, + required this.controller, + super.key, + }); + final Widget child; + final AnimationController controller; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Opacity( + opacity: 1 - 0.5 * sin(controller.value * pi), + child: child, + ); + }, + ); + } +} diff --git a/uni/lib/view/course_unit_info/course_unit_info.dart b/uni/lib/view/course_unit_info/course_unit_info.dart index 5b1a6c49d..c6d4d89be 100644 --- a/uni/lib/view/course_unit_info/course_unit_info.dart +++ b/uni/lib/view/course_unit_info/course_unit_info.dart @@ -7,6 +7,7 @@ import 'package:uni/model/providers/startup/session_provider.dart'; import 'package:uni/view/common_widgets/page_title.dart'; import 'package:uni/view/common_widgets/pages_layouts/secondary/secondary.dart'; import 'package:uni/view/course_unit_info/widgets/course_unit_classes.dart'; +import 'package:uni/view/course_unit_info/widgets/course_unit_files.dart'; import 'package:uni/view/course_unit_info/widgets/course_unit_sheet.dart'; class CourseUnitDetailPageView extends StatefulWidget { @@ -36,6 +37,15 @@ class CourseUnitDetailPageViewState ); } + final courseUnitFiles = + courseUnitsProvider.courseUnitsFiles[widget.courseUnit]; + if (courseUnitFiles == null || force) { + await courseUnitsProvider.fetchCourseUnitFiles( + widget.courseUnit, + session, + ); + } + final courseUnitClasses = courseUnitsProvider.courseUnitsClasses[widget.courseUnit]; if (courseUnitClasses == null || force) { @@ -59,7 +69,7 @@ class CourseUnitDetailPageViewState @override Widget getBody(BuildContext context) { return DefaultTabController( - length: 2, + length: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -71,6 +81,9 @@ class CourseUnitDetailPageViewState tabs: [ Tab(text: S.of(context).course_info), Tab(text: S.of(context).course_class), + Tab( + text: S.of(context).files, + ), ], ), Expanded( @@ -80,6 +93,7 @@ class CourseUnitDetailPageViewState children: [ _courseUnitSheetView(context), _courseUnitClassesView(context), + _courseUnitFilesView(context), ], ), ), @@ -106,6 +120,14 @@ class CourseUnitDetailPageViewState return CourseUnitSheetView(sheet); } + Widget _courseUnitFilesView(BuildContext context) { + final sheet = context + .watch() + .courseUnitsFiles[widget.courseUnit]; + + return CourseUnitFilesView(sheet!); + } + Widget _courseUnitClassesView(BuildContext context) { final classes = context .read() diff --git a/uni/lib/view/course_unit_info/widgets/course_unit_files.dart b/uni/lib/view/course_unit_info/widgets/course_unit_files.dart new file mode 100644 index 000000000..abd1ea77f --- /dev/null +++ b/uni/lib/view/course_unit_info/widgets/course_unit_files.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; +import 'package:uni/model/entities/course_units/course_unit_directory.dart'; +import 'package:uni/model/entities/course_units/course_unit_file.dart'; +import 'package:uni/view/course_unit_info/widgets/course_unit_files_row.dart'; +import 'package:uni/view/course_unit_info/widgets/course_unit_info_card.dart'; + +class CourseUnitFilesView extends StatelessWidget { + const CourseUnitFilesView(this.files, {super.key}); + final List files; + + @override + Widget build(BuildContext context) { + final cards = files + .where((element) => element.files.isNotEmpty) + .map((e) => _buildCard(e.folderName, e.files)) + .toList(); + + return cards.isEmpty + ? Center( + child: Container( + padding: const EdgeInsets.only(left: 10, right: 10), + child: Text(S.of(context).no_files_found), + ), + ) + : Container( + padding: const EdgeInsets.only(left: 10, right: 10), + child: ListView( + children: cards, + ), + ); + } + + CourseUnitInfoCard _buildCard(String folder, List files) => + CourseUnitInfoCard( + folder, + Column( + children: files.map(CourseUnitFilesRow.new).toList(), + ), + ); +} diff --git a/uni/lib/view/course_unit_info/widgets/course_unit_files_row.dart b/uni/lib/view/course_unit_info/widgets/course_unit_files_row.dart new file mode 100644 index 000000000..88c26c6dc --- /dev/null +++ b/uni/lib/view/course_unit_info/widgets/course_unit_files_row.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:open_file_plus/open_file_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:uni/controller/local_storage/file_offline_storage.dart'; +import 'package:uni/generated/l10n.dart'; +import 'package:uni/model/entities/course_units/course_unit_file.dart'; +import 'package:uni/model/providers/startup/session_provider.dart'; +import 'package:uni/view/common_widgets/pulse_animation.dart'; +import 'package:uni/view/common_widgets/toast_message.dart'; + +class CourseUnitFilesRow extends StatefulWidget { + const CourseUnitFilesRow(this.file, {super.key}); + + final CourseUnitFile file; + + @override + State createState() { + return CourseUnitFilesRowState(); + } +} + +class CourseUnitFilesRowState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + children: [ + const SizedBox(width: 8), + const Icon(Icons.picture_as_pdf), + const SizedBox(width: 1), + Expanded( + child: GestureDetector( + onTap: () { + _controller + ..reset() + ..repeat(reverse: true); + openFile(context, widget.file); + }, + child: Container( + padding: const EdgeInsets.only(left: 10), + child: PulseAnimation( + controller: _controller, + child: Text( + widget.file.name + .substring(0, widget.file.name.indexOf('_')), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Future openFile(BuildContext context, CourseUnitFile unitFile) async { + final session = context.read().state; + + final result = await loadFileFromStorageOrRetrieveNew( + unitFile.name, + unitFile.url, + session, + headers: {'pct_id': unitFile.fileCode}, + ); + + if (result?.path != null) { + final resultType = await OpenFile.open(result!.path); + if (context.mounted) handleFileOpening(resultType.type, context); + } else { + if (context.mounted) { + await ToastMessage.error(context, S.of(context).download_error); + } + } + + _controller.reset(); + } + + void handleFileOpening(ResultType resultType, BuildContext context) { + switch (resultType) { + case ResultType.done: + ToastMessage.success( + context, + S.of(context).successful_open, + ); + case ResultType.error: + ToastMessage.error( + context, + S.of(context).open_error, + ); + case ResultType.noAppToOpen: + ToastMessage.warning( + context, + S.of(context).no_app, + ); + case ResultType.permissionDenied: + ToastMessage.warning(context, S.of(context).permission_denied); + case ResultType.fileNotFound: + ToastMessage.error(context, S.of(context).download_error); + } + } +} diff --git a/uni/pubspec.lock b/uni/pubspec.lock index 37857c0cf..27418c39d 100644 --- a/uni/pubspec.lock +++ b/uni/pubspec.lock @@ -375,7 +375,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" @@ -701,6 +701,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + open_file_plus: + dependency: "direct main" + description: + path: "." + ref: "3c32191" + resolved-ref: "3c321911c54388d1316e34d4f999776281398fc2" + url: "https://github.com/joutvhu/open_file_plus.git" + source: git + version: "3.4.1" package_config: dependency: transitive description: diff --git a/uni/pubspec.yaml b/uni/pubspec.yaml index 04a1ec855..5c6e5a5fb 100644 --- a/uni/pubspec.yaml +++ b/uni/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: expansion_tile_card: ^3.0.0 flutter: sdk: flutter + flutter_cache_manager: ^3.3.1 flutter_dotenv: ^5.0.2 flutter_local_notifications: ^15.1.0+1 flutter_localizations: @@ -45,6 +46,10 @@ dependencies: latlong2: ^0.9.0 logger: ^2.0.2+1 material_design_icons_flutter: ^7.0.7296 + open_file_plus: + git: + url: https://github.com/joutvhu/open_file_plus.git + ref: "3c32191" path: ^1.8.0 path_provider: ^2.1.2 percent_indicator: ^4.2.2 @@ -69,6 +74,7 @@ dev_dependencies: sqflite_common_ffi: ^2.3.0+4 test: any very_good_analysis: ^5.1.0 + flutter: generate: true