From 17d09315ea19c5faa1d47647511591605af1c2d4 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:07:18 -0500 Subject: [PATCH 01/10] Use infinite_scroll_pagination package --- .../article_search_results_screen.dart | 236 +++++----- ...ossref_journals_search_results_screen.dart | 111 +++++ lib/screens/journals_details_screen.dart | 435 ++++++++---------- .../journals_search_results_screen.dart | 118 ----- lib/services/crossref_api.dart | 273 +++++------ lib/widgets/article_openAlex_search_form.dart | 33 +- lib/widgets/article_query_search_form.dart | 20 +- lib/widgets/search_query_card.dart | 12 - pubspec.lock | 24 + pubspec.yaml | 1 + 10 files changed, 573 insertions(+), 690 deletions(-) create mode 100644 lib/screens/crossref_journals_search_results_screen.dart delete mode 100644 lib/screens/journals_search_results_screen.dart diff --git a/lib/screens/article_search_results_screen.dart b/lib/screens/article_search_results_screen.dart index 879145dd..a6062a53 100644 --- a/lib/screens/article_search_results_screen.dart +++ b/lib/screens/article_search_results_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:wispar/widgets/publication_card/publication_card.dart'; import 'package:wispar/screens/publication_card_settings_screen.dart'; @@ -10,32 +11,28 @@ import 'package:wispar/services/logs_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ArticleSearchResultsScreen extends StatefulWidget { - final List initialSearchResults; - final bool initialHasMore; final Map queryParams; final String source; const ArticleSearchResultsScreen({ super.key, - required this.initialSearchResults, - required this.initialHasMore, required this.queryParams, required this.source, }); @override - ArticleSearchResultsScreenState createState() => - ArticleSearchResultsScreenState(); + State createState() => + _ArticleSearchResultsScreenState(); } -class ArticleSearchResultsScreenState +class _ArticleSearchResultsScreenState extends State { final logger = LogsService().logger; - late List _searchResults; - final ScrollController _scrollController = ScrollController(); - bool _isLoadingMore = false; - bool _hasMoreResults = true; - int _currentOpenAlexPage = 2; + + late final PagingController _pagingController; + + String? _latestCrossrefCursor; + int _currentOpenAlexPage = 1; SwipeAction _swipeLeftAction = SwipeAction.hide; SwipeAction _swipeRightAction = SwipeAction.favorite; @@ -50,127 +47,99 @@ class ArticleSearchResultsScreenState @override void initState() { super.initState(); - _searchResults = widget.initialSearchResults; - _hasMoreResults = widget.initialHasMore; - _loadCardPreferences(); + _pagingController = PagingController( + getNextPageKey: (state) { + if (widget.source == 'Crossref') { + if (state.pages == null || state.pages!.isEmpty) { + return '*'; + } + return _latestCrossrefCursor; + } else { + if (state.pages == null || state.pages!.isEmpty) { + return 1; + } + return _currentOpenAlexPage; + } + }, + fetchPage: _fetchPage, + ); - _scrollController.addListener(() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 70 && - !_isLoadingMore && - _hasMoreResults) { - _loadMoreResults(); - } - }); + _loadCardPreferences(); } - Future _loadCardPreferences() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - final leftActionName = - prefs.getString('swipeLeftAction') ?? SwipeAction.hide.name; - final rightActionName = - prefs.getString('swipeRightAction') ?? SwipeAction.favorite.name; - - SwipeAction newLeftAction = SwipeAction.hide; - SwipeAction newRightAction = SwipeAction.favorite; - + Future> _fetchPage(dynamic pageKey) async { try { - newLeftAction = SwipeAction.values.byName(leftActionName); - } catch (_) { - newLeftAction = SwipeAction.hide; - } - try { - newRightAction = SwipeAction.values.byName(rightActionName); - } catch (_) { - newRightAction = SwipeAction.favorite; - } - - if (mounted) { - setState(() { - _swipeLeftAction = newLeftAction; - _swipeRightAction = newRightAction; - _showJournalTitle = - prefs.getBool(PublicationCardSettingsScreen.showJournalTitleKey) ?? - true; - _showPublicationDate = prefs.getBool( - PublicationCardSettingsScreen.showPublicationDateKey) ?? - true; - _showAuthorNames = - prefs.getBool(PublicationCardSettingsScreen.showAuthorNamesKey) ?? - true; - _showLicense = - prefs.getBool(PublicationCardSettingsScreen.showLicenseKey) ?? true; - _showOptionsMenu = - prefs.getBool(PublicationCardSettingsScreen.showOptionsMenuKey) ?? - true; - _showFavoriteButton = prefs - .getBool(PublicationCardSettingsScreen.showFavoriteButtonKey) ?? - true; - }); - } - } - - Future _loadMoreResults() async { - if (_isLoadingMore || !_hasMoreResults) return; - - setState(() { - _isLoadingMore = true; - }); + if (widget.source == 'Crossref') { + final response = await CrossRefApi.getWorksByQuery( + queryParams: widget.queryParams, + cursor: pageKey ?? '*', + ); - try { - List newResults; - bool hasMore = false; + _latestCrossrefCursor = + response.nextCursor == null || response.items.isEmpty + ? null + : response.nextCursor; - if (widget.source == 'Crossref') { - final ListAndMore response = - await CrossRefApi.getWorksByQuery(widget.queryParams); - newResults = response.list; - hasMore = - response.hasMore && _searchResults.length < response.totalResults; + return response.items; } else { - newResults = await OpenAlexApi.getOpenAlexWorksByQuery( + final newItems = await OpenAlexApi.getOpenAlexWorksByQuery( widget.queryParams['query'] ?? '', widget.queryParams['scope'] ?? 1, widget.queryParams['sortField'], widget.queryParams['sortOrder'], widget.queryParams['dateFilter'], - page: _currentOpenAlexPage, + page: pageKey, ); - if (newResults.isNotEmpty) { - _currentOpenAlexPage++; - - hasMore = newResults.length >= 25; - } else { - hasMore = false; + if (newItems.isNotEmpty) { + _currentOpenAlexPage = pageKey + 1; } - } - setState(() { - _searchResults.addAll(newResults); - _hasMoreResults = hasMore; - }); + return newItems; + } } catch (e, stackTrace) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.failedLoadMoreResults)), - ); logger.severe( - 'Failed to load more article search results.', e, stackTrace); - } finally { - setState(() { - _isLoadingMore = false; - }); + 'Failed to fetch article search results.', + e, + stackTrace, + ); + rethrow; } } - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); + Future _loadCardPreferences() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + + final leftActionName = + prefs.getString('swipeLeftAction') ?? SwipeAction.hide.name; + final rightActionName = + prefs.getString('swipeRightAction') ?? SwipeAction.favorite.name; + + if (!mounted) return; + + setState(() { + _swipeLeftAction = SwipeAction.values.byName(leftActionName); + _swipeRightAction = SwipeAction.values.byName(rightActionName); + + _showJournalTitle = + prefs.getBool(PublicationCardSettingsScreen.showJournalTitleKey) ?? + true; + _showPublicationDate = + prefs.getBool(PublicationCardSettingsScreen.showPublicationDateKey) ?? + true; + _showAuthorNames = + prefs.getBool(PublicationCardSettingsScreen.showAuthorNamesKey) ?? + true; + _showLicense = + prefs.getBool(PublicationCardSettingsScreen.showLicenseKey) ?? true; + _showOptionsMenu = + prefs.getBool(PublicationCardSettingsScreen.showOptionsMenuKey) ?? + true; + _showFavoriteButton = + prefs.getBool(PublicationCardSettingsScreen.showFavoriteButtonKey) ?? + true; + }); } @override @@ -179,22 +148,14 @@ class ArticleSearchResultsScreenState appBar: AppBar( title: Text(AppLocalizations.of(context)!.searchresults), ), - body: _searchResults.isNotEmpty - ? ListView.builder( - controller: _scrollController, - itemCount: _searchResults.length + (_hasMoreResults ? 1 : 0), - cacheExtent: 1000.0, - itemBuilder: (context, index) { - if (index == _searchResults.length) { - return Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: CircularProgressIndicator(), - ), - ); - } - - final item = _searchResults[index]; + body: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) { + return PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { return PublicationCard( title: item.title, abstract: item.abstract, @@ -217,10 +178,25 @@ class ArticleSearchResultsScreenState showFavoriteButton: _showFavoriteButton, ); }, - ) - : Center( - child: Text(AppLocalizations.of(context)!.noresultsfound), + firstPageProgressIndicatorBuilder: (_) => + const Center(child: CircularProgressIndicator()), + newPageProgressIndicatorBuilder: (_) => + const Center(child: CircularProgressIndicator()), + noItemsFoundIndicatorBuilder: (_) => Center( + child: Text( + AppLocalizations.of(context)!.noresultsfound, + ), + ), ), + ); + }, + ), ); } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } } diff --git a/lib/screens/crossref_journals_search_results_screen.dart b/lib/screens/crossref_journals_search_results_screen.dart new file mode 100644 index 00000000..172a9d0d --- /dev/null +++ b/lib/screens/crossref_journals_search_results_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/services/crossref_api.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/widgets/journal_search_results_card.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +class CrossrefJournalResultsScreen extends StatefulWidget { + final String searchQuery; + + const CrossrefJournalResultsScreen({ + super.key, + required this.searchQuery, + }); + + @override + CrossrefJournalResultsScreenState createState() => + CrossrefJournalResultsScreenState(); +} + +class CrossrefJournalResultsScreenState + extends State { + final logger = LogsService().logger; + late final PagingController _pagingController; + + String? _latestNextCursor; + + @override + void initState() { + super.initState(); + + _pagingController = PagingController( + getNextPageKey: (state) { + if (state.pages == null || state.pages!.isEmpty) { + return '*'; + } + return _latestNextCursor; + }, + fetchPage: _fetchPage, + ); + } + + Future> _fetchPage(String? cursor) async { + try { + final issnRegex = RegExp(r'^\d{4}-\d{3}[\dXx]$'); + + if (issnRegex.hasMatch(widget.searchQuery.trim())) { + final journal = + await CrossRefApi.queryJournalByISSN(widget.searchQuery.trim()); + + if (journal != null) { + return [journal]; + } else { + return []; + } + } + final response = await CrossRefApi.queryJournalsByName( + query: widget.searchQuery, + cursor: cursor ?? '*', + ); + + _latestNextCursor = response.nextCursor == null || response.items.isEmpty + ? null + : response.nextCursor; + + final filteredItems = response.items.where((item) { + return item.issn.isNotEmpty; + }).toList(); + + return filteredItems; + } catch (e, stackTrace) { + logger.severe( + 'Failed to fetch journals', + e, + stackTrace, + ); + rethrow; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.searchresults), + ), + body: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) { + return PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => JournalsSearchResultCard( + item: item, + isFollowed: false, + ), + ), + ); + }, + ), + ); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/journals_details_screen.dart b/lib/screens/journals_details_screen.dart index 794ce585..e815b3fa 100644 --- a/lib/screens/journals_details_screen.dart +++ b/lib/screens/journals_details_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:wispar/services/crossref_api.dart'; import 'package:wispar/services/abstract_helper.dart'; @@ -27,17 +28,18 @@ class JournalDetailsScreen extends StatefulWidget { }); @override - JournalDetailsScreenState createState() => JournalDetailsScreenState(); + State createState() => _JournalDetailsScreenState(); } -class JournalDetailsScreenState extends State { +class _JournalDetailsScreenState extends State { final logger = LogsService().logger; - late List allWorks; - bool isLoading = false; - late ScrollController _scrollController; - bool hasMoreResults = true; + + late final PagingController _pagingController; + + String? _latestCursor; + Map abstractCache = {}; - late bool _isFollowed = false; + bool _isFollowed = false; SwipeAction _swipeLeftAction = SwipeAction.hide; SwipeAction _swipeRightAction = SwipeAction.favorite; @@ -52,274 +54,243 @@ class JournalDetailsScreenState extends State { @override void initState() { super.initState(); - allWorks = []; - CrossRefApi.resetJournalWorksCursor(); - _loadAllData(); - _scrollController = ScrollController(); - _scrollController.addListener(_onScroll); - _loadMoreWorks(); + _pagingController = PagingController( + getNextPageKey: (state) { + if (state.pages == null || state.pages!.isEmpty) { + return '*'; + } + return _latestCursor; + }, + fetchPage: _fetchPage, + ); + + _initialize(); } - Future _loadAllData() async { + Future _initialize() async { await _loadCardPreferences(); await _initFollowStatus(); - await _loadMoreWorks(); + } + + Future> _fetchPage(String? cursor) async { + try { + final response = await CrossRefApi.getJournalWorks( + issnList: widget.issn, + cursor: cursor ?? '*', + ); + + _latestCursor = response.nextCursor == null || response.items.isEmpty + ? null + : response.nextCursor; + + return response.items; + } catch (e, stackTrace) { + logger.severe( + 'Failed to load publications for journal ${widget.issn}.', + e, + stackTrace, + ); + rethrow; + } } Future _loadCardPreferences() async { SharedPreferences prefs = await SharedPreferences.getInstance(); - final leftActionName = - prefs.getString('swipeLeftAction') ?? SwipeAction.hide.name; - final rightActionName = - prefs.getString('swipeRightAction') ?? SwipeAction.favorite.name; + if (!mounted) return; - SwipeAction newLeftAction = SwipeAction.hide; - SwipeAction newRightAction = SwipeAction.favorite; + setState(() { + _swipeLeftAction = SwipeAction.values + .byName(prefs.getString('swipeLeftAction') ?? SwipeAction.hide.name); - try { - newLeftAction = SwipeAction.values.byName(leftActionName); - } catch (_) { - newLeftAction = SwipeAction.hide; - } - try { - newRightAction = SwipeAction.values.byName(rightActionName); - } catch (_) { - newRightAction = SwipeAction.favorite; - } + _swipeRightAction = SwipeAction.values.byName( + prefs.getString('swipeRightAction') ?? SwipeAction.favorite.name); - if (mounted) { - setState(() { - _swipeLeftAction = newLeftAction; - _swipeRightAction = newRightAction; - _showJournalTitle = - prefs.getBool(PublicationCardSettingsScreen.showJournalTitleKey) ?? - true; - _showPublicationDate = prefs.getBool( - PublicationCardSettingsScreen.showPublicationDateKey) ?? - true; - _showAuthorNames = - prefs.getBool(PublicationCardSettingsScreen.showAuthorNamesKey) ?? - true; - _showLicense = - prefs.getBool(PublicationCardSettingsScreen.showLicenseKey) ?? true; - _showOptionsMenu = - prefs.getBool(PublicationCardSettingsScreen.showOptionsMenuKey) ?? - true; - _showFavoriteButton = prefs - .getBool(PublicationCardSettingsScreen.showFavoriteButtonKey) ?? - true; - }); - } + _showJournalTitle = + prefs.getBool(PublicationCardSettingsScreen.showJournalTitleKey) ?? + true; + _showPublicationDate = + prefs.getBool(PublicationCardSettingsScreen.showPublicationDateKey) ?? + true; + _showAuthorNames = + prefs.getBool(PublicationCardSettingsScreen.showAuthorNamesKey) ?? + true; + _showLicense = + prefs.getBool(PublicationCardSettingsScreen.showLicenseKey) ?? true; + _showOptionsMenu = + prefs.getBool(PublicationCardSettingsScreen.showOptionsMenuKey) ?? + true; + _showFavoriteButton = + prefs.getBool(PublicationCardSettingsScreen.showFavoriteButtonKey) ?? + true; + }); } Future _initFollowStatus() async { final dbHelper = DatabaseHelper(); int? journalId = await dbHelper.getJournalIdByIssns(widget.issn); bool isFollowed = false; + if (journalId != null) { isFollowed = await dbHelper.isJournalFollowed(journalId); } - if (mounted) { - setState(() { - _isFollowed = isFollowed; - }); - } + if (!mounted) return; + + setState(() { + _isFollowed = isFollowed; + }); } @override Widget build(BuildContext context) { return Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - SliverAppBar( - pinned: true, - expandedHeight: 200.0, - flexibleSpace: FlexibleSpaceBar( - titlePadding: const EdgeInsets.symmetric( - horizontal: 50, - vertical: 8.0, + body: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) { + return CustomScrollView( + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: 250.0, + flexibleSpace: FlexibleSpaceBar( + centerTitle: true, + titlePadding: + const EdgeInsets.symmetric(horizontal: 40, vertical: 10), + title: Text( + widget.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16.0, + color: Colors.white, + ), + textAlign: TextAlign.center, + maxLines: 5, + overflow: TextOverflow.fade, + ), + ), + backgroundColor: Colors.deepPurple, ), - centerTitle: true, - title: Text( - widget.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, - color: Colors.white, + SliverPersistentHeader( + delegate: JournalInfoHeader( + title: widget.title, + publisher: widget.publisher, + issn: widget.issn.toSet().join(', '), + isFollowed: _isFollowed, + onFollowStatusChanged: (isFollowed) { + setState(() { + _isFollowed = isFollowed; + widget.onFollowStatusChanged?.call(isFollowed); + }); + }, ), - textAlign: TextAlign.center, - overflow: TextOverflow.fade, ), - ), - backgroundColor: Colors.deepPurple, - ), - SliverPersistentHeader( - delegate: JournalInfoHeader( - title: widget.title, - publisher: widget.publisher, - issn: widget.issn.toSet().join(', '), - isFollowed: _isFollowed, - onFollowStatusChanged: (isFollowed) { - setState(() { - _isFollowed = isFollowed; - widget.onFollowStatusChanged?.call(isFollowed); - }); - }, - ), - pinned: false, - ), - SliverPersistentHeader( - delegate: PersistentLatestPublicationsHeader(), - pinned: true, - ), - allWorks.isEmpty && !isLoading - ? SliverFillRemaining( - child: Center( - child: Text( - AppLocalizations.of(context)!.noPublicationFound, - style: const TextStyle(fontSize: 16.0), - ), - ), - ) - : SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index < allWorks.length) { - final work = allWorks[index]; - String? cachedAbstract = abstractCache[work.doi]; - if (cachedAbstract == null) { - return FutureBuilder( - future: AbstractHelper.buildAbstract( - context, work.abstract), - builder: (context, abstractSnapshot) { - if (abstractSnapshot.connectionState == - ConnectionState.waiting) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center( - child: CircularProgressIndicator()), - ); - } else if (abstractSnapshot.hasError) { - return Center( - child: Text( - 'Error: ${abstractSnapshot.error}')); - } else if (!abstractSnapshot.hasData) { - return const Center( - child: Text('No abstract available')); - } else { - String formattedAbstract = - abstractSnapshot.data!; - // Cache the abstract - abstractCache[work.doi] = formattedAbstract; + SliverPersistentHeader( + delegate: PersistentLatestPublicationsHeader(), + pinned: true, + ), + PagedSliverList( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, work, index) { + final cachedAbstract = abstractCache[work.doi]; - return PublicationCard( - title: work.title, - abstract: formattedAbstract, - journalTitle: work.journalTitle, - issn: widget.issn, - publishedDate: work.publishedDate, - doi: work.doi, - authors: work.authors, - url: work.primaryUrl, - license: work.license, - licenseName: work.licenseName, - publisher: work.publisher, - swipeLeftAction: _swipeLeftAction, - swipeRightAction: _swipeRightAction, - showJournalTitle: _showJournalTitle, - showPublicationDate: _showPublicationDate, - showAuthorNames: _showAuthorNames, - showLicense: _showLicense, - showOptionsMenu: _showOptionsMenu, - showFavoriteButton: _showFavoriteButton, - ); - } - }, - ); - } else { - return PublicationCard( - title: work.title, - abstract: cachedAbstract, - journalTitle: work.journalTitle, - issn: widget.issn, - publishedDate: work.publishedDate, - doi: work.doi, - authors: work.authors, - url: work.primaryUrl, - license: work.license, - licenseName: work.licenseName, - publisher: work.publisher, - swipeLeftAction: _swipeLeftAction, - swipeRightAction: _swipeRightAction, - showJournalTitle: _showJournalTitle, - showPublicationDate: _showPublicationDate, - showAuthorNames: _showAuthorNames, - showLicense: _showLicense, - showOptionsMenu: _showOptionsMenu, - showFavoriteButton: _showFavoriteButton, + if (cachedAbstract != null) { + return PublicationCard( + title: work.title, + abstract: cachedAbstract, + journalTitle: work.journalTitle, + issn: widget.issn, + publishedDate: work.publishedDate, + doi: work.doi, + authors: work.authors, + url: work.primaryUrl, + license: work.license, + licenseName: work.licenseName, + publisher: work.publisher, + swipeLeftAction: _swipeLeftAction, + swipeRightAction: _swipeRightAction, + showJournalTitle: _showJournalTitle, + showPublicationDate: _showPublicationDate, + showAuthorNames: _showAuthorNames, + showLicense: _showLicense, + showOptionsMenu: _showOptionsMenu, + showFavoriteButton: _showFavoriteButton, + ); + } + return FutureBuilder( + future: + AbstractHelper.buildAbstract(context, work.abstract), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(32.0), + child: Center(child: CircularProgressIndicator()), ); } - } else if (hasMoreResults) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: CircularProgressIndicator()), + + final formattedAbstract = snapshot.data ?? ''; + + if (snapshot.hasData) { + abstractCache[work.doi] = formattedAbstract; + } + + return PublicationCard( + title: work.title, + abstract: formattedAbstract, + journalTitle: work.journalTitle, + issn: widget.issn, + publishedDate: work.publishedDate, + doi: work.doi, + authors: work.authors, + url: work.primaryUrl, + license: work.license, + licenseName: work.licenseName, + publisher: work.publisher, + swipeLeftAction: _swipeLeftAction, + swipeRightAction: _swipeRightAction, + showJournalTitle: _showJournalTitle, + showPublicationDate: _showPublicationDate, + showAuthorNames: _showAuthorNames, + showLicense: _showLicense, + showOptionsMenu: _showOptionsMenu, + showFavoriteButton: _showFavoriteButton, ); - } else { - return const SizedBox.shrink(); - } - }, - childCount: allWorks.length + (hasMoreResults ? 1 : 0), + }, + ); + }, + firstPageProgressIndicatorBuilder: (_) => const SizedBox( + height: 200, + child: Center( + child: CircularProgressIndicator(), + ), + ), + newPageProgressIndicatorBuilder: (_) => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator())), + noItemsFoundIndicatorBuilder: (_) => SizedBox( + height: 200, + child: Center( + child: Text( + AppLocalizations.of(context)!.noPublicationFound, + ), + ), ), ), - ], + ), + ], + ); + }, ), ); } - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8 && - !isLoading && - hasMoreResults) { - _loadMoreWorks(); - } - } - - Future _loadMoreWorks() async { - setState(() => isLoading = true); - - try { - ListAndMore newWorks = - await CrossRefApi.getJournalWorks(widget.issn); - - setState(() { - allWorks.addAll(newWorks.list); - hasMoreResults = newWorks.hasMore && newWorks.list.isNotEmpty; - }); - } catch (e, stackTrace) { - logger.severe( - 'Failed to load more publications for journal ${widget.issn}.', - e, - stackTrace); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.failLoadMorePublication), - )); - } - } finally { - if (mounted) { - setState(() => isLoading = false); - } - } - } - @override void dispose() { - _scrollController.dispose(); + _pagingController.dispose(); super.dispose(); } } diff --git a/lib/screens/journals_search_results_screen.dart b/lib/screens/journals_search_results_screen.dart deleted file mode 100644 index 93856e93..00000000 --- a/lib/screens/journals_search_results_screen.dart +++ /dev/null @@ -1,118 +0,0 @@ -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'; - -class SearchResultsScreen extends StatefulWidget { - final ListAndMore searchResults; - final String searchQuery; - - const SearchResultsScreen({ - Key? key, - required this.searchResults, - required this.searchQuery, - }) : super(key: key); - - @override - _SearchResultsScreenState createState() => _SearchResultsScreenState(); -} - -class _SearchResultsScreenState extends State { - final logger = LogsService().logger; - List items = []; - bool isLoading = false; - late ScrollController _scrollController; - bool hasMoreResults = true; - - @override - void initState() { - super.initState(); - _scrollController = ScrollController(); - _scrollController.addListener(_onScroll); - - if (widget.searchResults.list.isNotEmpty) { - items = widget.searchResults.list; - hasMoreResults = widget.searchResults.hasMore; - } else { - hasMoreResults = false; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.searchresults), - ), - body: items.isEmpty - ? Center( - child: Text(AppLocalizations.of(context)!.noPublicationFound), - ) - : ListView.builder( - itemCount: items.length + (isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index == items.length && isLoading) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Center(child: CircularProgressIndicator()), - ); - } else { - Journals.Item currentItem = items[index]; - - // Skip invalid items - if (currentItem.issn.isEmpty) return SizedBox.shrink(); - - return JournalsSearchResultCard( - key: UniqueKey(), - item: currentItem, - isFollowed: false, - ); - } - }, - controller: _scrollController, - ), - ); - } - - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8 && - !isLoading && - hasMoreResults) { - loadMoreItems(widget.searchQuery); - } - } - - Future loadMoreItems(String query) async { - setState(() => isLoading = true); - - try { - ListAndMore newResults = - await CrossRefApi.queryJournalsByName(query); - - setState(() { - if (newResults.list.isNotEmpty) { - items.addAll(newResults.list); - hasMoreResults = newResults.hasMore; - } else { - hasMoreResults = false; - } - }); - } catch (e, stackTrace) { - logger.severe('Failed to load more journals.', e, stackTrace); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(AppLocalizations.of(context)!.failLoadMorePublication))); - } finally { - setState(() => isLoading = false); - } - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } -} diff --git a/lib/services/crossref_api.dart b/lib/services/crossref_api.dart index b0a66701..0c5086c2 100644 --- a/lib/services/crossref_api.dart +++ b/lib/services/crossref_api.dart @@ -1,200 +1,147 @@ import 'package:http/http.dart' as http; import 'dart:convert'; -import '../models/crossref_journals_models.dart' as Journals; -import '../models/crossref_journals_works_models.dart' as journalsWorks; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/models/crossref_journals_works_models.dart' + as journalsWorks; class CrossRefApi { static const String baseUrl = 'https://api.crossref.org'; static const String worksEndpoint = '/works'; static const String journalsEndpoint = '/journals'; - static const String email = 'mailto=wispar-app@protonmail.com'; - static String? _journalCursor = '*'; - static String? _journalWorksCursor = '*'; - static String? _worksQueryCursor = '*'; - static String? _currentQuery; - - // Query journals by name - static Future> queryJournalsByName( - String query) async { - _currentQuery = query; - String apiUrl = '$baseUrl$journalsEndpoint?query=$query&rows=30&$email'; - - if (_journalCursor != null) { - apiUrl += '&cursor=$_journalCursor'; + static const String mailto = 'wispar-app@protonmail.com'; + + static Future> queryJournalsByName({ + required String query, + required String cursor, + }) async { + final uri = Uri.parse('$baseUrl$journalsEndpoint').replace( + queryParameters: { + 'query': query, + 'rows': '30', + 'cursor': cursor, + 'mailto': mailto, + }, + ); + + final response = await http.get(uri); + + if (response.statusCode != 200) { + throw Exception('Failed to query journals.'); } - final response = await http.get(Uri.parse(apiUrl)); + final parsed = Journals.crossrefjournalsFromJson(response.body); - if (response.statusCode == 200) { - final crossrefJournals = Journals.crossrefjournalsFromJson(response.body); - - List items = crossrefJournals.message.items; - - // Update the journal cursor - _journalCursor = crossrefJournals.message.nextCursor; + return PaginatedResponse( + items: parsed.message.items, + nextCursor: parsed.message.nextCursor, + ); + } - // Use nextCursor to determine if there are more results - bool hasMoreResults = _journalCursor != null && _journalCursor != ""; + static Future queryJournalByISSN(String issn) async { + final uri = Uri.parse('$baseUrl$journalsEndpoint/$issn') + .replace(queryParameters: {'mailto': mailto}); - return ListAndMore( - list: items, - hasMore: hasMoreResults, - totalResults: crossrefJournals.message.totalResults, - ); - } else { - throw Exception( - 'Failed to query journals by name. Status code: ${response.statusCode}'); - } - } + final response = await http.get(uri); - // Query journals by ISSN - static Future> queryJournalsByISSN( - String query) async { - String apiUrl = '$baseUrl$journalsEndpoint/$query&$email'; - - final response = await http.get(Uri.parse(apiUrl)); - - if (response.statusCode == 200) { - Map jsonResponse = json.decode(response.body); - var message = jsonResponse['message']; - - if (message != null) { - Journals.Item item = Journals.Item.fromJson(message, query); - - return ListAndMore( - list: [item], - hasMore: false, - totalResults: 0, - ); - } else { - throw Exception('Message object missing in response'); - } - } else { - throw Exception( - 'Failed to query journals by ISSN. Status code: ${response.statusCode}'); + if (response.statusCode != 200) { + throw Exception('Failed to query journal by ISSN.'); } - } - // Query works for a specific journal by ISSN - static Future> getJournalWorks( - List issn) async { - final String issnFilter = issn.map((e) => 'issn:$e').join(','); - String apiUrl = - '$baseUrl$worksEndpoint?rows=30&sort=created&order=desc&$email&filter=$issnFilter'; + final data = json.decode(response.body); + final message = data['message']; - if (_journalWorksCursor != null) { - apiUrl += '&cursor=$_journalWorksCursor'; - } + if (message == null) return null; - final response = await http.get(Uri.parse(apiUrl)); - if (response.statusCode == 200) { - final crossrefWorks = - journalsWorks.JournalWork.fromJson(json.decode(response.body)); - List items = crossrefWorks.message.items; - - // Update the works cursor - _journalWorksCursor = crossrefWorks.message.nextCursor; - - // Use nextCursor to determine if there are more results - bool hasMoreResults = - _journalWorksCursor != null && _journalWorksCursor != ""; - - return ListAndMore( - list: items, - hasMore: hasMoreResults, - totalResults: crossrefWorks.message.totalResults, - ); - } else { - throw Exception( - 'Failed to query journal works: Status code: ${response.statusCode}'); - } + return Journals.Item.fromJson(message, issn); } - // Getter method for _journalCursor - static String? get journalCursor => _journalCursor; - - // Getter method for _journalWorksCursor - static String? get journalWorksCursor => _journalWorksCursor; + static Future> getJournalWorks({ + required List issnList, + required String cursor, + }) async { + final issnFilter = issnList.map((e) => 'issn:$e').join(','); + + final uri = Uri.parse('$baseUrl$worksEndpoint').replace(queryParameters: { + 'filter': issnFilter, + 'rows': '30', + 'sort': 'created', + 'order': 'desc', + 'cursor': cursor, + 'mailto': mailto, + }); + + final response = await http.get(uri); + + if (response.statusCode != 200) { + throw Exception('Failed to query journal works.'); + } - static String? getCurrentJournalCursor() => _journalCursor; - static String? getCurrentJournalWorksCursor() => _journalWorksCursor; + final parsed = + journalsWorks.JournalWork.fromJson(json.decode(response.body)); - static void resetJournalCursor() { - _journalCursor = '*'; + return PaginatedResponse( + items: parsed.message.items, + nextCursor: parsed.message.nextCursor, + ); } - static void resetJournalWorksCursor() { - _journalWorksCursor = '*'; - } + static Future> getWorksByQuery({ + required Map queryParams, + required String cursor, + }) async { + final uri = Uri.parse('$baseUrl$worksEndpoint').replace(queryParameters: { + ...queryParams.map( + (key, value) => MapEntry(key, value.toString()), + ), + 'rows': '50', + 'cursor': cursor, + 'mailto': mailto, + }); + + final response = await http.get(uri); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch works.'); + } - static void resetWorksQueryCursor() { - _worksQueryCursor = '*'; - } + final parsed = + journalsWorks.JournalWork.fromJson(json.decode(response.body)); - static String? getCurrentQuery() { - return _currentQuery; + return PaginatedResponse( + items: parsed.message.items, + nextCursor: parsed.message.nextCursor, + ); } static Future getWorkByDOI(String doi) async { - final response = await http.get(Uri.parse('$baseUrl$worksEndpoint/$doi')); - - if (response.statusCode == 200) { - final data = json.decode(response.body); - final dynamic message = data['message']; - - if (message is Map) { - return journalsWorks.Item.fromJson(message); - } else { - throw Exception('Invalid response format for work by DOI'); - } - } else { - throw Exception( - 'Failed to get work by DOI. Status code: ${response.statusCode}'); - } - } + final uri = Uri.parse('$baseUrl$worksEndpoint/$doi') + .replace(queryParameters: {'mailto': mailto}); - static Future> getWorksByQuery( - Map queryParams) async { - String url = '$baseUrl$worksEndpoint'; - // Construct the query parameters string by iterating over the queryParams map - String queryString = queryParams.entries - .map((entry) => - '${Uri.encodeQueryComponent(entry.key)}=${Uri.encodeQueryComponent(entry.value.toString())}') - .join('&'); - String apiUrl = '$url?$queryString&rows=50&$email'; - if (_worksQueryCursor != null) { - apiUrl += '&cursor=$_worksQueryCursor'; + final response = await http.get(uri); + + if (response.statusCode != 200) { + throw Exception('Failed to get work by DOI.'); } - final response = await http.get(Uri.parse(apiUrl)); - //print('$url?$queryString'); - - if (response.statusCode == 200) { - final responseData = - journalsWorks.JournalWork.fromJson(json.decode(response.body)); - List feedItems = responseData.message.items; - _worksQueryCursor = responseData.message.nextCursor; - bool hasMoreResults = - _worksQueryCursor != null && _worksQueryCursor != ""; - return ListAndMore( - list: feedItems, - hasMore: hasMoreResults, - totalResults: responseData.message.totalResults, - ); - } else { - throw Exception( - 'Failed to fetch results. Status code: ${response.statusCode}'); + + final data = json.decode(response.body); + final message = data['message']; + + if (message is! Map) { + throw Exception('Invalid response format for DOI.'); } + + return journalsWorks.Item.fromJson(message); } } -class ListAndMore { - final List list; - final bool hasMore; - final int totalResults; +class PaginatedResponse { + final List items; + final String? nextCursor; - ListAndMore({ - required this.list, - required this.hasMore, - required this.totalResults, + PaginatedResponse({ + required this.items, + required this.nextCursor, }); + + bool get hasMore => nextCursor != null && items.isNotEmpty; } diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index 860f5a70..99c35c43 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -190,13 +190,6 @@ class OpenAlexSearchFormState extends State { '$selectedSortBy' '$selectedSortOrder'; await dbHelper.saveSearchQuery(queryName, queryString, 'OpenAlex'); - results = await OpenAlexApi.getOpenAlexWorksByQuery( - query, - scope, - sortField, - sortOrder, - dateFilter, - ); } else { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( @@ -206,21 +199,23 @@ class OpenAlexSearchFormState extends State { ); return; } - } else { - results = await OpenAlexApi.getOpenAlexWorksByQuery( - query, scope, sortField, sortOrder, dateFilter); } Navigator.pop(context); Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ArticleSearchResultsScreen( - initialSearchResults: results, - initialHasMore: results.isNotEmpty, - queryParams: {'query': query}, - source: 'OpenAlex', - ), - )); + context, + MaterialPageRoute( + builder: (context) => ArticleSearchResultsScreen( + queryParams: { + 'query': query, + 'scope': scope, + if (sortField != null) 'sortField': sortField, + if (sortOrder != null) 'sortOrder': sortOrder, + if (dateFilter != null) 'dateFilter': dateFilter, + }, + source: 'OpenAlex', + ), + ), + ); } catch (e) { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index 98455009..f40684d0 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; -import 'package:wispar/services/crossref_api.dart'; import 'package:wispar/screens/article_search_results_screen.dart'; import 'package:wispar/services/database_helper.dart'; @@ -350,8 +349,6 @@ class QuerySearchFormState extends State { try { final dbHelper = DatabaseHelper(); - late final response; - CrossRefApi.resetWorksQueryCursor(); // Reset the cursor on new search if (saveQuery) { final queryName = queryNameController.text.trim(); if (queryName != '') { @@ -360,12 +357,8 @@ class QuerySearchFormState extends State { '${Uri.encodeQueryComponent(entry.key)}=${Uri.encodeQueryComponent(entry.value.toString())}') .join('&'); - // Call the save query function await dbHelper.saveSearchQuery(queryName, queryString, 'Crossref'); - // Makes the API call - response = await CrossRefApi.getWorksByQuery(queryParams); } else { - // Close the loading indicator Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -374,8 +367,6 @@ class QuerySearchFormState extends State { ); return; } - } else { - response = await CrossRefApi.getWorksByQuery(queryParams); } // Close the loading indicator @@ -385,13 +376,10 @@ class QuerySearchFormState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => ArticleSearchResultsScreen( - initialSearchResults: response.list, - initialHasMore: response.hasMore, - queryParams: queryParams, - source: 'Crossref', - ), - ), + builder: (context) => ArticleSearchResultsScreen( + queryParams: queryParams, + source: 'Crossref', + )), ); } catch (error) { // Close the loading indicator diff --git a/lib/widgets/search_query_card.dart b/lib/widgets/search_query_card.dart index e4e502f4..d59174bb 100644 --- a/lib/widgets/search_query_card.dart +++ b/lib/widgets/search_query_card.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:flutter/services.dart'; -import 'package:wispar/services/crossref_api.dart'; -import 'package:wispar/services/openAlex_api.dart'; import 'package:wispar/services/string_format_helper.dart'; import 'package:wispar/screens/article_search_results_screen.dart'; import 'package:wispar/services/database_helper.dart'; @@ -64,7 +62,6 @@ class SearchQueryCardState extends State { return const Center(child: CircularProgressIndicator()); }, ); - var response; Map queryMap = {}; String? query; int scope = 1; @@ -76,8 +73,6 @@ class SearchQueryCardState extends State { if (widget.queryProvider == 'Crossref') { // Convert the params string to the needed mapstring queryMap = Uri.splitQueryString(widget.queryParams); - CrossRefApi.resetWorksQueryCursor(); // Reset the cursor on new search - response = await CrossRefApi.getWorksByQuery(queryMap); } else if (widget.queryProvider == 'OpenAlex') { queryMap = Uri.splitQueryString(widget.queryParams); @@ -120,9 +115,6 @@ class SearchQueryCardState extends State { dateFilter = remainingFilters.join(','); } } - - response = await OpenAlexApi.getOpenAlexWorksByQuery( - query ?? '', scope, sortField, sortOrder, dateFilter); } Navigator.pop(context); @@ -133,15 +125,11 @@ class SearchQueryCardState extends State { builder: (context) { if (widget.queryProvider == 'Crossref') { return ArticleSearchResultsScreen( - initialSearchResults: response.list, - initialHasMore: response.hasMore, queryParams: queryMap, source: widget.queryProvider, ); } else { return ArticleSearchResultsScreen( - initialSearchResults: response, - initialHasMore: response.isNotEmpty, queryParams: { 'query': query, 'scope': scope, diff --git a/pubspec.lock b/pubspec.lock index c6a202d4..b88abafc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -379,6 +379,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" + flutter_staggered_grid_view: + dependency: transitive + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_svg: dependency: transitive description: @@ -453,6 +461,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + sha256: b0d28e37cd8f62490ff6aef63f9db93d4c78b7f11b7c6b26f33c69d8476fda78 + url: "https://pub.dev" + source: hosted + version: "5.1.1" intl: dependency: "direct main" description: @@ -938,6 +954,14 @@ packages: description: flutter source: sdk version: "0.0.0" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 935fa989..c7d8f40d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ dependencies: path_provider: ^2.1.5 archive: ^4.0.7 window_manager: ^0.5.1 + infinite_scroll_pagination: ^5.1.1 dev_dependencies: flutter_test: From c3fd0e8adc0988e8adaf45dc59fe617bdc2dfaeb Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:40:35 -0400 Subject: [PATCH 02/10] Add search journals by topics --- lib/l10n/app_en.arb | 4 + lib/models/openalex_domain_models.dart | 114 +++++ lib/screens/journals_details_screen.dart | 15 + .../openalex_journal_results_screen.dart | 141 +++++++ lib/services/openAlex_api.dart | 159 +++++++ lib/widgets/journal_search_form.dart | 295 ++++++++----- lib/widgets/openalex_topics_selector.dart | 391 ++++++++++++++++++ 7 files changed, 1018 insertions(+), 101 deletions(-) create mode 100644 lib/models/openalex_domain_models.dart create mode 100644 lib/screens/openalex_journal_results_screen.dart create mode 100644 lib/widgets/openalex_topics_selector.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 80c6c6de..095106fd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -201,6 +201,8 @@ "@queryPreview": {}, "searchByDOI": "Search by DOI", "@searchByDOI": {}, + "searchByTopic": "Search by topics", + "@searchByTopic":{}, "searchByTitle": "Search by title", "@searchByTitle": {}, "searchByISSN": "Search by ISSN", @@ -211,6 +213,8 @@ "@emptySearchQuery": {}, "journalSearchError": "An error occured while trying to search for journals.", "@journalSearchError": {}, + "selectTopicFirst": "Select a topic first!", + "@selectTopicFirst":{}, "moreOptions": "More options", "@moreOptions": {}, "saveQuery": "Save this query", diff --git a/lib/models/openalex_domain_models.dart b/lib/models/openalex_domain_models.dart new file mode 100644 index 00000000..a6b7f745 --- /dev/null +++ b/lib/models/openalex_domain_models.dart @@ -0,0 +1,114 @@ +class OpenAlexDomain { + final String id; + final String shortId; + final String displayName; + final List fields; + + OpenAlexDomain({ + required this.id, + required this.shortId, + required this.displayName, + required this.fields, + }); + + factory OpenAlexDomain.fromJson(Map json) { + final fullId = json['id'] ?? ''; + + return OpenAlexDomain( + id: fullId, + shortId: fullId.split('/').last, + displayName: json['display_name'] ?? '', + fields: (json['fields'] as List?) + ?.map((f) => OpenAlexField.fromJson(f)) + .toList() ?? + [], + ); + } +} + +class OpenAlexField { + final String id; + final String shortId; + final String displayName; + + OpenAlexField({ + required this.id, + required this.shortId, + required this.displayName, + }); + + factory OpenAlexField.fromJson(Map json) { + final fullId = json['id'] ?? ''; + + return OpenAlexField( + id: fullId, + shortId: fullId.split('/').last, + displayName: json['display_name'] ?? '', + ); + } +} + +class OpenAlexSubfield { + final String id; + final String shortId; + final String displayName; + final List topics; + + OpenAlexSubfield({ + required this.id, + required this.shortId, + required this.displayName, + required this.topics, + }); + + factory OpenAlexSubfield.fromJson(Map json) { + final fullId = json['id'] ?? ''; + + return OpenAlexSubfield( + id: fullId, + shortId: fullId.split('/').last, + displayName: json['display_name'] ?? '', + topics: (json['topics'] as List?) + ?.map((t) => OpenAlexTopic.fromJson(t)) + .toList() ?? + [], + ); + } +} + +class OpenAlexTopic { + final String id; + final String displayName; + + OpenAlexTopic({ + required this.id, + required this.displayName, + }); + + factory OpenAlexTopic.fromJson(Map json) { + return OpenAlexTopic( + id: json['id'] ?? '', + displayName: json['display_name'] ?? '', + ); + } +} + +class TopicJournalResult { + final String title; + final String publisher; + final List issn; + + TopicJournalResult({ + required this.title, + required this.publisher, + required this.issn, + }); + + factory TopicJournalResult.fromJson(Map json) { + return TopicJournalResult( + title: json['display_name'] ?? '', + publisher: json['host_organization_name'] ?? '', + issn: (json['issn'] as List?)?.cast() ?? [], + ); + } +} diff --git a/lib/screens/journals_details_screen.dart b/lib/screens/journals_details_screen.dart index e815b3fa..4117431f 100644 --- a/lib/screens/journals_details_screen.dart +++ b/lib/screens/journals_details_screen.dart @@ -80,6 +80,21 @@ class _JournalDetailsScreenState extends State { cursor: cursor ?? '*', ); + // This part is needed when the Crossref API returns deleted DOIs + // Without this filtering, the app spams the API endlessly + final usableItems = response.items.where((item) { + final isDeletedDoi = + item.journalTitle.contains("CrossRef Listing of Deleted DOIs"); + final hasNoTitle = item.title.isEmpty; + + return !isDeletedDoi && !hasNoTitle; + }).toList(); + + if (usableItems.isEmpty && response.nextCursor != null) { + _latestCursor = null; + return []; + } + _latestCursor = response.nextCursor == null || response.items.isEmpty ? null : response.nextCursor; diff --git a/lib/screens/openalex_journal_results_screen.dart b/lib/screens/openalex_journal_results_screen.dart new file mode 100644 index 00000000..5b390315 --- /dev/null +++ b/lib/screens/openalex_journal_results_screen.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/services/openalex_api.dart'; +import 'package:wispar/widgets/journal_search_results_card.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; + +class OpenAlexJournalResultsScreen extends StatefulWidget { + final String? domainId; + final String? fieldId; + final String? subfieldId; + final String? topicId; + + const OpenAlexJournalResultsScreen({ + super.key, + this.domainId, + this.fieldId, + this.subfieldId, + this.topicId, + }); + + @override + State createState() => + _OpenAlexJournalResultsScreenState(); +} + +class _OpenAlexJournalResultsScreenState + extends State { + final ScrollController _scrollController = ScrollController(); + + final List _journals = []; + + bool _loading = true; + bool _loadingMore = false; + bool _hasMore = true; + + int _page = 1; + + @override + void initState() { + super.initState(); + + _fetchPage(); + + _scrollController.addListener(() { + if (_scrollController.position.pixels > + _scrollController.position.maxScrollExtent - 300 && + !_loadingMore && + _hasMore) { + _fetchPage(); + } + }); + } + + Future _fetchPage() async { + if (_page == 1) { + setState(() => _loading = true); + } else { + setState(() => _loadingMore = true); + } + + try { + final result = await OpenAlexApi.getJournalsByTopic( + domainId: widget.domainId, + fieldId: widget.fieldId, + subfieldId: widget.subfieldId, + topicId: widget.topicId, + page: _page, + ); + + setState(() { + final filteredJournals = + result.journals.where((j) => j.issn.isNotEmpty).map((j) { + return Journals.Item( + title: j.title, + publisher: j.publisher, + issn: j.issn, + lastStatusCheckTime: 0, + counts: Journals.Counts( + totalDois: 0, + currentDois: 0, + backfileDois: 0, + ), + breakdowns: Journals.Breakdowns(doisByIssuedYear: []), + coverage: {}, + coverageType: Journals.CoverageType.fromJson({}), + flags: {}, + issnType: [], + ); + }).toList(); + + if (filteredJournals.isEmpty && result.hasMore) { + _page++; + _loadingMore = false; + _fetchPage(); + return; + } + + _journals.addAll(filteredJournals); + _hasMore = result.hasMore; + _page++; + }); + } catch (e) { + debugPrint(e.toString()); + } + + setState(() { + _loading = false; + _loadingMore = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.journals), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + controller: _scrollController, + itemCount: _journals.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _journals.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + final journal = _journals[index]; + + return JournalsSearchResultCard( + item: journal, + isFollowed: false, + ); + }, + ), + ); + } +} diff --git a/lib/services/openAlex_api.dart b/lib/services/openAlex_api.dart index 772ebc3c..8e33838a 100644 --- a/lib/services/openAlex_api.dart +++ b/lib/services/openAlex_api.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:wispar/models/openAlex_works_models.dart'; import 'package:wispar/models/crossref_journals_works_models.dart' as journalWorks; +import 'package:wispar/models/openalex_domain_models.dart'; class OpenAlexApi { static const String baseUrl = 'https://api.openalex.org'; @@ -91,4 +92,162 @@ class OpenAlexApi { throw Exception('Failed to fetch results: ${response.reasonPhrase}'); } } + + static Future> getDomains() async { + final prefs = await SharedPreferences.getInstance(); + apiKey = prefs.getString('openalex_api_key'); + + final apiUrl = '$baseUrl/domains?per_page=50&select=id,display_name,fields' + '${apiKey != null && apiKey!.isNotEmpty ? '&api_key=$apiKey' : ''}'; + + final response = await http.get(Uri.parse(apiUrl)); + + if (response.statusCode == 200) { + final jsonResponse = jsonDecode(response.body); + + final results = (jsonResponse['results'] as List?) + ?.map((item) => OpenAlexDomain.fromJson(item)) + .toList() ?? + []; + + return results; + } else { + throw Exception('Failed to fetch domains: ${response.reasonPhrase}'); + } + } + + static Future> getSubfieldsByFieldId( + String fieldId) async { + final prefs = await SharedPreferences.getInstance(); + apiKey = prefs.getString('openalex_api_key'); + + final apiUrl = '$baseUrl/subfields?per_page=100' + '&filter=field.id:$fieldId' + '&select=id,display_name,topics' + '${apiKey != null && apiKey!.isNotEmpty ? '&api_key=$apiKey' : ''}'; + + final response = await http.get(Uri.parse(apiUrl)); + + if (response.statusCode == 200) { + final jsonResponse = jsonDecode(response.body); + + final results = (jsonResponse['results'] as List?) + ?.map((item) => OpenAlexSubfield.fromJson(item)) + .toList() ?? + []; + + return results; + } else { + throw Exception('Failed to fetch subfields: ${response.reasonPhrase}'); + } + } + + static Future getJournalsByTopic({ + String? domainId, + String? fieldId, + String? subfieldId, + String? topicId, + required int page, + int perPage = 20, + }) async { + final prefs = await SharedPreferences.getInstance(); + apiKey = prefs.getString('openalex_api_key'); + + String? filter; + + if (topicId != null) { + final short = topicId.split('/').last; + + if (topicId.contains('/T')) { + filter = 'primary_topic.id:$short'; + } else if (topicId.contains('/subfields/')) { + filter = 'primary_topic.subfield.id:$short'; + } else if (topicId.contains('/fields/')) { + filter = 'primary_topic.field.id:$short'; + } else if (topicId.contains('/domains/')) { + filter = 'primary_topic.domain.id:$short'; + } + } else { + throw Exception("No topic level selected"); + } + + /* Since there's no direct way to get journals by topics in OpenAlex, + I first group by a bunch of articles based on their domain, field, + subfield, topic to extract ISSNs. Groupby are currently limited to 200 + results so I applied a few extra filters to narrow the results down + */ + final groupUrl = '$baseUrl/works' + '?filter=$filter,primary_location.source.type:journal,primary_location.source.has_issn:true' + '&group_by=primary_location.source.id' + '&per_page=200' + '${apiKey != null && apiKey!.isNotEmpty ? '&api_key=$apiKey' : ''}'; + final groupResponse = await http.get(Uri.parse(groupUrl)); + + if (groupResponse.statusCode != 200) { + throw Exception('Failed to group works: ${groupResponse.reasonPhrase}'); + } + + final groupJson = jsonDecode(groupResponse.body); + + final groups = (groupJson['group_by'] as List?) + ?.map((g) => g['key'] as String?) + .whereType() + .toList() ?? + []; + + if (groups.isEmpty) { + return OpenAlexJournalPage(journals: [], hasMore: false); + } + + final journalIds = groups.map((id) => id.split('/').last).toList(); + + final start = (page - 1) * perPage; + final end = start + perPage; + + if (start >= journalIds.length) { + return OpenAlexJournalPage( + journals: [], + hasMore: false, + ); + } + + final paginatedIds = + journalIds.sublist(start, end.clamp(0, journalIds.length)); + /* I can then use the list of ISSNs extracted to get a list of journals */ + final journalsUrl = '$baseUrl/sources' + '?filter=openalex:${paginatedIds.join('|')},type:journal' + '&select=id,display_name,issn,type,host_organization_name' + '${apiKey != null && apiKey!.isNotEmpty ? '&api_key=$apiKey' : ''}'; + + final journalsResponse = await http.get(Uri.parse(journalsUrl)); + + if (journalsResponse.statusCode != 200) { + throw Exception( + 'Failed to fetch journals: ${journalsResponse.reasonPhrase}'); + } + + final journalsJson = jsonDecode(journalsResponse.body); + + final journals = (journalsJson['results'] as List?) + ?.map((item) => TopicJournalResult.fromJson(item)) + .toList() ?? + []; + + final hasMore = end < journalIds.length; + + return OpenAlexJournalPage( + journals: journals, + hasMore: hasMore, + ); + } +} + +class OpenAlexJournalPage { + final List journals; + final bool hasMore; + + OpenAlexJournalPage({ + required this.journals, + required this.hasMore, + }); } diff --git a/lib/widgets/journal_search_form.dart b/lib/widgets/journal_search_form.dart index 089533a8..dcecadab 100644 --- a/lib/widgets/journal_search_form.dart +++ b/lib/widgets/journal_search_form.dart @@ -1,52 +1,43 @@ 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/screens/openalex_journal_results_screen.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/screens/crossref_journals_search_results_screen.dart'; +import 'package:wispar/models/crossref_journals_models.dart' as Journals; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/widgets/openalex_topics_selector.dart'; +import 'package:wispar/models/openalex_domain_models.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 'topics', 1 for 'title', 2 for 'issn' late Journals.Item selectedJournal; - TextEditingController _searchController = TextEditingController(); + final TextEditingController _searchController = TextEditingController(); + final List _topicResults = []; + final bool _loadingTopicsResults = false; + final ScrollController _scrollController = ScrollController(); + OpenAlexDomain? _selectedDomain; + OpenAlexField? _selectedField; + OpenAlexSubfield? _selectedSubfield; + OpenAlexField? _selectedLevel; @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), - ); - }*/ - } - }); + _searchController.addListener(() {}); } @override void dispose() { + _scrollController.dispose(); _searchController.dispose(); super.dispose(); } @@ -54,7 +45,8 @@ class _JournalSearchFormState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Padding( + body: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -67,6 +59,7 @@ class _JournalSearchFormState extends State { isSelected: [ selectedSearchIndex == 0, selectedSearchIndex == 1, + selectedSearchIndex == 2, ], onPressed: (int index) { setState(() { @@ -74,98 +67,198 @@ class _JournalSearchFormState extends State { _searchController.clear(); }); }, + borderRadius: BorderRadius.circular(15.0), children: [ Container( - width: constraints.maxWidth / 2 - 1.5, + 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)!.searchByTitle, + textAlign: TextAlign.center, + ), + ), + Container( + width: constraints.maxWidth / 3 - 1.5, + alignment: Alignment.center, + 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(), + if (selectedSearchIndex == 0) ...[ + OpenAlexTopicSelector( + scrollController: _scrollController, + onSelectionChanged: ({ + OpenAlexDomain? domain, + OpenAlexField? field, + OpenAlexSubfield? subfield, + OpenAlexField? topic, + }) { + setState(() { + _selectedDomain = domain; + _selectedField = field; + _selectedSubfield = subfield; + + _selectedLevel = topic ?? + (subfield != null + ? OpenAlexField( + id: subfield.id, + shortId: subfield.shortId, + displayName: subfield.displayName, + ) + : field ?? + (domain != null + ? OpenAlexField( + id: domain.id, + shortId: domain.shortId, + displayName: domain.displayName, + ) + : null)); + }); + }, + ), + const SizedBox(height: 16), + if (_loadingTopicsResults) + const Center(child: CircularProgressIndicator()), + if (_topicResults.isNotEmpty) + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _topicResults.length, + itemBuilder: (context, index) { + final journal = _topicResults[index]; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: ListTile( + title: Text(journal.title), + subtitle: Text(journal.issn.join(", ")), + ), + ); + }, + ), + ] else + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: selectedSearchIndex == 1 + ? AppLocalizations.of(context)!.journaltitle + : 'ISSN', + border: const OutlineInputBorder(), + ), ), - ), SizedBox(height: 16), ], ), ), - 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 == 0 && _selectedLevel != null + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + onPressed: () { + _searchByTopicSelection(); + }, + child: const Icon(Icons.search), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + constraints: const BoxConstraints(maxWidth: 140), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withAlpha(50), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _selectedLevel!.displayName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ) + : FloatingActionButton( + onPressed: () async { + if (selectedSearchIndex == 0) { + if (_selectedLevel == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.selectTopicFirst), + ), + ); + return; + } - void _handleSearch(String query) async { - try { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return Center( - child: CircularProgressIndicator(), - ); - }, - ); + _searchByTopicSelection(); + return; + } - CrossRefApi.resetJournalCursor(); + final query = _searchController.text.trim(); - ListAndMore searchResults; - if (selectedSearchIndex == 0) { - searchResults = await CrossRefApi.queryJournalsByName(query); - } else if (selectedSearchIndex == 1) { - searchResults = await CrossRefApi.queryJournalsByISSN(query); - } else { - throw Exception('Invalid search type selected'); - } + if (query.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.emptySearchQuery, + ), + ), + ); + return; + } - Navigator.pop(context); + _handleSearch(query); + }, + child: const Icon(Icons.search), + ), + ); + } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SearchResultsScreen( - searchResults: searchResults, - searchQuery: query, - ), + void _handleSearch(String query) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CrossrefJournalResultsScreen( + 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); - } + ), + ); + } + + void _searchByTopicSelection() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => OpenAlexJournalResultsScreen( + domainId: _selectedDomain?.id, + fieldId: _selectedField?.id, + subfieldId: _selectedSubfield?.id, + topicId: _selectedLevel?.id, + ), + ), + ); } } diff --git a/lib/widgets/openalex_topics_selector.dart b/lib/widgets/openalex_topics_selector.dart new file mode 100644 index 00000000..31cbd808 --- /dev/null +++ b/lib/widgets/openalex_topics_selector.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart'; +import 'package:wispar/services/openalex_api.dart'; +import 'package:wispar/models/openalex_domain_models.dart'; + +class OpenAlexTopicSelector extends StatefulWidget { + final ScrollController scrollController; + final void Function({ + OpenAlexDomain? domain, + OpenAlexField? field, + OpenAlexSubfield? subfield, + OpenAlexField? topic, + }) onSelectionChanged; + + const OpenAlexTopicSelector({ + super.key, + required this.scrollController, + required this.onSelectionChanged, + }); + + @override + State createState() => _OpenAlexTopicSelectorState(); +} + +class _OpenAlexTopicSelectorState extends State { + late Future> _domainsFuture; + OpenAlexDomain? _selectedDomain; + OpenAlexField? _selectedField; + OpenAlexSubfield? _selectedSubfield; + OpenAlexField? _selectedTopic; + + List? _subfields; + + final GlobalKey _fieldSectionKey = GlobalKey(); + final GlobalKey _subfieldSectionKey = GlobalKey(); + final GlobalKey _topicSectionKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _domainsFuture = OpenAlexApi.getDomains(); + } + + IconData _getDomainIcon(String name) { + switch (name.toLowerCase()) { + case 'physical sciences': + return Icons.science; + case 'social sciences': + return Icons.groups; + case 'health sciences': + return Icons.local_hospital; + case 'life sciences': + return Icons.eco; + default: + return Icons.category; + } + } + + Future _loadSubfields(OpenAlexField field) async { + setState(() { + _subfields = null; + }); + + final results = await OpenAlexApi.getSubfieldsByFieldId(field.shortId); + results.sort((a, b) => a.displayName.compareTo(b.displayName)); + + setState(() { + _subfields = results; + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _domainsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return const Text("Error loading domains"); + } + + final domains = (snapshot.data ?? []) + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Domain cards + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: domains.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, + mainAxisExtent: 100, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final domain = domains[index]; + final isSelected = _selectedDomain?.id == domain.id; + + return InkWell( + onTap: () { + setState(() { + _selectedDomain = isSelected ? null : domain; + + _selectedField = null; + _selectedSubfield = null; + _subfields = null; + }); + widget.onSelectionChanged( + domain: _selectedDomain, + ); + + _scrollToSection(_fieldSectionKey); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _getDomainIcon(domain.displayName), + size: 25, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + domain.displayName, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w600, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ], + ), + ), + ); + }, + ), + + const SizedBox(height: 16), + + // Field cards + if (_selectedDomain != null) ...[ + Text( + _selectedDomain!.displayName, + key: _fieldSectionKey, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Builder(builder: (context) { + final sortedFields = _selectedDomain!.fields.toList() + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sortedFields.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, + mainAxisExtent: 100, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final field = sortedFields[index]; + final isSelectedField = _selectedField?.id == field.id; + + return InkWell( + onTap: () async { + setState(() { + _selectedField = isSelectedField ? null : field; + _selectedField = field; + _selectedSubfield = null; + _subfields = null; + }); + if (_selectedField != null) { + await _loadSubfields(field); + + _scrollToSection(_subfieldSectionKey); + } + widget.onSelectionChanged( + domain: _selectedDomain, + field: field, + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all( + color: isSelectedField + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + width: isSelectedField ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + field.displayName, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ), + ); + }, + ); + }), + + // Subfield cards + if (_selectedField != null) ...[ + const SizedBox(height: 16), + Text( + _selectedField!.displayName, + key: _subfieldSectionKey, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + if (_subfields == null) + const Center(child: CircularProgressIndicator()) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _subfields!.length, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, + mainAxisExtent: 100, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final subfield = _subfields![index]; + final isSelectedSubfield = + _selectedSubfield?.id == subfield.id; + + return InkWell( + onTap: () { + setState(() { + _selectedSubfield = subfield; + }); + widget.onSelectionChanged( + domain: _selectedDomain, + field: _selectedField, + subfield: subfield, + ); + _scrollToSection(_topicSectionKey); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all( + color: isSelectedSubfield + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + width: isSelectedSubfield ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + subfield.displayName, + textAlign: TextAlign.center, + ), + ), + ), + ); + }, + ), + if (_selectedSubfield != null) ...[ + const SizedBox(height: 16), + Text( + _selectedSubfield!.displayName, + key: _topicSectionKey, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Builder(builder: (context) { + final sortedTopics = _selectedSubfield!.topics.toList() + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sortedTopics.length, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 220, + mainAxisExtent: 100, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final topic = sortedTopics[index]; + final isSelectedTopic = _selectedTopic?.id == topic.id; + + return InkWell( + onTap: () { + final topicField = OpenAlexField( + id: topic.id, + shortId: topic.id.split('/').last, + displayName: topic.displayName, + ); + + setState(() { + _selectedTopic = topicField; + }); + + widget.onSelectionChanged( + domain: _selectedDomain, + field: _selectedField, + subfield: _selectedSubfield, + topic: topicField, + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all( + color: isSelectedTopic + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + width: isSelectedTopic ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + topic.displayName, + textAlign: TextAlign.center, + ), + ), + ), + ); + }, + ); + }) + ] + ] + ], + ], + ); + }, + ); + } + + void _scrollToSection(GlobalKey key) { + Future.delayed(const Duration(milliseconds: 100), () { + if (key.currentContext != null && widget.scrollController.hasClients) { + Scrollable.ensureVisible( + key.currentContext!, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOut, + ); + } + }); + } +} From 839cfcb375b5ab6a67b82197a177df45ee8005fa Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:47:11 -0400 Subject: [PATCH 03/10] Fix date icon color --- lib/widgets/article_openAlex_search_form.dart | 4 ++-- lib/widgets/article_query_search_form.dart | 4 ++-- lib/widgets/custom_feed_bottom_sheet.dart | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index 99c35c43..401af7de 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -398,7 +398,7 @@ class OpenAlexSearchFormState extends State { : _publishedAfter!.toIso8601String().split('T')[0], ), trailing: Icon(Icons.calendar_today, - color: Theme.of(context).primaryColor), + color: Theme.of(context).colorScheme.primary), onTap: () => _pickDate(context, true), ), ), @@ -415,7 +415,7 @@ class OpenAlexSearchFormState extends State { ? AppLocalizations.of(context)!.selectEndDate : _publishedBefore!.toIso8601String().split('T')[0]), trailing: Icon(Icons.calendar_today, - color: Theme.of(context).primaryColor), + color: Theme.of(context).colorScheme.primary), onTap: () => _pickDate(context, false), ), ), diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index f40684d0..634f9bbf 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -501,7 +501,7 @@ class QuerySearchFormState extends State { ? AppLocalizations.of(context)!.selectStartDate : _createdAfter!.toIso8601String().split('T')[0]), trailing: Icon(Icons.calendar_today, - color: Theme.of(context).primaryColor), + color: Theme.of(context).colorScheme.primary), onTap: () => _pickDate(context, true), ), ), @@ -518,7 +518,7 @@ class QuerySearchFormState extends State { ? AppLocalizations.of(context)!.selectEndDate : _createdBefore!.toIso8601String().split('T')[0]), trailing: Icon(Icons.calendar_today, - color: Theme.of(context).primaryColor), + color: Theme.of(context).colorScheme.primary), onTap: () => _pickDate(context, false), ), ), diff --git a/lib/widgets/custom_feed_bottom_sheet.dart b/lib/widgets/custom_feed_bottom_sheet.dart index ebebd30b..6f936a66 100644 --- a/lib/widgets/custom_feed_bottom_sheet.dart +++ b/lib/widgets/custom_feed_bottom_sheet.dart @@ -375,7 +375,7 @@ class CustomizeFeedBottomSheetState extends State { .split('T')[0]), trailing: Icon( Icons.calendar_today, - color: Theme.of(context).primaryColor, + color: Theme.of(context).colorScheme.primary, ), onTap: () => _pickDate(context, true), ), @@ -396,7 +396,7 @@ class CustomizeFeedBottomSheetState extends State { .toIso8601String() .split('T')[0]), trailing: Icon(Icons.calendar_today, - color: Theme.of(context).primaryColor), + color: Theme.of(context).colorScheme.primary), onTap: () => _pickDate(context, false), ), ), From de85558e86ac7d9dc9c9e589e170a4d8eb935ae8 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:53:06 -0400 Subject: [PATCH 04/10] Add missing borders to crossref sort menus --- lib/widgets/article_query_search_form.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index 634f9bbf..a5d4209e 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -529,6 +529,7 @@ class QuerySearchFormState extends State { Expanded( child: DropdownButtonFormField( decoration: InputDecoration( + border: OutlineInputBorder(), labelText: 'Sort by', ), initialValue: selectedSortBy, @@ -545,6 +546,7 @@ class QuerySearchFormState extends State { Expanded( child: DropdownButtonFormField( decoration: InputDecoration( + border: OutlineInputBorder(), labelText: 'Sort order', ), initialValue: selectedSortOrder, From a8f4de50cf967347284b0dbb8a05f6718652942d Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:59:49 -0400 Subject: [PATCH 05/10] Remove rounded corners from search buttons --- lib/widgets/article_crossref_search_form.dart | 44 +++++++++---------- lib/widgets/article_openAlex_search_form.dart | 1 - 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/lib/widgets/article_crossref_search_form.dart b/lib/widgets/article_crossref_search_form.dart index af4d93ee..e0f7de7c 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(); @@ -83,8 +85,7 @@ class _CrossRefSearchFormState extends State { ), ); } catch (e, stackTrace) { - logger.severe( - 'Error searching by DOI for DOI ${doi}.', 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)), @@ -92,7 +93,7 @@ class _CrossRefSearchFormState extends State { } } } 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( @@ -118,6 +119,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 +142,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 +156,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 401af7de..035168f4 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -578,7 +578,6 @@ class OpenAlexSearchFormState extends State { ), floatingActionButton: FloatingActionButton( onPressed: _executeSearch, - shape: CircleBorder(), child: Icon(Icons.search), ), ); From 4ab99ff9cb0376439c15c49bdce7ad09d208d17b Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:28:33 -0400 Subject: [PATCH 06/10] Remove unecessary fields in crossref journal model --- lib/models/crossref_journals_models.dart | 100 ------------------ .../openalex_journal_results_screen.dart | 10 -- lib/widgets/journal_header.dart | 9 -- 3 files changed, 119 deletions(-) diff --git a/lib/models/crossref_journals_models.dart b/lib/models/crossref_journals_models.dart index b9d1aa3c..7e41e660 100644 --- a/lib/models/crossref_journals_models.dart +++ b/lib/models/crossref_journals_models.dart @@ -72,42 +72,22 @@ class Message { } class Item { - int lastStatusCheckTime; - Counts counts; - Breakdowns breakdowns; String publisher; - Map coverage; String title; - CoverageType coverageType; - Map flags; List issn; List issnType; Item({ - required this.lastStatusCheckTime, - required this.counts, - required this.breakdowns, required this.publisher, - required this.coverage, required this.title, - required this.coverageType, - required this.flags, required this.issn, required this.issnType, }); factory Item.fromJson(Map json, [String? queriedISSN]) => Item( - lastStatusCheckTime: json["last-status-check-time"] ?? 0, - counts: Counts.fromJson(json["counts"] ?? {}), - breakdowns: Breakdowns.fromJson(json["breakdowns"] ?? {}), publisher: json["publisher"] ?? "Unknown", - coverage: Map.from(json["coverage"] ?? {}) - .map((k, v) => MapEntry(k, (v ?? 0).toDouble())), title: json["title"] ?? "Untitled", - coverageType: CoverageType.fromJson(json["coverage-type"] ?? {}), - flags: Map.from(json["flags"] ?? {}) - .map((k, v) => MapEntry(k, v ?? false)), issn: (queriedISSN != null && json["ISSN"].contains(queriedISSN)) ? [queriedISSN] : List.from(json["ISSN"]?.map((x) => x) ?? []), @@ -117,93 +97,13 @@ class Item { ); Map toJson() => { - "last-status-check-time": lastStatusCheckTime, - "counts": counts.toJson(), - "breakdowns": breakdowns.toJson(), "publisher": publisher, - "coverage": - Map.from(coverage).map((k, v) => MapEntry(k, v)), "title": title, - "coverage-type": coverageType.toJson(), - "flags": Map.from(flags).map((k, v) => MapEntry(k, v)), "ISSN": List.from(issn.map((x) => x)), "issn-type": List.from(issnType.map((x) => x.toJson())), }; } -class Breakdowns { - List> doisByIssuedYear; - - Breakdowns({ - required this.doisByIssuedYear, - }); - - factory Breakdowns.fromJson(Map json) => Breakdowns( - doisByIssuedYear: List>.from( - (json["dois-by-issued-year"] ?? []) - .map((x) => List.from(x.map((x) => x ?? 0))), - ), - ); - - Map toJson() => { - "dois-by-issued-year": List.from( - doisByIssuedYear.map((x) => List.from(x.map((x) => x)))), - }; -} - -class Counts { - int currentDois; - int backfileDois; - int totalDois; - - Counts({ - required this.currentDois, - required this.backfileDois, - required this.totalDois, - }); - - factory Counts.fromJson(Map json) => Counts( - currentDois: json["current-dois"] ?? 0, - backfileDois: json["backfile-dois"] ?? 0, - totalDois: json["total-dois"] ?? 0, - ); - - Map toJson() => { - "current-dois": currentDois, - "backfile-dois": backfileDois, - "total-dois": totalDois, - }; -} - -class CoverageType { - Map all; - Map backfile; - Map current; - - CoverageType({ - required this.all, - required this.backfile, - required this.current, - }); - - factory CoverageType.fromJson(Map json) => CoverageType( - all: Map.from(json["all"] ?? {}) - .map((k, v) => MapEntry(k, (v ?? 0).toDouble())), - backfile: Map.from(json["backfile"] ?? {}) - .map((k, v) => MapEntry(k, (v ?? 0).toDouble())), - current: Map.from(json["current"] ?? {}) - .map((k, v) => MapEntry(k, (v ?? 0).toDouble())), - ); - - Map toJson() => { - "all": Map.from(all).map((k, v) => MapEntry(k, v)), - "backfile": - Map.from(backfile).map((k, v) => MapEntry(k, v)), - "current": - Map.from(current).map((k, v) => MapEntry(k, v)), - }; -} - class IssnType { String value; Type type; diff --git a/lib/screens/openalex_journal_results_screen.dart b/lib/screens/openalex_journal_results_screen.dart index 5b390315..0a6b5a6e 100644 --- a/lib/screens/openalex_journal_results_screen.dart +++ b/lib/screens/openalex_journal_results_screen.dart @@ -74,16 +74,6 @@ class _OpenAlexJournalResultsScreenState title: j.title, publisher: j.publisher, issn: j.issn, - lastStatusCheckTime: 0, - counts: Journals.Counts( - totalDois: 0, - currentDois: 0, - backfileDois: 0, - ), - breakdowns: Journals.Breakdowns(doisByIssuedYear: []), - coverage: {}, - coverageType: Journals.CoverageType.fromJson({}), - flags: {}, issnType: [], ); }).toList(); diff --git a/lib/widgets/journal_header.dart b/lib/widgets/journal_header.dart index aeb0df73..176265ea 100644 --- a/lib/widgets/journal_header.dart +++ b/lib/widgets/journal_header.dart @@ -27,18 +27,9 @@ class JournalInfoHeader extends SliverPersistentHeaderDelegate { @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { - // a little bit hacky, but oh well :( final journalItem = Journals.Item( - lastStatusCheckTime: 0, - counts: Journals.Counts(currentDois: 0, backfileDois: 0, totalDois: 0), - breakdowns: Journals.Breakdowns(doisByIssuedYear: [ - [0] - ]), publisher: publisher, - coverage: {}, title: title, - coverageType: Journals.CoverageType(all: {}, backfile: {}, current: {}), - flags: {}, issn: issn.split(','), issnType: [], ); From 83e8955fac72f2165c53bed460b06ffd01cfa9ec Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:10:07 -0400 Subject: [PATCH 07/10] Fix import casing --- lib/screens/openalex_journal_results_screen.dart | 2 +- lib/widgets/openalex_topics_selector.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/openalex_journal_results_screen.dart b/lib/screens/openalex_journal_results_screen.dart index 0a6b5a6e..dc4d54b0 100644 --- a/lib/screens/openalex_journal_results_screen.dart +++ b/lib/screens/openalex_journal_results_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; -import 'package:wispar/services/openalex_api.dart'; +import 'package:wispar/services/openAlex_api.dart'; import 'package:wispar/widgets/journal_search_results_card.dart'; import 'package:wispar/models/crossref_journals_models.dart' as Journals; diff --git a/lib/widgets/openalex_topics_selector.dart b/lib/widgets/openalex_topics_selector.dart index 31cbd808..9256b647 100644 --- a/lib/widgets/openalex_topics_selector.dart +++ b/lib/widgets/openalex_topics_selector.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:wispar/services/openalex_api.dart'; +import 'package:wispar/services/openAlex_api.dart'; import 'package:wispar/models/openalex_domain_models.dart'; class OpenAlexTopicSelector extends StatefulWidget { From e6c8d367c748ff5a64adfce9402827adbd905b93 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:17:02 -0400 Subject: [PATCH 08/10] Show the follow btn if publisher is missing --- lib/l10n/app_en.arb | 2 ++ lib/widgets/journal_header.dart | 17 ++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 095106fd..a96ba533 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -233,6 +233,8 @@ "@category": {}, "publisher": "Publisher", "@publisher": {}, + "publisherWithValue": "Publisher: {name}", + "@publisherWithValue":{}, "publishedin": "Published in", "@publishedin": {}, "subjects": "Subjects", diff --git a/lib/widgets/journal_header.dart b/lib/widgets/journal_header.dart index 176265ea..c3285127 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; @@ -48,14 +48,17 @@ class JournalInfoHeader extends SliverPersistentHeaderDelegate { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: 8.0), - Text( - '${AppLocalizations.of(context)!.publisher}: ${publisher}'), - Text('ISSN: ${issn}'), + if (publisher.isNotEmpty) + Text( + AppLocalizations.of(context)! + .publisherWithValue(publisher), + ), + Text('ISSN: $issn'), SizedBox(height: 8.0), ], ), ), - title.isNotEmpty && publisher.isNotEmpty && issn.isNotEmpty + title.isNotEmpty && issn.isNotEmpty ? FollowButton( item: journalItem, isFollowed: isFollowed, From 707302885d26b1eefe2b3eacd7f55f17b1706e25 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:02:24 -0400 Subject: [PATCH 09/10] Add more bottom padding to prevent floating btn hiding topics --- lib/widgets/article_openAlex_search_form.dart | 5 +---- lib/widgets/journal_search_form.dart | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index 035168f4..3d11a5d0 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; -import 'package:wispar/services/openAlex_api.dart'; import 'package:wispar/screens/article_search_results_screen.dart'; -import 'package:wispar/models/crossref_journals_works_models.dart' - as journals_works; import 'package:wispar/services/database_helper.dart'; class OpenAlexSearchForm extends StatefulWidget { @@ -152,7 +149,7 @@ class OpenAlexSearchFormState extends State { ); final dbHelper = DatabaseHelper(); - List results = []; + if (saveQuery) { final queryName = queryNameController.text.trim(); if (queryName != '') { diff --git a/lib/widgets/journal_search_form.dart b/lib/widgets/journal_search_form.dart index dcecadab..dbfdf733 100644 --- a/lib/widgets/journal_search_form.dart +++ b/lib/widgets/journal_search_form.dart @@ -47,7 +47,7 @@ class JournalSearchFormState extends State { return Scaffold( body: SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ From fc4cb2c13a7cad136aa6e93f4e3aadf977790847 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:38:46 -0400 Subject: [PATCH 10/10] Fix crossref query search --- lib/widgets/article_crossref_search_form.dart | 13 ++----------- lib/widgets/article_query_search_form.dart | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/widgets/article_crossref_search_form.dart b/lib/widgets/article_crossref_search_form.dart index e0f7de7c..bcd8c59a 100644 --- a/lib/widgets/article_crossref_search_form.dart +++ b/lib/widgets/article_crossref_search_form.dart @@ -28,22 +28,13 @@ class CrossRefSearchFormState extends State { void _handleSearch() async { try { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return Center( - child: CircularProgressIndicator(), - ); - }, - ); if (selectedSearchIndex == 0) { // Query search if (_queryFormKey.currentState != null) { - _queryFormKey.currentState! + await _queryFormKey.currentState! .submitForm(); // Call the search function in QuerySearchForm } else {} - Navigator.pop(context); + return; } else { // DOI-based search String doi = doiController.text.trim(); diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index a5d4209e..9ff9a807 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -213,7 +213,7 @@ class QuerySearchFormState extends State { } } - void submitForm() async { + Future submitForm() async { // Gather all input values, ignoring empty fields final Map queryParams = {};