diff --git a/README.md b/README.md index 45111f2d..49a9f9b2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ If you run into any issue while using Wispar, have a question or want to share y
  • Unpaywall
  • Crossref
  • OpenAlex
  • +
  • Journal topics database by hitfyd
  • ## Screenshots diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3803cb6d..3cbb4ef6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -134,6 +134,10 @@ "journals": "Journals", "@journals": { "description": "The journals menu button and the app bar title when in the journals screen." + }, + "countJournals": "{count, plural, =0{No journals} =1{1 journal} other{{count} journals}}", + "@countJournals": { + "placeholders": {"count": {}} }, "queries": "Queries", "@queries": { @@ -189,6 +193,8 @@ "@searchByDOI": {}, "searchByTitle": "Search by title", "@searchByTitle": {}, + "searchByTopic": "Search by topic", + "@searchByTopic":{}, "searchByISSN": "Search by ISSN", "@searchByISSN": {}, "queryHasNoNameError": "You must enter a query name in order to save it.", @@ -215,6 +221,8 @@ "@category": {}, "publisher": "Publisher", "@publisher": {}, + "publisherName": "Publisher: {publisherName}", + "@publisherName":{}, "publishedin": "Published in", "@publishedin": {}, "subjects": "Subjects", diff --git a/lib/models/journal_topics_models.dart b/lib/models/journal_topics_models.dart new file mode 100644 index 00000000..552afafc --- /dev/null +++ b/lib/models/journal_topics_models.dart @@ -0,0 +1,13 @@ +class JournalTopicsCsv { + final String journal; + final String issn; + final String eissn; + final List categories; + + JournalTopicsCsv({ + required this.journal, + required this.issn, + required this.eissn, + required this.categories, + }); +} diff --git a/lib/screens/journals_search_results_screen.dart b/lib/screens/journals_search_results_screen.dart index 93856e93..5245594a 100644 --- a/lib/screens/journals_search_results_screen.dart +++ b/lib/screens/journals_search_results_screen.dart @@ -1,25 +1,25 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../services/crossref_api.dart'; -import '../models/crossref_journals_models.dart' as Journals; -import '../widgets/journal_search_results_card.dart'; -import '../services/logs_helper.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/widgets/journal_search_results_card.dart'; +import 'package:wispar/services/logs_helper.dart'; class SearchResultsScreen extends StatefulWidget { final ListAndMore searchResults; final String searchQuery; const SearchResultsScreen({ - Key? key, + super.key, required this.searchResults, required this.searchQuery, - }) : super(key: key); + }); @override - _SearchResultsScreenState createState() => _SearchResultsScreenState(); + SearchResultsScreenState createState() => SearchResultsScreenState(); } -class _SearchResultsScreenState extends State { +class SearchResultsScreenState extends State { final logger = LogsService().logger; List items = []; bool isLoading = false; @@ -101,10 +101,12 @@ class _SearchResultsScreenState extends State { } }); } catch (e, stackTrace) { - logger.severe('Failed to load more journals.', e, stackTrace); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(AppLocalizations.of(context)!.failLoadMorePublication))); + if (mounted) { + logger.severe('Failed to load more journals.', e, stackTrace); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.failLoadMorePublication))); + } } finally { setState(() => isLoading = false); } diff --git a/lib/services/journal_topics_helper.dart b/lib/services/journal_topics_helper.dart new file mode 100644 index 00000000..82c81903 --- /dev/null +++ b/lib/services/journal_topics_helper.dart @@ -0,0 +1,46 @@ +import 'package:http/http.dart' as http; +import 'package:wispar/models/journal_topics_models.dart'; +import 'package:csv/csv.dart'; + +Future> fetchCsvCategories() async { + final url = + "https://raw.githubusercontent.com/hitfyd/ShowJCR/refs/heads/master/%E4%B8%AD%E7%A7%91%E9%99%A2%E5%88%86%E5%8C%BA%E8%A1%A8%E5%8F%8AJCR%E5%8E%9F%E5%A7%8B%E6%95%B0%E6%8D%AE%E6%96%87%E4%BB%B6/JCR2024-UTF8.csv"; + + final response = await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + throw Exception("Failed to load CSV"); + } + + final csvRows = const CsvToListConverter(eol: '\n', shouldParseNumbers: false) + .convert(response.body); + + final List entries = []; + + for (int i = 1; i < csvRows.length; i++) { + final row = csvRows[i]; + final journal = row[0].toString(); + final issn = + (row[1].toString().toUpperCase() == 'N/A') ? '' : row[1].toString(); + final eissn = + (row[2].toString().toUpperCase() == 'N/A') ? '' : row[2].toString(); + final categories = row[3].toString().split(';').map((c) { + String clean = c.replaceAll(RegExp(r'\([A-Z]+\)$'), '').trim(); + return clean + .toLowerCase() + .split(' ') + .map((word) => word.isEmpty + ? '' + : '${word[0].toUpperCase()}${word.substring(1)}') + .join(' '); + }).toList(); + + entries.add(JournalTopicsCsv( + journal: journal, + issn: issn, + eissn: eissn, + categories: categories, + )); + } + + return entries; +} diff --git a/lib/widgets/article_crossref_search_form.dart b/lib/widgets/article_crossref_search_form.dart index af4d93ee..1919b26a 100644 --- a/lib/widgets/article_crossref_search_form.dart +++ b/lib/widgets/article_crossref_search_form.dart @@ -1,17 +1,19 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import 'article_doi_search_form.dart'; -import 'article_query_search_form.dart'; -import '../services/crossref_api.dart'; -import '../screens/article_screen.dart'; -import '../services/logs_helper.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/widgets/article_doi_search_form.dart'; +import 'package:wispar/widgets/article_query_search_form.dart'; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/screens/article_screen.dart'; +import 'package:wispar/services/logs_helper.dart'; class CrossRefSearchForm extends StatefulWidget { + const CrossRefSearchForm({super.key}); + @override - _CrossRefSearchFormState createState() => _CrossRefSearchFormState(); + CrossRefSearchFormState createState() => CrossRefSearchFormState(); } -class _CrossRefSearchFormState extends State { +class CrossRefSearchFormState extends State { final logger = LogsService().logger; int selectedSearchIndex = 0; // 0 for Query, 1 for DOI final TextEditingController doiController = TextEditingController(); @@ -63,41 +65,47 @@ class _CrossRefSearchFormState extends State { final article = await CrossRefApi.getWorkByDOI(extractedDoi); // Dismiss the loading dialog - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ArticleScreen( - doi: article.doi, - title: article.title, - issn: article.issn, - abstract: article.abstract, - journalTitle: article.journalTitle, - publishedDate: article.publishedDate, - authors: article.authors, - url: article.url, - license: article.license, - licenseName: article.licenseName, - publisher: article.publisher, + if (mounted) { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ArticleScreen( + doi: article.doi, + title: article.title, + issn: article.issn, + abstract: article.abstract, + journalTitle: article.journalTitle, + publishedDate: article.publishedDate, + authors: article.authors, + url: article.url, + license: article.license, + licenseName: article.licenseName, + publisher: article.publisher, + ), ), - ), - ); + ); + } } catch (e, stackTrace) { - logger.severe( - 'Error searching by DOI for DOI ${doi}.', e, stackTrace); - Navigator.pop(context); // Close loading dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.errorOccured)), - ); + logger.severe('Error searching by DOI for DOI $doi.', e, stackTrace); + if (mounted) { + Navigator.pop(context); // Close loading dialog + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.errorOccured)), + ); + } } } } catch (e, stackTrace) { - logger.severe('Error searching articles using ${selectedSearchIndex}.', e, + logger.severe('Error searching articles using $selectedSearchIndex.', e, stackTrace); - Navigator.pop(context); // Close loading dialog - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.errorOccured)), - ); + if (mounted) { + Navigator.pop(context); // Close loading dialog + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.errorOccured)), + ); + } } } @@ -118,6 +126,16 @@ class _CrossRefSearchFormState extends State { child: LayoutBuilder( builder: (context, constraints) { return ToggleButtons( + isSelected: [ + selectedSearchIndex == 0, + selectedSearchIndex == 1, + ], + onPressed: (int index) { + setState(() { + selectedSearchIndex = index; + }); + }, + borderRadius: BorderRadius.circular(15.0), children: [ Container( width: constraints.maxWidth / 2 - 1.5, @@ -131,16 +149,6 @@ class _CrossRefSearchFormState extends State { child: Text(AppLocalizations.of(context)!.searchByDOI), ), ], - isSelected: [ - selectedSearchIndex == 0, - selectedSearchIndex == 1, - ], - onPressed: (int index) { - setState(() { - selectedSearchIndex = index; - }); - }, - borderRadius: BorderRadius.circular(15.0), ); }, ), @@ -155,7 +163,6 @@ class _CrossRefSearchFormState extends State { floatingActionButton: FloatingActionButton( onPressed: _handleSearch, child: Icon(Icons.search), - shape: CircleBorder(), ), ); } diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index dc5065b9..e7aaf41f 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -438,7 +438,6 @@ class _OpenAlexSearchFormState extends State { floatingActionButton: FloatingActionButton( onPressed: _executeSearch, child: Icon(Icons.search), - shape: CircleBorder(), ), ); } diff --git a/lib/widgets/article_search_form.dart b/lib/widgets/article_search_form.dart index 816da702..41669a16 100644 --- a/lib/widgets/article_search_form.dart +++ b/lib/widgets/article_search_form.dart @@ -1,13 +1,15 @@ import 'package:flutter/material.dart'; -import './article_crossref_search_form.dart'; -import './article_openAlex_search_form.dart'; +import 'package:wispar/widgets/article_crossref_search_form.dart'; +import 'package:wispar/widgets/article_openAlex_search_form.dart'; class ArticleSearchScreen extends StatefulWidget { + const ArticleSearchScreen({super.key}); + @override - _ArticleSearchScreenState createState() => _ArticleSearchScreenState(); + ArticleSearchScreenState createState() => ArticleSearchScreenState(); } -class _ArticleSearchScreenState extends State { +class ArticleSearchScreenState extends State { int selectedProviderIndex = 0; // 0 = OpenAlex, 1 = Crossref @override diff --git a/lib/widgets/journal_header.dart b/lib/widgets/journal_header.dart index aeb0df73..55311c45 100644 --- a/lib/widgets/journal_header.dart +++ b/lib/widgets/journal_header.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../models/crossref_journals_models.dart' as Journals; -import './journal_follow_button.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/widgets/journal_follow_button.dart'; class JournalInfoHeader extends SliverPersistentHeaderDelegate { final String title; @@ -57,8 +57,9 @@ class JournalInfoHeader extends SliverPersistentHeaderDelegate { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: 8.0), - Text( - '${AppLocalizations.of(context)!.publisher}: ${publisher}'), + if (publisher.isNotEmpty) + Text(AppLocalizations.of(context)! + .publisherName(publisher)), Text('ISSN: ${issn}'), SizedBox(height: 8.0), ], diff --git a/lib/widgets/journal_search_form.dart b/lib/widgets/journal_search_form.dart index 089533a8..bd07681e 100644 --- a/lib/widgets/journal_search_form.dart +++ b/lib/widgets/journal_search_form.dart @@ -1,48 +1,32 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../screens/journals_search_results_screen.dart'; -import '../services/crossref_api.dart'; -import '../models/crossref_journals_models.dart' as Journals; -import '../services/logs_helper.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/screens/journals_search_results_screen.dart'; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/widgets/journal_topics_widget.dart'; +import 'package:wispar/models/journal_topics_models.dart'; +import 'package:wispar/services/journal_topics_helper.dart'; class JournalSearchForm extends StatefulWidget { + const JournalSearchForm({super.key}); + @override - _JournalSearchFormState createState() => _JournalSearchFormState(); + JournalSearchFormState createState() => JournalSearchFormState(); } -class _JournalSearchFormState extends State { +class JournalSearchFormState extends State { final logger = LogsService().logger; bool saveQuery = false; - int selectedSearchIndex = 0; // 0 for 'name', 1 for 'issn' + int selectedSearchIndex = 0; // 0 for 'name', 1 for topics, 2 for 'issn' late Journals.Item selectedJournal; - TextEditingController _searchController = TextEditingController(); + final TextEditingController _searchController = TextEditingController(); + Map>? _topicCategories; + bool showCategories = false; @override void initState() { super.initState(); - - _searchController.addListener(() { - if (selectedSearchIndex == 1) { - /*String text = _searchController.text; - - // Limit input to 9 characters - if (text.length > 9) { - _searchController.value = TextEditingValue( - text: text.substring(0, 9), - selection: TextSelection.collapsed(offset: 9), - ); - return; - } - - // Automatically add a dash after the first 4 digits - if (text.length == 4 && !text.contains('-')) { - _searchController.value = TextEditingValue( - text: '${text}-', - selection: TextSelection.collapsed(offset: text.length + 1), - ); - }*/ - } - }); } @override @@ -55,11 +39,10 @@ class _JournalSearchFormState extends State { Widget build(BuildContext context) { return Scaffold( body: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(6.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 16), Center( child: LayoutBuilder( builder: (context, constraints) { @@ -67,61 +50,81 @@ class _JournalSearchFormState extends State { isSelected: [ selectedSearchIndex == 0, selectedSearchIndex == 1, + selectedSearchIndex == 2, ], - onPressed: (int index) { + onPressed: (index) { setState(() { selectedSearchIndex = index; _searchController.clear(); + if (index == 1) _loadTopics(); }); }, + borderRadius: BorderRadius.circular(15), children: [ Container( - width: constraints.maxWidth / 2 - 1.5, + width: constraints.maxWidth / 3 - 1.5, + alignment: Alignment.center, + child: Text( + AppLocalizations.of(context)!.searchByTitle, + textAlign: TextAlign.center, + ), + ), + Container( + width: constraints.maxWidth / 3 - 1.5, alignment: Alignment.center, - child: - Text(AppLocalizations.of(context)!.searchByTitle), + child: Text( + AppLocalizations.of(context)!.searchByTopic, + textAlign: TextAlign.center, + ), ), Container( - width: constraints.maxWidth / 2 - 1.5, + width: constraints.maxWidth / 3 - 1.5, alignment: Alignment.center, - child: Text(AppLocalizations.of(context)!.searchByISSN), + child: Text( + AppLocalizations.of(context)!.searchByISSN, + textAlign: TextAlign.center, + ), ), ], - borderRadius: BorderRadius.circular(15.0), ); }, ), ), - SizedBox(height: 32), - TextField( - controller: _searchController, - decoration: InputDecoration( - labelText: selectedSearchIndex == 0 - ? AppLocalizations.of(context)!.journaltitle - : 'ISSN', - border: OutlineInputBorder(), - ), - ), SizedBox(height: 16), + if (selectedSearchIndex == 1) + _topicCategories == null + ? Expanded(child: Center(child: CircularProgressIndicator())) + : TopicsListWidget(categories: _topicCategories!), + if (selectedSearchIndex != 1) + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: selectedSearchIndex == 0 + ? AppLocalizations.of(context)!.journaltitle + : "ISSN", + border: OutlineInputBorder(), + ), + ), ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - String query = _searchController.text.trim(); - if (query.isNotEmpty) { - _handleSearch(query); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(AppLocalizations.of(context)!.emptySearchQuery)), - ); - } - }, - child: Icon(Icons.search), - shape: CircleBorder(), - ), + floatingActionButton: selectedSearchIndex == 1 + ? null + : FloatingActionButton( + onPressed: () { + String query = _searchController.text.trim(); + if (query.isNotEmpty) { + _handleSearch(query); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.emptySearchQuery)), + ); + } + }, + child: Icon(Icons.search), + ), ); } @@ -142,30 +145,51 @@ class _JournalSearchFormState extends State { ListAndMore searchResults; if (selectedSearchIndex == 0) { searchResults = await CrossRefApi.queryJournalsByName(query); - } else if (selectedSearchIndex == 1) { + } else if (selectedSearchIndex == 2) { searchResults = await CrossRefApi.queryJournalsByISSN(query); } else { throw Exception('Invalid search type selected'); } - Navigator.pop(context); + if (mounted) { + Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SearchResultsScreen( - searchResults: searchResults, - searchQuery: query, + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SearchResultsScreen( + searchResults: searchResults, + searchQuery: query, + ), ), - ), - ); + ); + } } catch (e, stackTrace) { logger.severe("Unable to search for journals.", e, stackTrace); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.journalSearchError)), - ); - Navigator.pop(context); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.journalSearchError)), + ); + Navigator.pop(context); + } + } + } + + void _loadTopics() async { + setState(() => _topicCategories = null); + try { + final entries = await fetchCsvCategories(); + final Map> grouped = {}; + for (final e in entries) { + for (final c in e.categories) { + grouped.putIfAbsent(c, () => []).add(e); + } + } + if (mounted) setState(() => _topicCategories = grouped); + } catch (e) { + logger.severe("Failed to load topics: $e"); } } } diff --git a/lib/widgets/journal_search_results_card.dart b/lib/widgets/journal_search_results_card.dart index a1901d6f..cd764d25 100644 --- a/lib/widgets/journal_search_results_card.dart +++ b/lib/widgets/journal_search_results_card.dart @@ -1,24 +1,23 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../models/crossref_journals_models.dart' as Journals; -import '../services/database_helper.dart'; -import '../screens/journals_details_screen.dart'; -import './journal_follow_button.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/screens/journals_details_screen.dart'; +import 'package:wispar/widgets/journal_follow_button.dart'; class JournalsSearchResultCard extends StatefulWidget { final Journals.Item item; final bool isFollowed; const JournalsSearchResultCard( - {Key? key, required this.item, required this.isFollowed}) - : super(key: key); + {super.key, required this.item, required this.isFollowed}); @override - _JournalsSearchResultCardState createState() => - _JournalsSearchResultCardState(); + JournalsSearchResultCardState createState() => + JournalsSearchResultCardState(); } -class _JournalsSearchResultCardState extends State { +class JournalsSearchResultCardState extends State { late bool _isFollowed = false; // Initialize with false by default @override @@ -56,8 +55,9 @@ class _JournalsSearchResultCardState extends State { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${AppLocalizations.of(context)!.publisher}: ${widget.item.publisher}'), + if (widget.item.publisher.isNotEmpty) + Text(AppLocalizations.of(context)! + .publisherName(widget.item.publisher)), if (widget.item.issn.isNotEmpty) Text('ISSN: ${widget.item.issn.toSet().join(', ')}'), ], diff --git a/lib/widgets/journal_topics_widget.dart b/lib/widgets/journal_topics_widget.dart new file mode 100644 index 00000000..92e39591 --- /dev/null +++ b/lib/widgets/journal_topics_widget.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:wispar/models/journal_topics_models.dart'; +import 'package:wispar/screens/journals_search_results_screen.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; + +class TopicsListWidget extends StatelessWidget { + final Map> categories; + + const TopicsListWidget({super.key, required this.categories}); + + @override + Widget build(BuildContext context) { + final keys = categories.keys.toList()..sort(); + + return Expanded( + child: ListView.builder( + itemCount: keys.length, + itemBuilder: (context, index) { + final category = keys[index]; + final journals = categories[category]!; + + // Converts the topics into a Journals.Item for the results screen + final List converted = journals.map((j) { + return Journals.Item( + title: j.journal, + issn: [j.issn, j.eissn].where((s) => s.isNotEmpty).toList(), + publisher: '', + lastStatusCheckTime: 0, + counts: Journals.Counts( + currentDois: 0, + backfileDois: 0, + totalDois: 0, + ), + breakdowns: Journals.Breakdowns( + doisByIssuedYear: [], + ), + coverage: {}, + coverageType: Journals.CoverageType( + all: {}, + backfile: {}, + current: {}, + ), + flags: {}, + issnType: [], + ); + }).toList(); + + return Card( + child: ListTile( + title: Text(category), + subtitle: Text( + AppLocalizations.of(context)!.countJournals(journals.length)), + trailing: Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SearchResultsScreen( + searchQuery: category, + searchResults: ListAndMore( + list: converted, + hasMore: false, + totalResults: converted.length, + ), + ), + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 63ad86f6..8315e3ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + csv: + dependency: "direct main" + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" cupertino_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 40f7a609..1aa48ca0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: flutter_markdown_plus: ^1.0.3 path_provider: ^2.1.5 archive: ^4.0.7 + csv: ^6.0.0 dev_dependencies: flutter_test: