diff --git a/app/lib/client/client.dart b/app/lib/client/client.dart index 1589a181..69b7bfd5 100644 --- a/app/lib/client/client.dart +++ b/app/lib/client/client.dart @@ -205,7 +205,6 @@ class SPHclient { } on LanisException { rethrow; } catch (e) { - debugPrint(e.toString()); throw LoggedOffOrUnknownException(); } } @@ -364,8 +363,6 @@ class SPHclient { var userDataTableBody = document.querySelector("div.col-md-12 table.table.table-striped tbody"); - //TODO find out how "Zugeordnete Eltern/Erziehungsberechtigte" is used in this scope - if (userDataTableBody != null) { var result = {}; @@ -410,43 +407,47 @@ class SPHclient { await deleteSubfoldersAndFiles(tempDir); } + // This function generates a unique hash for a given source string + String generateUniqueHash(String source) { + var bytes = utf8.encode(source); + var digest = sha256.convert(bytes); + + var shortHash = digest + .toString() + .replaceAll(RegExp(r'[^A-z0-9]'), '') + .substring(0, 12); + + return shortHash; + } + + /// This function checks if a file exists in the temporary directory downloaded by [downloadFile] + Future doesFileExist(String url, String filename) async { + var tempDir = await getTemporaryDirectory(); + String urlHash = generateUniqueHash(url); + String folderPath = "${tempDir.path}/$urlHash"; + String filePath = "$folderPath/$filename"; + + File existingFile = File(filePath); + return existingFile.existsSync(); + } + ///downloads a file from an URL and returns the path of the file. /// ///The file is stored in the temporary directory of the device. ///So calling the same URL twice will result in the same file and one Download. Future downloadFile(String url, String filename) async { - String generateUniqueHash(String source) { - var bytes = utf8.encode(source); - var digest = sha256.convert(bytes); - - var shortHash = digest - .toString() - .replaceAll(RegExp(r'[^A-z0-9]'), '') - .substring(0, 6); - - return shortHash; - } - try { var tempDir = await getTemporaryDirectory(); - - // To ensure unique file names, we store each file in a folder - // with a hashed value of the download URL. - // It is necessary for a teacher to upload files with unique file names. String urlHash = generateUniqueHash(url); - String folderPath = "${tempDir.path}/$urlHash"; String savePath = "$folderPath/$filename"; - // Check if the folder exists, create it if not Directory folder = Directory(folderPath); if (!folder.existsSync()) { folder.createSync(recursive: true); } - // Check if the file already exists - File existingFile = File(savePath); - if (existingFile.existsSync()) { + if (await doesFileExist(url, filename)) { return savePath; } diff --git a/app/lib/client/client_submodules/datastorage.dart b/app/lib/client/client_submodules/datastorage.dart index 86f527e7..aebdddd1 100644 --- a/app/lib/client/client_submodules/datastorage.dart +++ b/app/lib/client/client_submodules/datastorage.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:html/parser.dart'; +import '../../shared/exceptions/client_status_exceptions.dart'; import '../../shared/types/dateispeicher_node.dart'; import '../client.dart'; @@ -12,45 +15,84 @@ class DataStorageParser { dio = dioClient; } - Future getNode(int nodeID) async { + Future searchFiles(String query) async { final response = await dio.get( - "https://start.schulportal.hessen.de/dateispeicher.php?a=view&folder=$nodeID"); - var document = parse(response.data); - List files = []; - List headers = document - .querySelectorAll("table#files thead th") - .map((e) => e.text) - .toList(); - for (var file in document.querySelectorAll("table#files tbody tr")) { - final fields = file.querySelectorAll("td"); - var name = fields[headers.indexOf("Name")].text.trim(); - var aenderung = fields[headers.indexOf("Änderung")].text.trim(); - var groesse = fields[headers.indexOf("Größe")].text.trim(); - var id = int.parse(file.attributes["data-id"]!.trim()); - files.add(FileNode( - name, - id, - "https://start.schulportal.hessen.de/dateispeicher.php?a=download&f=$id", - aenderung, - groesse)); - } - List folders = []; - for (var folder in document.querySelectorAll(".folder")) { - var name = folder.querySelector(".caption")!.text.trim(); - var desc = folder.querySelector(".desc")!.text.trim(); - var subfolders = int.tryParse(RegExp(r"\d+") - .firstMatch(folder - .querySelector("[title=\"Anzahl Ordner\"]") - ?.text - .trim() ?? - "") - ?.group(0) ?? - "") ?? - 0; - var id = int.parse(folder.attributes["data-id"]!); - folders.add(FolderNode(name, id, subfolders, desc)); + "https://start.schulportal.hessen.de/dateispeicher.php", + queryParameters: { + "q": query, + "a": "searchFiles" + }, + data: { + "q": query, + "a": "searchFiles" + }, + options: Options( + contentType: "application/x-www-form-urlencoded" + ) + ); + final data = jsonDecode(response.data); + return data[0]; + } + + Future<(List, List)> getNode(int nodeID) async { + try { + late final Response response; + try { + response = await dio.get( + "https://start.schulportal.hessen.de/dateispeicher.php?a=view&folder=$nodeID"); + } catch (e) { + throw NoConnectionException(); + } + + var document = parse(response.data); + List files = []; + List headers = document + .querySelectorAll("table#files thead th") + .map((e) => e.text) + .toList(); + for (var file in document.querySelectorAll("table#files tbody tr")) { + final fields = file.querySelectorAll("td"); + String? hinweis = + fields[headers.indexOf("Name")].querySelector("small")?.text.trim(); + if (hinweis != null) { + fields[headers.indexOf("Name")].querySelector("small")?.text = ""; + } + var name = fields[headers.indexOf("Name")].text.trim(); + var aenderung = fields[headers.indexOf("Änderung")].text.trim(); + var groesse = fields[headers.indexOf("Größe")].text.trim(); + var id = int.parse(file.attributes["data-id"]!.trim()); + files.add(FileNode( + name: name, + id: id, + downloadUrl: "https://start.schulportal.hessen.de/dateispeicher.php?a=download&f=$id", + aenderung: aenderung, + groesse: groesse, + hinweis: hinweis, + )); + } + + List folders = []; + for (var folder in document.querySelectorAll(".folder")) { + var name = folder.querySelector(".caption")!.text.trim(); + var desc = folder.querySelector(".desc")!.text.trim(); + var subfolders = int.tryParse(RegExp(r"\d+") + .firstMatch(folder + .querySelector("[title=\"Anzahl Ordner\"]") + ?.text + .trim() ?? + "") + ?.group(0) ?? + "") ?? + 0; + var id = int.parse(folder.attributes["data-id"]!); + folders.add(FolderNode(name, id, subfolders, desc)); + } + return (files, folders); + } on NoConnectionException { + rethrow; + } catch (e) { + throw LoggedOffOrUnknownException(); } - return (files, folders); } Future getRoot() async { diff --git a/app/lib/client/client_submodules/substitutions.dart b/app/lib/client/client_submodules/substitutions.dart index 40ec44c7..52fe86f3 100644 --- a/app/lib/client/client_submodules/substitutions.dart +++ b/app/lib/client/client_submodules/substitutions.dart @@ -152,7 +152,6 @@ class SubstitutionsParser { try { var dates = await getSubstitutionDates(); - debugPrint(dates.toString()); if (dates.isEmpty) { return getVplanNonAJAX(); diff --git a/app/lib/home_page.dart b/app/lib/home_page.dart index 5d1cef31..75ed5f81 100644 --- a/app/lib/home_page.dart +++ b/app/lib/home_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:sph_plan/client/client.dart'; import 'package:sph_plan/view/calendar/calendar.dart'; import 'package:sph_plan/view/conversations/conversations.dart'; +import 'package:sph_plan/view/data_storage/data_storage.dart'; import 'package:sph_plan/view/mein_unterricht/mein_unterricht.dart'; import 'package:sph_plan/view/settings/settings.dart'; import 'package:sph_plan/view/bug_report/send_bugreport.dart'; @@ -86,6 +87,17 @@ class _HomePageState extends State { enableBottomNavigation: true, enableDrawer: true, body: const MeinUnterrichtAnsicht()), + Destination( + label: "Dateispeicher", + icon: const Icon(Icons.folder_copy), + selectedIcon: const Icon(Icons.folder_copy_outlined), + isSupported: client.doesSupportFeature(SPHAppEnum.dateispeicher), + enableBottomNavigation: false, + enableDrawer: true, + action: (context) => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DataStorageAnsicht()), + )), Destination( label: "Lanis im Browser öffnen", icon: const Icon(Icons.open_in_new), @@ -307,7 +319,6 @@ class _HomePageState extends State { indexNavbarTranslationLayer.add(null); } } - return NavigationBar( destinations: barDestinations, selectedIndex: indexNavbarTranslationLayer[selectedDestinationDrawer]!, diff --git a/app/lib/shared/launch_file.dart b/app/lib/shared/launch_file.dart new file mode 100644 index 00000000..977bcdd1 --- /dev/null +++ b/app/lib/shared/launch_file.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:open_file/open_file.dart'; + +import '../client/client.dart'; + +void launchFile(BuildContext context, String url, String filename, String filesize, Function callback) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: Text("Download... $filesize"), + content: const Center( + heightFactor: 1.1, + child: CircularProgressIndicator(), + ), + ); + }); + client + .downloadFile(url, filename) + .then((filepath) { + Navigator.of(context).pop(); + + if (filepath == "") { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Fehler!"), + content: Text( + "Beim Download der Datei $filename ist ein unerwarteter Fehler aufgetreten. Wenn dieses Problem besteht, senden Sie uns bitte einen Fehlerbericht."), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + )); + } else { + OpenFile.open(filepath); + callback(); // Call the callback function after the file is opened + } + }); +} \ No newline at end of file diff --git a/app/lib/shared/types/dateispeicher_node.dart b/app/lib/shared/types/dateispeicher_node.dart index 78cc2434..deab1f82 100644 --- a/app/lib/shared/types/dateispeicher_node.dart +++ b/app/lib/shared/types/dateispeicher_node.dart @@ -1,11 +1,13 @@ class FileNode { String name; int id; + int? folderID; String downloadUrl; String groesse; String aenderung; + String? hinweis; - FileNode(this.name, this.id, this.downloadUrl, this.aenderung, this.groesse); + FileNode({required this.name, required this.id, required this.downloadUrl, this.aenderung = "", this.groesse = "", this.hinweis, this.folderID}); } class FolderNode { diff --git a/app/lib/view/data_storage/data_storage.dart b/app/lib/view/data_storage/data_storage.dart new file mode 100644 index 00000000..b860a053 --- /dev/null +++ b/app/lib/view/data_storage/data_storage.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:sph_plan/view/data_storage/root_view.dart'; + +class DataStorageAnsicht extends StatefulWidget { + const DataStorageAnsicht({super.key}); + + @override + State createState() => _DataStorageAnsichtState(); +} + +class _DataStorageAnsichtState extends State { + + + + @override + Widget build(BuildContext context) { + return const DataStorageRootView(); + } +} + diff --git a/app/lib/view/data_storage/file_listtile.dart b/app/lib/view/data_storage/file_listtile.dart new file mode 100644 index 00000000..293133d7 --- /dev/null +++ b/app/lib/view/data_storage/file_listtile.dart @@ -0,0 +1,110 @@ +import 'package:file_icon/file_icon.dart'; +import 'package:flutter/material.dart'; + +import '../../client/client.dart'; +import '../../shared/launch_file.dart'; +import '../../shared/widgets/marquee.dart'; + +enum FileExists { yes, no, loading } +extension FileExistsExtension on FileExists { + MaterialColor? get color => { + FileExists.yes: Colors.green, + FileExists.no: Colors.red, + FileExists.loading: Colors.grey, + }[this]; +} + + +class FileListTile extends StatefulWidget { + final dynamic file; + final BuildContext context; + + const FileListTile({super.key, required this.context, required this.file}); + + @override + _FileListTileState createState() => _FileListTileState(); +} + +class _FileListTileState extends State { + var exists = FileExists.loading; + + @override + void initState() { + super.initState(); + updateLocalFileStatus(); + } + + void updateLocalFileStatus() { + client.doesFileExist(widget.file.downloadUrl, widget.file.name).then((value) { + setState(() { + exists = value ? FileExists.yes : FileExists.no; + }); + }); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: MarqueeWidget(child: Text(widget.file.name)), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (widget.file.hinweis != null) Expanded(child: MarqueeWidget(child: Text(widget.file.hinweis!),)) else Text(widget.file.groesse), + const SizedBox(width: 5), + Text(widget.file.aenderung), + ], + ), + leading: Badge( + backgroundColor: exists.color, + child: FileIcon(widget.file.name, ) + ), + onTap: () => launchFile(context, widget.file.downloadUrl, widget.file.name, widget.file.groesse, updateLocalFileStatus), + ); + } +} + +class SearchFileListTile extends StatefulWidget { + final String name; + final String downloadUrl; + final BuildContext context; + + const SearchFileListTile({ + Key? key, + required this.context, + required this.name, + required this.downloadUrl + }) : super(key: key); + + @override + _SearchFileListTileState createState() => _SearchFileListTileState(); +} + +class _SearchFileListTileState extends State { + var exists = FileExists.loading; + + @override + void initState() { + super.initState(); + updateLocalFileStatus(); + } + + void updateLocalFileStatus() { + client.doesFileExist(widget.downloadUrl, widget.name).then((value) { + setState(() { + exists = value ? FileExists.yes : FileExists.no; + }); + }); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: MarqueeWidget(child: Text(widget.name)), + leading: Badge( + backgroundColor: exists.color, + child: FileIcon(widget.name), + ), + onTap: () => launchFile(context, widget.downloadUrl, widget.name, "", updateLocalFileStatus), + ); + } +} \ No newline at end of file diff --git a/app/lib/view/data_storage/folder_listtile.dart b/app/lib/view/data_storage/folder_listtile.dart new file mode 100644 index 00000000..b00e8641 --- /dev/null +++ b/app/lib/view/data_storage/folder_listtile.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:sph_plan/shared/types/dateispeicher_node.dart'; + +import 'node_view.dart'; + + +class FolderListTile extends ListTile { + final FolderNode folder; + final BuildContext context; + + const FolderListTile({super.key, required this.context, required this.folder}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(folder.name), + subtitle: Text(folder.desc, maxLines: 2, overflow: TextOverflow.ellipsis,), + leading: const Icon(Icons.folder_outlined), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DataStorageNodeView(nodeID: folder.id, title: folder.name), + ), + ); + } + ); + } +} \ No newline at end of file diff --git a/app/lib/view/data_storage/node_view.dart b/app/lib/view/data_storage/node_view.dart new file mode 100644 index 00000000..e7c30759 --- /dev/null +++ b/app/lib/view/data_storage/node_view.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../../client/client.dart'; +import '../../shared/exceptions/client_status_exceptions.dart'; +import '../../shared/types/dateispeicher_node.dart'; +import 'file_listtile.dart'; + +class DataStorageNodeView extends StatefulWidget { + final int nodeID; + final String title; + + const DataStorageNodeView({super.key, required this.nodeID, required this.title}); + + @override + State createState() => _DataStorageNodeViewState(); +} + +class _DataStorageNodeViewState extends State { + var loading = true; + var error = false; + late List files; + late List folders; + + @override + void initState() { + super.initState(); + loadItems(); + } + + void loadItems() async { + try { + var items = await client.dataStorage.getNode(widget.nodeID); + var (fileList, folderList) = items; + files = fileList; + folders = folderList; + + setState(() { + loading = false; + }); + } on LanisException catch (e) { + setState(() { + error = true; + loading = false; + }); + } + } + + List getListTiles() { + var listTiles = []; + + for (var folder in folders) { + listTiles.add(ListTile( + title: Text(folder.name), + subtitle: Text(folder.desc, maxLines: 2, overflow: TextOverflow.ellipsis,), + leading: const Icon(Icons.folder_outlined), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DataStorageNodeView(nodeID: folder.id, title: folder.name), + ), + ); + } + )); + } + + for (var file in files) { + listTiles.add(FileListTile(context: context, file: file)); + } + + return listTiles; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: loading ? const Center( + child: CircularProgressIndicator(), + ) : error ? const Center( + child: Column( + children: [ + Icon(Icons.error_outline, size: 100), + SizedBox(height: 10), + Text("Fehler beim Laden der Dateien"), + ], + ) + ) : ListView( + children: getListTiles(), + ), + ); + } +} diff --git a/app/lib/view/data_storage/root_view.dart b/app/lib/view/data_storage/root_view.dart new file mode 100644 index 00000000..0de4a2de --- /dev/null +++ b/app/lib/view/data_storage/root_view.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:sph_plan/view/data_storage/folder_listtile.dart'; + +import '../../client/client.dart'; +import '../../shared/exceptions/client_status_exceptions.dart'; +import '../../shared/types/dateispeicher_node.dart'; +import 'file_listtile.dart'; + +class DataStorageRootView extends StatefulWidget { + + const DataStorageRootView({super.key}); + + @override + State createState() => _DataStorageRootViewState(); +} + +class _DataStorageRootViewState extends State { + var loading = true; + var error = false; + late List files; + late List folders; + final SearchController searchController = SearchController(); + + @override + void initState() { + super.initState(); + loadItems(); + } + + void loadItems() async { + try { + var items = await client.dataStorage.getRoot(); + var (fileList, folderList) = items; + files = fileList; + folders = folderList; + + setState(() { + loading = false; + }); + } on LanisException catch (e) { + setState(() { + error = true; + loading = false; + }); + } + } + + List getListTiles() { + var listTiles = []; + + for (var folder in folders) { + listTiles.add(FolderListTile(context: context, folder: folder)); + } + for (var file in files) { + listTiles.add(FileListTile(context: context, file: file)); + } + return listTiles; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Datenspeicher"), + actions: const [ + AsyncSearchAnchor(), + ], + ), + body: loading ? const Center( + child: CircularProgressIndicator(), + ) : error ? const Center( + child: Column( + children: [ + Icon(Icons.error_outline, size: 100), + SizedBox(height: 10), + Text("Fehler beim Laden der Dateien"), + ], + ) + ) : ListView( + children: getListTiles(), + ), + ); + } +} + +class AsyncSearchAnchor extends StatefulWidget { + const AsyncSearchAnchor({super.key}); + + @override + State createState() => _AsyncSearchAnchorState(); +} + +class _AsyncSearchAnchorState extends State { + String? _searchingWithQuery; + late Iterable _lastOptions = []; + + @override + Widget build(BuildContext context) { + return SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, suggestionsBuilder: + (BuildContext context, SearchController controller) async { + _searchingWithQuery = controller.text; + var options = await client.dataStorage.searchFiles(_searchingWithQuery!); + + if (_searchingWithQuery != controller.text) { + return _lastOptions; + } + + _lastOptions = List.generate(options.length, (int index) { + final Map item = options[index]; + return SearchFileListTile( + context: context, + name: item["text"], + downloadUrl: "https://start.schulportal.hessen.de/dateispeicher.php?a=download&f=${item["id"]}" + ); + }); + + if (_lastOptions.isEmpty) { + _lastOptions = [ + const ListTile( + title: Text("Keine Ergebnisse"), + ) + ]; + } + + return _lastOptions; + }); + } +} diff --git a/app/lib/view/login/auth.dart b/app/lib/view/login/auth.dart index fc97b2b6..074f18c8 100644 --- a/app/lib/view/login/auth.dart +++ b/app/lib/view/login/auth.dart @@ -129,10 +129,8 @@ class LoginFormState extends State { InputDecoration(labelText: "Schule auswählen")), selectedItem: dropDownSelectedItem, onChanged: (value) { - debugPrint("changed!"); dropDownSelectedItem = value; selectedSchoolID = extractNumber(value); - debugPrint(selectedSchoolID); }, items: schoolList, ), diff --git a/app/lib/view/mein_unterricht/course_overview.dart b/app/lib/view/mein_unterricht/course_overview.dart index 88edca90..17352df8 100644 --- a/app/lib/view/mein_unterricht/course_overview.dart +++ b/app/lib/view/mein_unterricht/course_overview.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:open_file/open_file.dart'; import 'package:sph_plan/view/mein_unterricht/upload_page.dart'; import '../../client/client.dart'; +import '../../shared/launch_file.dart'; import '../../shared/widgets/error_view.dart'; import '../../shared/widgets/format_text.dart'; class CourseOverviewAnsicht extends StatefulWidget { - final String dataFetchURL; // Add the dataFetchURL property + final String dataFetchURL; final String title; const CourseOverviewAnsicht( {super.key, required this.dataFetchURL, required this.title}); @@ -118,45 +118,9 @@ class _CourseOverviewAnsichtState extends State { data["historie"][index]["files"].forEach((file) { files.add(ActionChip( label: Text(file["filename"]), - onPressed: () { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return AlertDialog( - title: Text("Download... ${file['filesize']}"), - content: const Center( - heightFactor: 1.1, - child: CircularProgressIndicator(), - ), - ); - }); - client - .downloadFile(file["url"], file["filename"]) - .then((filepath) { - Navigator.of(context).pop(); - - if (filepath == "") { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("Fehler!"), - content: Text( - "Beim Download der Datei ${file["filename"]} ist ein unerwarteter Fehler aufgetreten. Wenn dieses Problem besteht, senden Sie uns bitte einen Fehlerbericht."), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - )); - } else { - OpenFile.open(filepath); - } - }); - }, + onPressed: () => launchFile( + context, file["url"], file["filename"], file["size"], (){} + ), )); }); diff --git a/app/lib/view/settings/subsettings/clear_cache.dart b/app/lib/view/settings/subsettings/clear_cache.dart index 13861f0b..61155a95 100644 --- a/app/lib/view/settings/subsettings/clear_cache.dart +++ b/app/lib/view/settings/subsettings/clear_cache.dart @@ -41,7 +41,6 @@ class _BodyState extends State { } }); } - debugPrint(fileNum.toString()); return {'fileNum': fileNum, 'size': totalSize}; } diff --git a/app/pubspec.lock b/app/pubspec.lock index d19a5088..ee8457e8 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + file_icon: + dependency: "direct main" + description: + name: file_icon + sha256: c46b6c24d9595d18995758b90722865baeda407f56308eadd757e1ab913f50a1 + url: "https://pub.dev" + source: hosted + version: "1.0.0" file_picker: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a4150bb6..20aee7c6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: table_calendar: ^3.0.9 intl: ^0.18.1 cached_network_image: ^3.3.1 + file_icon: ^1.0.0 flutter_launcher_icons: android: false