From 5e3f4d71e430f54e97f14fa2402698f15a2e7837 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:40:46 -0500 Subject: [PATCH 1/9] Fix openalex abstracts bug --- lib/models/openAlex_works_models.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/models/openAlex_works_models.dart b/lib/models/openAlex_works_models.dart index 93023fbe..323cc301 100644 --- a/lib/models/openAlex_works_models.dart +++ b/lib/models/openAlex_works_models.dart @@ -67,17 +67,22 @@ class OpenAlexWorks { } String? reconstructAbstract(Map? invertedIndex) { - if (invertedIndex == null) return null; + if (invertedIndex == null || invertedIndex.isEmpty) return null; - int maxIndex = invertedIndex.values - .expand((positions) => positions) - .reduce((a, b) => a > b ? a : b); + final allPositions = + invertedIndex.values.expand((positions) => positions as List); + + if (allPositions.isEmpty) return null; + + int maxIndex = allPositions.reduce((a, b) => a > b ? a : b); List words = List.filled(maxIndex + 1, '', growable: false); invertedIndex.forEach((word, positions) { - for (int pos in positions) { - words[pos] = word; + for (int pos in (positions as List)) { + if (pos <= maxIndex) { + words[pos] = word; + } } }); From ca306e9d750ac0f4b675f52601df3bace908866d Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:41:32 -0500 Subject: [PATCH 2/9] fix openalex not loading all results --- lib/screens/article_search_results_screen.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/screens/article_search_results_screen.dart b/lib/screens/article_search_results_screen.dart index 2ecba2e3..879145dd 100644 --- a/lib/screens/article_search_results_screen.dart +++ b/lib/screens/article_search_results_screen.dart @@ -35,7 +35,7 @@ class ArticleSearchResultsScreenState final ScrollController _scrollController = ScrollController(); bool _isLoadingMore = false; bool _hasMoreResults = true; - int _currentOpenAlexPage = 1; + int _currentOpenAlexPage = 2; SwipeAction _swipeLeftAction = SwipeAction.hide; SwipeAction _swipeRightAction = SwipeAction.favorite; @@ -135,14 +135,16 @@ class ArticleSearchResultsScreenState widget.queryParams['scope'] ?? 1, widget.queryParams['sortField'], widget.queryParams['sortOrder'], + widget.queryParams['dateFilter'], page: _currentOpenAlexPage, ); if (newResults.isNotEmpty) { _currentOpenAlexPage++; + + hasMore = newResults.length >= 25; } else { hasMore = false; - _isLoadingMore = false; } } From 0686396d67f6bbf16ec6335cda517d36407c96c2 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:02:10 -0500 Subject: [PATCH 3/9] Add date filter to openalex --- lib/l10n/app_en.arb | 12 ++ lib/services/openAlex_api.dart | 49 +++++-- lib/widgets/article_openAlex_search_form.dart | 131 ++++++++++++++++-- lib/widgets/search_query_card.dart | 102 ++++++++------ 4 files changed, 224 insertions(+), 70 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1df06930..1c455fa0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -185,6 +185,18 @@ "@worksCount": {}, "publicationDate": "Publication date", "@publicationDate": {}, + "publishedAfter": "Published after", + "@publishedAfter":{}, + "publishedBefore": "Published before", + "@publishedBefore":{}, + "publishedBetween": "Published between", + "@publishedBetween":{}, + "noFilter": "No filter", + "@noFilter":{}, + "selectStartDate": "Select start date", + "@selectStartDate":{}, + "selectEndDate": "Select end date", + "@selectEndDate":{}, "queryPreview": "Query preview:", "@queryPreview": {}, "searchByDOI": "Search by DOI", diff --git a/lib/services/openAlex_api.dart b/lib/services/openAlex_api.dart index c2aedbbf..f18635e7 100644 --- a/lib/services/openAlex_api.dart +++ b/lib/services/openAlex_api.dart @@ -8,23 +8,44 @@ class OpenAlexApi { static const String worksEndpoint = '/works?'; static const String email = 'mailto=wispar-app@protonmail.com'; - static Future> getOpenAlexWorksByQuery( - String query, int scope, String? sortField, String? sortOrder, - {int page = 1} // Default to page 1 - ) async { - final scopeMap = { - 1: 'search=', // Everything - 2: 'filter=title_and_abstract.search:', // Title and Abstract - 3: 'filter=title.search:', // Title only - 4: 'filter=abstract.search:', // Abstract only + static Future> getOpenAlexWorksByQuery(String query, + int scope, String? sortField, String? sortOrder, String? dateFilter, + {int page = 1}) async { + Map scopeMap = { + 1: '', // Everything + 2: 'title_and_abstract.search:', // Title and Abstract + 3: 'title.search:', // Title only + 4: 'abstract.search:', // Abstract only }; - String searchField = scopeMap[scope] ?? 'search='; - String sortBy = sortField != null ? '&sort=$sortField' : ''; - String orderBy = sortOrder != null ? ':$sortOrder' : ''; + String searchPart; + String filterPart = ''; - String apiUrl = - '$baseUrl$worksEndpoint$searchField$query$sortBy$orderBy&$email&page=$page'; + if (scope == 1) { + searchPart = 'search=$query'; + } else { + searchPart = ''; + filterPart = 'filter=${scopeMap[scope]}$query'; + } + + if (dateFilter != null && dateFilter.isNotEmpty) { + if (filterPart.isEmpty) { + filterPart = 'filter=$dateFilter'; + } else { + filterPart += ',$dateFilter'; + } + } + + String sortPart = ''; + if (sortField != null && sortOrder != null) { + sortPart = '&sort=$sortField:$sortOrder'; + } + + String apiUrl = '$baseUrl/works?$searchPart' + '${filterPart.isNotEmpty ? '&$filterPart' : ''}' + '$sortPart' + '&$email' + '&page=$page'; final response = await http.get(Uri.parse(apiUrl)); diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index dc5065b9..dfc3cfd1 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -1,20 +1,27 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../services/openAlex_api.dart'; -import '../screens/article_search_results_screen.dart'; -import '../models/crossref_journals_works_models.dart' as journalsWorks; -import '../services/database_helper.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 { + const OpenAlexSearchForm({super.key}); + @override - _OpenAlexSearchFormState createState() => _OpenAlexSearchFormState(); + OpenAlexSearchFormState createState() => OpenAlexSearchFormState(); } -class _OpenAlexSearchFormState extends State { +class OpenAlexSearchFormState extends State { List> queryParts = []; String searchScope = 'Everything'; String selectedSortField = '-'; String selectedSortOrder = '-'; + DateTime? _publishedAfter; + DateTime? _publishedBefore; + + String _dateMode = 'none'; // bool _filtersExpanded = false; bool saveQuery = false; List controllers = []; @@ -80,6 +87,26 @@ class _OpenAlexSearchFormState extends State { }); } + Future _pickDate(BuildContext context, bool isAfter) async { + final initialDate = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + if (isAfter) { + _publishedAfter = picked; + } else { + _publishedBefore = picked; + } + }); + } + } + void _executeSearch() async { String query = queryParts.map((part) => part['value']).join(' '); final scopeMap = { @@ -92,6 +119,20 @@ class _OpenAlexSearchFormState extends State { String? sortField = selectedSortField == '-' ? null : selectedSortField; String? sortOrder = selectedSortOrder == '-' ? null : selectedSortOrder; + String? dateFilter; + + String formatDate(DateTime d) => d.toIso8601String().split('T')[0]; + + if (_dateMode == 'after' && _publishedAfter != null) { + dateFilter = "from_publication_date:${formatDate(_publishedAfter!)}"; + } else if (_dateMode == 'before' && _publishedBefore != null) { + dateFilter = "to_publication_date:${formatDate(_publishedBefore!)}"; + } else if (_dateMode == 'between' && + _publishedAfter != null && + _publishedBefore != null) { + dateFilter = "from_publication_date:${formatDate(_publishedAfter!)}," + "to_publication_date:${formatDate(_publishedBefore!)}"; + } try { showDialog( @@ -103,7 +144,7 @@ class _OpenAlexSearchFormState extends State { ); final dbHelper = DatabaseHelper(); - List results = []; + List results = []; if (saveQuery) { final queryName = queryNameController.text.trim(); if (queryName != '') { @@ -126,13 +167,27 @@ class _OpenAlexSearchFormState extends State { selectedSortOrder = ':$sortOrder'; } - queryString = '$searchField$query$selectedSortBy$selectedSortOrder'; + String datePart = ''; + + if (dateFilter != null && dateFilter.isNotEmpty) { + if (searchField.startsWith('filter=')) { + datePart = ',$dateFilter'; + } else { + datePart = '&filter=$dateFilter'; + } + } + + queryString = '$searchField$query' + '$datePart' + '$selectedSortBy' + '$selectedSortOrder'; await dbHelper.saveSearchQuery(queryName, queryString, 'OpenAlex'); results = await OpenAlexApi.getOpenAlexWorksByQuery( query, scope, sortField, sortOrder, + dateFilter, ); } else { Navigator.pop(context); @@ -145,11 +200,7 @@ class _OpenAlexSearchFormState extends State { } } else { results = await OpenAlexApi.getOpenAlexWorksByQuery( - query, - scope, - sortField, - sortOrder, - ); + query, scope, sortField, sortOrder, dateFilter); } Navigator.pop(context); Navigator.push( @@ -278,6 +329,56 @@ class _OpenAlexSearchFormState extends State { ), ], ), + SizedBox(height: 16), + + DropdownButtonFormField( + initialValue: _dateMode, + onChanged: (value) { + setState(() { + _dateMode = value!; + _publishedAfter = null; + _publishedBefore = null; + }); + }, + items: [ + DropdownMenuItem( + value: 'none', + child: Text(AppLocalizations.of(context)!.noFilter)), + DropdownMenuItem( + value: 'after', + child: Text(AppLocalizations.of(context)!.publishedAfter)), + DropdownMenuItem( + value: 'before', + child: Text(AppLocalizations.of(context)!.publishedBefore)), + DropdownMenuItem( + value: 'between', + child: + Text(AppLocalizations.of(context)!.publishedBetween)), + ], + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: AppLocalizations.of(context)!.publicationDate), + ), + + SizedBox(height: 10), + + if (_dateMode == 'after' || _dateMode == 'between') + ListTile( + title: Text(_publishedAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _publishedAfter!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today), + onTap: () => _pickDate(context, true), + ), + + if (_dateMode == 'before' || _dateMode == 'between') + ListTile( + title: Text(_publishedBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _publishedBefore!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today), + onTap: () => _pickDate(context, false), + ), SizedBox(height: 10), // Dynamic query builder @@ -437,8 +538,8 @@ class _OpenAlexSearchFormState extends State { ), floatingActionButton: FloatingActionButton( onPressed: _executeSearch, - child: Icon(Icons.search), shape: CircleBorder(), + child: Icon(Icons.search), ), ); } diff --git a/lib/widgets/search_query_card.dart b/lib/widgets/search_query_card.dart index 65fd8293..e4e502f4 100644 --- a/lib/widgets/search_query_card.dart +++ b/lib/widgets/search_query_card.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:flutter/services.dart'; -import '../services/crossref_api.dart'; -import '../services/openAlex_api.dart'; -import '../services/string_format_helper.dart'; -import '../screens/article_search_results_screen.dart'; -import '../services/database_helper.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'; class SearchQueryCard extends StatefulWidget { final int queryId; @@ -17,7 +17,7 @@ class SearchQueryCard extends StatefulWidget { final VoidCallback? onDelete; const SearchQueryCard({ - Key? key, + super.key, required this.queryId, required this.queryName, required this.queryParams, @@ -25,13 +25,13 @@ class SearchQueryCard extends StatefulWidget { this.showDeleteButton = false, required this.dateSaved, this.onDelete, - }) : super(key: key); + }); @override - _SearchQueryCardState createState() => _SearchQueryCardState(); + SearchQueryCardState createState() => SearchQueryCardState(); } -class _SearchQueryCardState extends State { +class SearchQueryCardState extends State { bool _includeInFeed = false; late DatabaseHelper databaseHelper; @@ -67,6 +67,11 @@ class _SearchQueryCardState extends State { var response; Map queryMap = {}; String? query; + int scope = 1; + String? sortField; + String? sortOrder; + String? dateFilter; + String? filterValue; if (widget.queryProvider == 'Crossref') { // Convert the params string to the needed mapstring @@ -76,40 +81,48 @@ class _SearchQueryCardState extends State { } else if (widget.queryProvider == 'OpenAlex') { queryMap = Uri.splitQueryString(widget.queryParams); - String? sortField = queryMap['sort']; - String? sortOrder = queryMap['sortOrder']; - int scope = 1; - String? query; - - if (queryMap.containsKey('search') && - !queryMap.containsKey('filter')) { - query = queryMap['search']; - scope = 1; - } else if (queryMap.containsKey('filter')) { - String filterValue = queryMap['filter'] ?? ''; - - if (filterValue.contains('title.search')) { - query = filterValue.replaceFirst('title.search:', '').trim(); - scope = 3; - } else if (filterValue.contains('title_and_abstract')) { - query = filterValue.replaceFirst('title_and_abstract', '').trim(); - scope = 2; - } else if (filterValue.contains('title')) { - query = filterValue.replaceFirst('title', '').trim(); - scope = 3; - } else if (filterValue.contains('abstract')) { - query = filterValue.replaceFirst('abstract', '').trim(); - scope = 4; - } else { - query = filterValue; - scope = 1; - } + String? sortParam = queryMap['sort']; + String? filterValue = queryMap['filter']; + String? searchValue = queryMap['search']; + + sortField = null; + sortOrder = null; + + if (sortParam != null && sortParam.contains(':')) { + final parts = sortParam.split(':'); + sortField = parts[0]; + sortOrder = parts.length > 1 ? parts[1] : null; } - query ??= ''; + query = searchValue ?? ''; + scope = 1; + + if (filterValue != null) { + List filters = filterValue.split(','); + List remainingFilters = []; + + for (var f in filters) { + if (f.startsWith('title.search:')) { + query = f.replaceFirst('title.search:', ''); + scope = 3; + } else if (f.startsWith('title_and_abstract.search:')) { + query = f.replaceFirst('title_and_abstract.search:', ''); + scope = 2; + } else if (f.startsWith('abstract.search:')) { + query = f.replaceFirst('abstract.search:', ''); + scope = 4; + } else if (f.startsWith('from_publication_date:') || + f.startsWith('to_publication_date:')) { + remainingFilters.add(f); + } + } + if (remainingFilters.isNotEmpty) { + dateFilter = remainingFilters.join(','); + } + } response = await OpenAlexApi.getOpenAlexWorksByQuery( - query, scope, sortField, sortOrder); + query ?? '', scope, sortField, sortOrder, dateFilter); } Navigator.pop(context); @@ -129,7 +142,14 @@ class _SearchQueryCardState extends State { return ArticleSearchResultsScreen( initialSearchResults: response, initialHasMore: response.isNotEmpty, - queryParams: {'query': query}, + queryParams: { + 'query': query, + 'scope': scope, + 'sortField': sortField, + 'sortOrder': sortOrder, + 'dateFilter': dateFilter, + 'filter': filterValue, + }, source: widget.queryProvider, ); } From 4fc643d6f246a833781f35d19dbc229a55494c16 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:04:57 -0500 Subject: [PATCH 4/9] Remoce invalid openalex filter --- lib/widgets/article_openAlex_search_form.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index dfc3cfd1..68a7dc9b 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -284,10 +284,6 @@ class OpenAlexSearchFormState extends State { value: "cited_by_count", child: Text(AppLocalizations.of(context)!.citedByCount)), - DropdownMenuItem( - value: "works_count", - child: - Text(AppLocalizations.of(context)!.worksCount)), DropdownMenuItem( value: "publication_date", child: Text( From 7486cc7f8d2d712f7609b7925b380a5ca584085d Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:38:55 -0500 Subject: [PATCH 5/9] Add OpenAlex API key --- lib/l10n/app_en.arb | 8 + lib/screens/api_settings_screen.dart | 239 ++++++++++++++++++ lib/screens/database_settings_screen.dart | 140 +--------- lib/screens/settings_screen.dart | 11 + lib/services/feed_api.dart | 11 +- lib/services/openAlex_api.dart | 13 +- lib/widgets/article_openAlex_search_form.dart | 31 +++ 7 files changed, 310 insertions(+), 143 deletions(-) create mode 100644 lib/screens/api_settings_screen.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1c455fa0..fc627ef8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -306,6 +306,14 @@ "@noFavorites": { "description": "The message shown when the user has no articles in their favorites." }, + "apiSettings":"API settings", + "@apiSettings":{}, + "openAlexApiKey":"OpenAlex API Key", + "@openAlexApiKey":{}, + "openAlexApiKeyDesc":"OpenAlex requires a free API key to access their API. While requests may still work without a key, using one is strongly recommended to avoid rate limits and ensure reliability.", + "@openAlexApiKeyDesc":{}, + "openAlexNoApiKey":"No OpenAlex API key set. Please add one in settings to prevent disruptions.", + "@openAlexNoApiKey":{}, "display": "Display", "@display": {}, "displaySettings": "Display settings", diff --git a/lib/screens/api_settings_screen.dart b/lib/screens/api_settings_screen.dart new file mode 100644 index 00000000..f5361b51 --- /dev/null +++ b/lib/screens/api_settings_screen.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:wispar/services/openAlex_api.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; + +class ApiSettingsScreen extends StatefulWidget { + const ApiSettingsScreen({super.key}); + + @override + State createState() => ApiSettingsScreenState(); +} + +class ApiSettingsScreenState extends State { + final TextEditingController _openAlexKeyController = TextEditingController(); + bool passwordVisible = false; + + bool _scrapeAbstracts = true; // Default to scraping missing abstracts + int _fetchInterval = 6; // Default API fetch to 6 hours + int _concurrentFetches = 3; // Default to 3 concurrent fetches + + bool _overrideUserAgent = false; + final TextEditingController _userAgentController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadKeys(); + } + + @override + void dispose() { + _userAgentController.dispose(); + super.dispose(); + } + + Future _loadKeys() async { + final prefs = await SharedPreferences.getInstance(); + + final openAlexKey = prefs.getString('openalex_api_key') ?? ''; + final fetchInterval = prefs.getInt('fetchInterval') ?? 6; + final scrapeAbstracts = prefs.getBool('scrapeAbstracts') ?? true; + final concurrentFetches = prefs.getInt('concurrentFetches') ?? 3; + final overrideUserAgent = prefs.getBool('overrideUserAgent') ?? false; + final customUserAgent = prefs.getString('customUserAgent') ?? ''; + + setState(() { + _openAlexKeyController.text = openAlexKey; + _fetchInterval = fetchInterval; + _scrapeAbstracts = scrapeAbstracts; + _concurrentFetches = concurrentFetches; + _overrideUserAgent = overrideUserAgent; + _userAgentController.text = customUserAgent; + }); + } + + Future _saveKeys() async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setString( + 'openalex_api_key', _openAlexKeyController.text.trim()); + + await prefs.setInt('fetchInterval', _fetchInterval); + await prefs.setBool('scrapeAbstracts', _scrapeAbstracts); + await prefs.setInt('concurrentFetches', _concurrentFetches); + await prefs.setBool('overrideUserAgent', _overrideUserAgent); + if (_overrideUserAgent) { + await prefs.setString('customUserAgent', _userAgentController.text); + } + + OpenAlexApi.apiKey = _openAlexKeyController.text.trim(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.settingsSaved)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.apiSettings), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.openAlexApiKeyDesc, + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () { + launchUrl( + Uri.parse('https://openalex.org/settings/api'), + ); + }, + child: Text(AppLocalizations.of(context)!.zoteroCreateKey)), + const SizedBox(height: 16), + TextField( + controller: _openAlexKeyController, + obscureText: !passwordVisible, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.openAlexApiKey, + border: OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + passwordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + passwordVisible = !passwordVisible; + }); + }, + ), + ), + onChanged: (value) {}, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.cachedArticleRetentionDaysDesc, + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + isExpanded: true, + initialValue: _fetchInterval, + onChanged: (int? newValue) { + setState(() { + _fetchInterval = newValue!; + }); + }, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.apiFetchInterval, + hintText: AppLocalizations.of(context)!.apiFetchIntervalHint, + ), + items: [ + DropdownMenuItem( + value: 3, + child: Text('3 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 6, + child: Text('6 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 12, + child: Text('12 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 24, + child: Text('24 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 48, + child: Text('48 ${AppLocalizations.of(context)!.hours}'), + ), + DropdownMenuItem( + value: 72, + child: Text('72 ${AppLocalizations.of(context)!.hours}'), + ), + ], + ), + SizedBox(height: 16), + Text( + AppLocalizations.of(context)! + .concurrentFetches(_concurrentFetches), + ), + Slider( + value: _concurrentFetches.toDouble(), + min: 1, + max: 5, + divisions: 4, + label: _concurrentFetches.toString(), + onChanged: (double value) { + setState(() { + _concurrentFetches = value.toInt(); + }); + }, + ), + SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context)!.scrapeAbstracts, + ), + Switch( + value: _scrapeAbstracts, + onChanged: (bool value) async { + setState(() { + _scrapeAbstracts = value; + }); + }, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(AppLocalizations.of(context)!.overrideUserAgent), + Switch( + value: _overrideUserAgent, + onChanged: (bool value) { + setState(() { + _overrideUserAgent = value; + }); + }, + ), + ], + ), + if (_overrideUserAgent) + TextFormField( + controller: _userAgentController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.customUserAgent, + hintText: + "Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:140.0) Gecko/140.0 Firefox/140.0", + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _saveKeys, + child: Text(AppLocalizations.of(context)!.saveSettings), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/database_settings_screen.dart b/lib/screens/database_settings_screen.dart index d16731e7..2f47db3a 100644 --- a/lib/screens/database_settings_screen.dart +++ b/lib/screens/database_settings_screen.dart @@ -1,14 +1,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; -import '../services/logs_helper.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/services/database_helper.dart'; import 'package:flutter/services.dart'; class DatabaseSettingsScreen extends StatefulWidget { @@ -23,16 +23,11 @@ class DatabaseSettingsScreenState extends State { final logger = LogsService().logger; final _formKey = GlobalKey(); - bool _scrapeAbstracts = true; // Default to scraping missing abstracts int _cleanupThreshold = 90; // Default for cleanup interval - int _fetchInterval = 6; // Default API fetch to 6 hours - int _concurrentFetches = 3; // Default to 3 concurrent fetches + final TextEditingController _cleanupThresholdController = TextEditingController(); - bool _overrideUserAgent = false; - final TextEditingController _userAgentController = TextEditingController(); - bool _useCustomPath = false; String? _customDatabasePath; String? @@ -50,11 +45,6 @@ class DatabaseSettingsScreenState extends State { setState(() { // Load the values from SharedPreferences if available _cleanupThreshold = prefs.getInt('cleanupThreshold') ?? 90; - _fetchInterval = prefs.getInt('fetchInterval') ?? 6; - _scrapeAbstracts = prefs.getBool('scrapeAbstracts') ?? true; - _concurrentFetches = prefs.getInt('concurrentFetches') ?? 3; - _overrideUserAgent = prefs.getBool('overrideUserAgent') ?? false; - _userAgentController.text = prefs.getString('customUserAgent') ?? ''; _useCustomPath = prefs.getBool('useCustomDatabasePath') ?? false; _customDatabasePath = prefs.getString('customDatabasePath'); _customDatabaseBookmark = prefs.getString('customDatabaseBookmark'); @@ -67,13 +57,6 @@ class DatabaseSettingsScreenState extends State { if (_formKey.currentState?.validate() ?? false) { SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setInt('cleanupThreshold', _cleanupThreshold); - await prefs.setInt('fetchInterval', _fetchInterval); - await prefs.setBool('scrapeAbstracts', _scrapeAbstracts); - await prefs.setInt('concurrentFetches', _concurrentFetches); - await prefs.setBool('overrideUserAgent', _overrideUserAgent); - if (_overrideUserAgent) { - await prefs.setString('customUserAgent', _userAgentController.text); - } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.settingsSaved)), @@ -537,7 +520,6 @@ class DatabaseSettingsScreenState extends State { @override void dispose() { _cleanupThresholdController.dispose(); - _userAgentController.dispose(); super.dispose(); } @@ -586,119 +568,6 @@ class DatabaseSettingsScreenState extends State { return null; }, ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)! - .cachedArticleRetentionDaysDesc, - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - isExpanded: true, - initialValue: _fetchInterval, - onChanged: (int? newValue) { - setState(() { - _fetchInterval = newValue!; - }); - }, - decoration: InputDecoration( - labelText: - AppLocalizations.of(context)!.apiFetchInterval, - hintText: - AppLocalizations.of(context)!.apiFetchIntervalHint, - ), - items: [ - DropdownMenuItem( - value: 3, - child: - Text('3 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 6, - child: - Text('6 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 12, - child: - Text('12 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 24, - child: - Text('24 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 48, - child: - Text('48 ${AppLocalizations.of(context)!.hours}'), - ), - DropdownMenuItem( - value: 72, - child: - Text('72 ${AppLocalizations.of(context)!.hours}'), - ), - ], - ), - SizedBox(height: 16), - Text( - AppLocalizations.of(context)! - .concurrentFetches(_concurrentFetches), - ), - Slider( - value: _concurrentFetches.toDouble(), - min: 1, - max: 5, - divisions: 4, - label: _concurrentFetches.toString(), - onChanged: (double value) { - setState(() { - _concurrentFetches = value.toInt(); - }); - }, - ), - SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - AppLocalizations.of(context)!.scrapeAbstracts, - ), - Switch( - value: _scrapeAbstracts, - onChanged: (bool value) async { - setState(() { - _scrapeAbstracts = value; - }); - }, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(AppLocalizations.of(context)!.overrideUserAgent), - Switch( - value: _overrideUserAgent, - onChanged: (bool value) { - setState(() { - _overrideUserAgent = value; - }); - }, - ), - ], - ), - if (_overrideUserAgent) - TextFormField( - controller: _userAgentController, - decoration: InputDecoration( - labelText: - AppLocalizations.of(context)!.customUserAgent, - hintText: - "Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:140.0) Gecko/140.0 Firefox/140.0", - ), - ), const SizedBox(height: 16), FilledButton( onPressed: _saveSettings, @@ -706,7 +575,6 @@ class DatabaseSettingsScreenState extends State { ), const SizedBox(height: 64), const Divider(), - const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ff71ad82..9beef963 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:wispar/screens/zotero_settings_screen.dart'; +import 'package:wispar/screens/api_settings_screen.dart'; import 'package:wispar/screens/ai_settings_screen.dart'; import 'package:wispar/screens/database_settings_screen.dart'; import 'package:wispar/screens/display_settings_screen.dart'; @@ -97,6 +98,16 @@ class SettingsScreenState extends State { MaterialPageRoute( builder: (context) => const PublicationCardSettingsScreen())), ), + _buildTile( + icon: Icons.api_outlined, + label: AppLocalizations.of(context)!.apiSettings, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ApiSettingsScreen(), + ), + ), + ), _buildTile( icon: Icons.lock_open_outlined, label: 'Unpaywall', diff --git a/lib/services/feed_api.dart b/lib/services/feed_api.dart index 84e63387..6e692261 100644 --- a/lib/services/feed_api.dart +++ b/lib/services/feed_api.dart @@ -1,13 +1,16 @@ import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; -import '../models/crossref_journals_works_models.dart' as journalWorks; -import '../models/openAlex_works_models.dart'; +import 'package:wispar/models/crossref_journals_works_models.dart' + as journalWorks; +import 'package:wispar/models/openAlex_works_models.dart'; class FeedApi { static const String baseUrl = 'https://api.crossref.org'; static const String journalsEndpoint = '/journals'; static const String worksEndpoint = '/works'; static const String email = 'mailto=wispar-app@protonmail.com'; + static String? openAlexApiKey; static const String baseUrlOpenAlex = 'https://api.openalex.org'; static const String worksEndpointOpenAlex = '/works?'; @@ -51,8 +54,10 @@ class FeedApi { static Future> getSavedQueryOpenAlex( String query) async { + final prefs = await SharedPreferences.getInstance(); + openAlexApiKey = prefs.getString('openalex_api_key'); final url = Uri.parse( - '$baseUrlOpenAlex$worksEndpointOpenAlex$query&per-page=50&$email'); + '$baseUrlOpenAlex$worksEndpointOpenAlex$query&per-page=50${openAlexApiKey != null && openAlexApiKey!.isNotEmpty ? '&api_key=$openAlexApiKey' : ''}'); final response = await http.get(url); diff --git a/lib/services/openAlex_api.dart b/lib/services/openAlex_api.dart index f18635e7..772ebc3c 100644 --- a/lib/services/openAlex_api.dart +++ b/lib/services/openAlex_api.dart @@ -1,16 +1,21 @@ import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; -import '../models/openAlex_works_models.dart'; -import '../models/crossref_journals_works_models.dart' as journalWorks; +import 'package:wispar/models/openAlex_works_models.dart'; +import 'package:wispar/models/crossref_journals_works_models.dart' + as journalWorks; class OpenAlexApi { static const String baseUrl = 'https://api.openalex.org'; static const String worksEndpoint = '/works?'; - static const String email = 'mailto=wispar-app@protonmail.com'; + static String? apiKey; static Future> getOpenAlexWorksByQuery(String query, int scope, String? sortField, String? sortOrder, String? dateFilter, {int page = 1}) async { + final prefs = await SharedPreferences.getInstance(); + apiKey = prefs.getString('openalex_api_key'); + Map scopeMap = { 1: '', // Everything 2: 'title_and_abstract.search:', // Title and Abstract @@ -44,7 +49,7 @@ class OpenAlexApi { String apiUrl = '$baseUrl/works?$searchPart' '${filterPart.isNotEmpty ? '&$filterPart' : ''}' '$sortPart' - '&$email' + '${apiKey != null && apiKey!.isNotEmpty ? '&api_key=$apiKey' : ''}' '&page=$page'; final response = await http.get(Uri.parse(apiUrl)); diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index 68a7dc9b..a39f4522 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -1,4 +1,5 @@ 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'; @@ -14,6 +15,7 @@ class OpenAlexSearchForm extends StatefulWidget { } class OpenAlexSearchFormState extends State { + bool _hasApiKey = true; List> queryParts = []; String searchScope = 'Everything'; String selectedSortField = '-'; @@ -28,6 +30,12 @@ class OpenAlexSearchFormState extends State { final TextEditingController queryNameController = TextEditingController(); + @override + void initState() { + super.initState(); + _checkApiKey(); + } + void _addQueryPart(String type) { setState(() { if (queryParts.isNotEmpty && queryParts.last['type'] != 'operator') { @@ -221,6 +229,15 @@ class OpenAlexSearchFormState extends State { } } + Future _checkApiKey() async { + final prefs = await SharedPreferences.getInstance(); + final key = prefs.getString('openalex_api_key') ?? ''; + + setState(() { + _hasApiKey = key.isNotEmpty; + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -229,6 +246,20 @@ class OpenAlexSearchFormState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!_hasApiKey) + Center( + child: Text( + AppLocalizations.of(context)!.openAlexNoApiKey, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: Colors.red, + ), + ), + ), + const SizedBox( + height: 8, + ), DropdownButtonFormField( initialValue: searchScope, onChanged: (String? newValue) { From 33a8fe4861722e70b0c0280a4edfd5593d48008d Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:28:58 -0500 Subject: [PATCH 6/9] Add date filter for Crossref --- lib/widgets/article_query_search_form.dart | 127 +++++++++++++++++++-- 1 file changed, 115 insertions(+), 12 deletions(-) diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index 074c5fa2..17ea8023 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; -import '../services/crossref_api.dart'; -import '../screens/article_search_results_screen.dart'; -import '../services/database_helper.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'; class QuerySearchForm extends StatefulWidget { // The key allows to access the state of the form from outside - const QuerySearchForm({Key? key}) : super(key: key); + const QuerySearchForm({super.key}); @override QuerySearchFormState createState() => QuerySearchFormState(); @@ -14,6 +14,9 @@ class QuerySearchForm extends StatefulWidget { class QuerySearchFormState extends State { final GlobalKey _formKey = GlobalKey(); + DateTime? _createdAfter; + DateTime? _createdBefore; + String _dateMode = 'none'; bool isAdvancedSearchVisible = false; bool saveQuery = false; int selectedSortBy = 0; @@ -192,6 +195,25 @@ class QuerySearchFormState extends State { super.dispose(); } + Future _pickDate(BuildContext context, bool isAfter) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + if (isAfter) { + _createdAfter = picked; + } else { + _createdBefore = picked; + } + }); + } + } + void submitForm() async { // Gather all input values, ignoring empty fields final Map queryParams = {}; @@ -213,8 +235,9 @@ class QuerySearchFormState extends State { if (affiliation.isNotEmpty) queryParams['query.affiliation'] = affiliation; final bibliographic = bibliographicController.text.trim(); - if (bibliographic.isNotEmpty) + if (bibliographic.isNotEmpty) { queryParams['query.bibliographic'] = bibliographic; + } final degree = degreeController.text.trim(); if (degree.isNotEmpty) queryParams['query.degree'] = degree; @@ -230,19 +253,22 @@ class QuerySearchFormState extends State { } final eventAcronym = eventAcronymController.text.trim(); - if (eventAcronym.isNotEmpty) + if (eventAcronym.isNotEmpty) { queryParams['query.event-acronym'] = eventAcronym; + } final eventLocation = eventLocationController.text.trim(); - if (eventLocation.isNotEmpty) + if (eventLocation.isNotEmpty) { queryParams['query.event-location'] = eventLocation; + } final eventName = eventNameController.text.trim(); if (eventName.isNotEmpty) queryParams['query.event-name'] = eventName; final eventSponsor = eventSponsorController.text.trim(); - if (eventSponsor.isNotEmpty) + if (eventSponsor.isNotEmpty) { queryParams['query.event-sponsor'] = eventSponsor; + } final eventTheme = eventThemeController.text.trim(); if (eventTheme.isNotEmpty) queryParams['query.event-theme'] = eventTheme; @@ -251,16 +277,19 @@ class QuerySearchFormState extends State { if (funderName.isNotEmpty) queryParams['query.funder-name'] = funderName; final publisherLocation = publisherLocationController.text.trim(); - if (publisherLocation.isNotEmpty) + if (publisherLocation.isNotEmpty) { queryParams['query.publisher-location'] = publisherLocation; + } final standardsBodyAcronym = standardsBodyAcronymController.text.trim(); - if (standardsBodyAcronym.isNotEmpty) + if (standardsBodyAcronym.isNotEmpty) { queryParams['query.standards-body-acronym'] = standardsBodyAcronym; + } final standardsBodyName = standardsBodyNameController.text.trim(); - if (standardsBodyName.isNotEmpty) + if (standardsBodyName.isNotEmpty) { queryParams['query.standards-body-name'] = standardsBodyName; + } // Handle sorting options if (selectedSortBy != 0) { @@ -289,6 +318,27 @@ class QuerySearchFormState extends State { queryParams['order'] = orderOptions[selectedSortOrder]; } + String? dateFilter; + + String formatDate(DateTime d) => d.toIso8601String().split('T')[0]; + + if (_dateMode == 'after' && _createdAfter != null) { + dateFilter = 'from-created-date:${formatDate(_createdAfter!)}'; + } else if (_dateMode == 'before' && _createdBefore != null) { + dateFilter = 'until-created-date:${formatDate(_createdBefore!)}'; + } else if (_dateMode == 'between' && + _createdAfter != null && + _createdBefore != null) { + dateFilter = 'from-created-date:${formatDate(_createdAfter!)},' + 'until-created-date:${formatDate(_createdBefore!)}'; + } + + if (dateFilter != null) { + queryParams['filter'] = queryParams.containsKey('filter') + ? '${queryParams['filter']},$dateFilter' + : dateFilter; + } + // Show loading indicator showDialog( context: context, @@ -416,6 +466,59 @@ class QuerySearchFormState extends State { ), ), SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _dateMode, + onChanged: (value) { + setState(() { + _dateMode = value!; + _createdAfter = null; + _createdBefore = null; + }); + }, + items: [ + DropdownMenuItem( + value: 'none', + child: Text(AppLocalizations.of(context)!.noFilter), + ), + DropdownMenuItem( + value: 'after', + child: Text(AppLocalizations.of(context)!.publishedAfter), + ), + DropdownMenuItem( + value: 'before', + child: Text(AppLocalizations.of(context)!.publishedBefore), + ), + DropdownMenuItem( + value: 'between', + child: Text(AppLocalizations.of(context)!.publishedBetween), + ), + ], + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.publicationDate, + border: OutlineInputBorder(), + ), + ), + + SizedBox(height: 8), + + if (_dateMode == 'after' || _dateMode == 'between') + ListTile( + title: Text(_createdAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _createdAfter!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today), + onTap: () => _pickDate(context, true), + ), + + if (_dateMode == 'before' || _dateMode == 'between') + ListTile( + title: Text(_createdBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _createdBefore!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today), + onTap: () => _pickDate(context, false), + ), + SizedBox(height: 16), // Sort by and sort order fields Row( children: [ From 04994ad20407222e9a69c1d2759675aec6b3c4db Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:18:03 -0500 Subject: [PATCH 7/9] Add date filters for custom feeds --- lib/models/feed_filter_entity.dart | 6 ++ lib/screens/home_screen.dart | 98 +++++++++++++++--- lib/services/database_helper.dart | 28 ++++- lib/widgets/custom_feed_bottom_sheet.dart | 121 +++++++++++++++++++++- 4 files changed, 234 insertions(+), 19 deletions(-) diff --git a/lib/models/feed_filter_entity.dart b/lib/models/feed_filter_entity.dart index 341baa61..b10afaf4 100644 --- a/lib/models/feed_filter_entity.dart +++ b/lib/models/feed_filter_entity.dart @@ -4,6 +4,9 @@ class FeedFilter { final String include; final String exclude; final Set journals; + final String? dateMode; + final String? dateAfter; + final String? dateBefore; final String dateCreated; FeedFilter({ @@ -12,6 +15,9 @@ class FeedFilter { required this.include, required this.exclude, required this.journals, + this.dateMode, + this.dateAfter, + this.dateBefore, required this.dateCreated, }); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 50bac9bf..a6d830df 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -126,7 +126,14 @@ class HomeScreenState extends State { _currentFeedName = match.name; }); _applyAdvancedFilters( - match.name, match.journals, match.include, match.exclude); + match.name, + match.journals, + match.include, + match.exclude, + match.dateMode, + match.dateAfter, + match.dateBefore, + ); } } else { setState(() { @@ -436,9 +443,17 @@ class HomeScreenState extends State { return CustomizeFeedBottomSheet( followedJournals: followedJournals, moreJournals: unfollowedJournals, - onApply: (String feedName, Set journals, String include, - String exclude) { - _applyAdvancedFilters(feedName, journals, include, exclude); + onApply: ( + String feedName, + Set journals, + String include, + String exclude, + String? dateMode, + String? dateAfter, + String? dateBefore, + ) { + _applyAdvancedFilters(feedName, journals, include, exclude, + dateMode, dateAfter, dateBefore); }, ); }, @@ -641,7 +656,14 @@ class HomeScreenState extends State { } void _applyAdvancedFilters( - String feedName, Set journals, String include, String exclude) { + String feedName, + Set journals, + String include, + String exclude, + String? dateMode, + String? dateAfter, + String? dateBefore, + ) { if (journals.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -680,6 +702,31 @@ class HomeScreenState extends State { ? true : excludeWords.every((word) => !content.contains(word)); + if (dateMode != null && dateMode != 'none') { + final pubDate = pub.publishedDate; + if (pubDate == null) return false; + + if (dateMode == 'after' && dateAfter != null) { + final afterDate = DateTime.parse(dateAfter); + if (!pubDate.isAfter(afterDate)) return false; + } + + if (dateMode == 'before' && dateBefore != null) { + final beforeDate = DateTime.parse(dateBefore); + if (!pubDate.isBefore(beforeDate)) return false; + } + + if (dateMode == 'between' && + dateAfter != null && + dateBefore != null) { + final afterDate = DateTime.parse(dateAfter); + final beforeDate = DateTime.parse(dateBefore); + if (pubDate.isBefore(afterDate) || pubDate.isAfter(beforeDate)) { + return false; + } + } + } + return matchesInclude && matchesExclude; }).toList(); @@ -708,7 +755,14 @@ class HomeScreenState extends State { if (match.name != 'Home') { _applyAdvancedFilters( - match.name, match.journals, match.include, match.exclude); + match.name, + match.journals, + match.include, + match.exclude, + match.dateMode, + match.dateAfter, + match.dateBefore, + ); } } @@ -826,13 +880,28 @@ class HomeScreenState extends State { initialExclude: filter.exclude, initialSelectedJournals: filter.journals, + initialDateMode: filter.dateMode, + initialDateAfter: filter.dateAfter, + initialDateBefore: + filter.dateBefore, feedId: filter.id, - onApply: (String feedName, - Set journals, - String include, - String exclude) { - _applyAdvancedFilters(feedName, - journals, include, exclude); + onApply: ( + String feedName, + Set journals, + String include, + String exclude, + String? dateMode, + String? dateAfter, + String? dateBefore, + ) { + _applyAdvancedFilters( + feedName, + journals, + include, + exclude, + dateMode, + dateAfter, + dateBefore); }, ); }, @@ -893,7 +962,10 @@ class HomeScreenState extends State { filter.name, filter.journals, filter.include, - filter.exclude); + filter.exclude, + filter.dateMode, + filter.dateAfter, + filter.dateBefore); } } : null, diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index d943bd1b..1e176c9f 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -64,7 +64,7 @@ class DatabaseHelper { Future initDatabase() async { String databasePath = await getDbPath(); - return openDatabase(databasePath, version: 9, onOpen: (db) async { + return openDatabase(databasePath, version: 10, onOpen: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, onCreate: (db, version) async { await db.execute('PRAGMA foreign_keys = ON'); @@ -269,6 +269,17 @@ class DatabaseHelper { ALTER TABLE articles ADD COLUMN graphAbstractPath TEXT; '''); } + if (oldVersion < 10) { + await db.execute(''' + ALTER TABLE feed_filters ADD COLUMN date_mode TEXT; + '''); + await db.execute(''' + ALTER TABLE feed_filters ADD COLUMN date_after TEXT; + '''); + await db.execute(''' + ALTER TABLE feed_filters ADD COLUMN date_before TEXT; + '''); + } }); } @@ -1273,6 +1284,9 @@ class DatabaseHelper { required String include, required String exclude, required Set journals, + String? dateMode, + String? dateAfter, + String? dateBefore, }) async { final db = await database; @@ -1281,6 +1295,9 @@ class DatabaseHelper { 'includedKeywords': include, 'excludedKeywords': exclude, 'journals': journals.join(','), + 'date_mode': dateMode, + 'date_after': dateAfter, + 'date_before': dateBefore, }); } @@ -1298,6 +1315,9 @@ class DatabaseHelper { include: row['includedKeywords'] ?? '', exclude: row['excludedKeywords'] ?? '', journals: (row['journals'] ?? '').split(',').toSet(), + dateMode: row['date_mode'], + dateAfter: row['date_after'], + dateBefore: row['date_before'], dateCreated: row['dateCreated'], ); }).toList(); @@ -1309,6 +1329,9 @@ class DatabaseHelper { required String include, required String exclude, required Set journals, + String? dateMode, + String? dateAfter, + String? dateBefore, }) async { final db = await database; @@ -1319,6 +1342,9 @@ class DatabaseHelper { 'includedKeywords': include, 'excludedKeywords': exclude, 'journals': journals.join(','), + 'date_mode': dateMode, + 'date_after': dateAfter, + 'date_before': dateBefore, }, where: 'id = ?', whereArgs: [id], diff --git a/lib/widgets/custom_feed_bottom_sheet.dart b/lib/widgets/custom_feed_bottom_sheet.dart index 9e625bed..15a1ccdc 100644 --- a/lib/widgets/custom_feed_bottom_sheet.dart +++ b/lib/widgets/custom_feed_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/services/database_helper.dart'; class CustomizeFeedBottomSheet extends StatefulWidget { final List followedJournals; @@ -10,12 +10,18 @@ class CustomizeFeedBottomSheet extends StatefulWidget { Set journals, String include, String exclude, + String dateMode, + String? dateAfter, + String? dateBefore, ) onApply; final String? initialName; final String? initialInclude; final String? initialExclude; final Set? initialSelectedJournals; + final String? initialDateMode; + final String? initialDateAfter; + final String? initialDateBefore; final int? feedId; const CustomizeFeedBottomSheet( @@ -27,14 +33,17 @@ class CustomizeFeedBottomSheet extends StatefulWidget { this.initialInclude, this.initialExclude, this.initialSelectedJournals, + this.initialDateMode, + this.initialDateAfter, + this.initialDateBefore, this.feedId}); @override - _CustomizeFeedBottomSheetState createState() => - _CustomizeFeedBottomSheetState(); + CustomizeFeedBottomSheetState createState() => + CustomizeFeedBottomSheetState(); } -class _CustomizeFeedBottomSheetState extends State { +class CustomizeFeedBottomSheetState extends State { late TextEditingController _nameController; late TextEditingController _includeController; late TextEditingController _excludeController; @@ -48,10 +57,24 @@ class _CustomizeFeedBottomSheetState extends State { Set _selectedJournals = {}; bool _showMoreJournals = false; + DateTime? _publishedDateAfter; + DateTime? _publishedDateBefore; + String _dateMode = 'none'; + @override void initState() { super.initState(); + _dateMode = widget.initialDateMode ?? 'none'; + + if (widget.initialDateAfter != null) { + _publishedDateAfter = DateTime.parse(widget.initialDateAfter!); + } + + if (widget.initialDateBefore != null) { + _publishedDateBefore = DateTime.parse(widget.initialDateBefore!); + } + _nameController = TextEditingController(text: widget.initialName ?? ''); _includeController = TextEditingController(text: widget.initialInclude ?? ''); @@ -116,6 +139,25 @@ class _CustomizeFeedBottomSheetState extends State { }); } + Future _pickDate(BuildContext context, bool isAfter) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (picked != null) { + setState(() { + if (isAfter) { + _publishedDateAfter = picked; + } else { + _publishedDateBefore = picked; + } + }); + } + } + @override Widget build(BuildContext context) { return SafeArea( @@ -282,6 +324,62 @@ class _CustomizeFeedBottomSheetState extends State { AppLocalizations.of(context)!.typePressSpace), ), const SizedBox(height: 24), + DropdownButtonFormField( + initialValue: _dateMode, + items: [ + DropdownMenuItem( + value: 'none', + child: + Text(AppLocalizations.of(context)!.noFilter)), + DropdownMenuItem( + value: 'after', + child: Text(AppLocalizations.of(context)! + .publishedAfter)), + DropdownMenuItem( + value: 'before', + child: Text(AppLocalizations.of(context)! + .publishedBefore)), + DropdownMenuItem( + value: 'between', + child: Text(AppLocalizations.of(context)! + .publishedBetween)), + ], + onChanged: (value) { + setState(() { + _dateMode = value!; + if (_dateMode == 'none') { + _publishedDateAfter = null; + _publishedDateBefore = null; + } + }); + }, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: + AppLocalizations.of(context)!.publicationDate), + ), + const SizedBox(height: 8), + if (_dateMode == 'after' || _dateMode == 'between') + ListTile( + title: Text(_publishedDateAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _publishedDateAfter! + .toIso8601String() + .split('T')[0]), + trailing: const Icon(Icons.calendar_today), + onTap: () => _pickDate(context, true), + ), + + if (_dateMode == 'before' || _dateMode == 'between') + ListTile( + title: Text(_publishedDateBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _publishedDateBefore! + .toIso8601String() + .split('T')[0]), + trailing: const Icon(Icons.calendar_today), + onTap: () => _pickDate(context, false), + ), ], ), ), @@ -330,6 +428,9 @@ class _CustomizeFeedBottomSheetState extends State { include: include, exclude: exclude, journals: _selectedJournals, + dateMode: _dateMode, + dateAfter: _publishedDateAfter?.toIso8601String(), + dateBefore: _publishedDateBefore?.toIso8601String(), ); } else { await db.insertFeedFilter( @@ -337,11 +438,21 @@ class _CustomizeFeedBottomSheetState extends State { include: include, exclude: exclude, journals: _selectedJournals, + dateMode: _dateMode, + dateAfter: _publishedDateAfter?.toIso8601String(), + dateBefore: _publishedDateBefore?.toIso8601String(), ); } widget.onApply( - feedName, _selectedJournals, include, exclude); + feedName, + _selectedJournals, + include, + exclude, + _dateMode, + _publishedDateAfter?.toIso8601String(), + _publishedDateBefore?.toIso8601String(), + ); Navigator.pop(context); }, label: Text(AppLocalizations.of(context)!.save), From 5b8f5b496b183b364a161c6fda5ebc7580b52920 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:08:30 -0500 Subject: [PATCH 8/9] Move back cache setting description --- lib/screens/api_settings_screen.dart | 5 ----- lib/screens/database_settings_screen.dart | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/screens/api_settings_screen.dart b/lib/screens/api_settings_screen.dart index f5361b51..510b932e 100644 --- a/lib/screens/api_settings_screen.dart +++ b/lib/screens/api_settings_screen.dart @@ -123,11 +123,6 @@ class ApiSettingsScreenState extends State { onChanged: (value) {}, ), const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.cachedArticleRetentionDaysDesc, - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 8), DropdownButtonFormField( isExpanded: true, initialValue: _fetchInterval, diff --git a/lib/screens/database_settings_screen.dart b/lib/screens/database_settings_screen.dart index 2f47db3a..8a082d63 100644 --- a/lib/screens/database_settings_screen.dart +++ b/lib/screens/database_settings_screen.dart @@ -538,6 +538,12 @@ class DatabaseSettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Text( + AppLocalizations.of(context)! + .cachedArticleRetentionDaysDesc, + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 8), TextFormField( controller: _cleanupThresholdController, keyboardType: TextInputType.number, From a88d17d45a77581aa9f8685a8a9d649cc7393a12 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:31:32 -0500 Subject: [PATCH 9/9] Add borders to calendar pickers --- lib/widgets/article_openAlex_search_form.dart | 42 ++++++++++----- lib/widgets/article_query_search_form.dart | 40 +++++++++----- lib/widgets/custom_feed_bottom_sheet.dart | 52 +++++++++++++------ 3 files changed, 94 insertions(+), 40 deletions(-) diff --git a/lib/widgets/article_openAlex_search_form.dart b/lib/widgets/article_openAlex_search_form.dart index a39f4522..860f5a70 100644 --- a/lib/widgets/article_openAlex_search_form.dart +++ b/lib/widgets/article_openAlex_search_form.dart @@ -390,21 +390,39 @@ class OpenAlexSearchFormState extends State { SizedBox(height: 10), if (_dateMode == 'after' || _dateMode == 'between') - ListTile( - title: Text(_publishedAfter == null - ? AppLocalizations.of(context)!.selectStartDate - : _publishedAfter!.toIso8601String().split('T')[0]), - trailing: Icon(Icons.calendar_today), - onTap: () => _pickDate(context, true), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text( + _publishedAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _publishedAfter!.toIso8601String().split('T')[0], + ), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, true), + ), ), if (_dateMode == 'before' || _dateMode == 'between') - ListTile( - title: Text(_publishedBefore == null - ? AppLocalizations.of(context)!.selectEndDate - : _publishedBefore!.toIso8601String().split('T')[0]), - trailing: Icon(Icons.calendar_today), - onTap: () => _pickDate(context, false), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_publishedBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _publishedBefore!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, false), + ), ), SizedBox(height: 10), diff --git a/lib/widgets/article_query_search_form.dart b/lib/widgets/article_query_search_form.dart index 17ea8023..98455009 100644 --- a/lib/widgets/article_query_search_form.dart +++ b/lib/widgets/article_query_search_form.dart @@ -502,21 +502,37 @@ class QuerySearchFormState extends State { SizedBox(height: 8), if (_dateMode == 'after' || _dateMode == 'between') - ListTile( - title: Text(_createdAfter == null - ? AppLocalizations.of(context)!.selectStartDate - : _createdAfter!.toIso8601String().split('T')[0]), - trailing: Icon(Icons.calendar_today), - onTap: () => _pickDate(context, true), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_createdAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _createdAfter!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, true), + ), ), if (_dateMode == 'before' || _dateMode == 'between') - ListTile( - title: Text(_createdBefore == null - ? AppLocalizations.of(context)!.selectEndDate - : _createdBefore!.toIso8601String().split('T')[0]), - trailing: Icon(Icons.calendar_today), - onTap: () => _pickDate(context, false), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_createdBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _createdBefore!.toIso8601String().split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, false), + ), ), SizedBox(height: 16), // Sort by and sort order fields diff --git a/lib/widgets/custom_feed_bottom_sheet.dart b/lib/widgets/custom_feed_bottom_sheet.dart index 15a1ccdc..ebebd30b 100644 --- a/lib/widgets/custom_feed_bottom_sheet.dart +++ b/lib/widgets/custom_feed_bottom_sheet.dart @@ -360,25 +360,45 @@ class CustomizeFeedBottomSheetState extends State { ), const SizedBox(height: 8), if (_dateMode == 'after' || _dateMode == 'between') - ListTile( - title: Text(_publishedDateAfter == null - ? AppLocalizations.of(context)!.selectStartDate - : _publishedDateAfter! - .toIso8601String() - .split('T')[0]), - trailing: const Icon(Icons.calendar_today), - onTap: () => _pickDate(context, true), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_publishedDateAfter == null + ? AppLocalizations.of(context)!.selectStartDate + : _publishedDateAfter! + .toIso8601String() + .split('T')[0]), + trailing: Icon( + Icons.calendar_today, + color: Theme.of(context).primaryColor, + ), + onTap: () => _pickDate(context, true), + ), ), if (_dateMode == 'before' || _dateMode == 'between') - ListTile( - title: Text(_publishedDateBefore == null - ? AppLocalizations.of(context)!.selectEndDate - : _publishedDateBefore! - .toIso8601String() - .split('T')[0]), - trailing: const Icon(Icons.calendar_today), - onTap: () => _pickDate(context, false), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: ListTile( + title: Text(_publishedDateBefore == null + ? AppLocalizations.of(context)!.selectEndDate + : _publishedDateBefore! + .toIso8601String() + .split('T')[0]), + trailing: Icon(Icons.calendar_today, + color: Theme.of(context).primaryColor), + onTap: () => _pickDate(context, false), + ), ), ], ),