diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7a099068..6f7a6336 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,6 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - name: Install Ninja
+ run: sudo apt-get install -y ninja-build
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
diff --git a/README.md b/README.md
index dd04692e..29187af1 100644
--- a/README.md
+++ b/README.md
@@ -19,14 +19,19 @@
## Screenshots
-| Followed journals (dark) | Search results (light) | Journal details (light) | Abstract (dark) |
-|----------------------------------------------------|---------------------------------------------------------|--------------------------------------------------------------|----------------------------------------------------|
-|  |  |  |  |
+
+| Home screen (light) | Search screen (light) | Journals screen(dark) |
+|---------------------------------------------------|------------------------------------------------------|---------------------------------------------------------|
+|  |  |  |
+
+| Queries screen (dark) | Journal latest works (dark) | Abstract (dark) |
+|---------------------------------------------------|------------------------------------------------------|---------------------------------------------------------|
+|  |  |  |
## Description
-Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly searches scientific journals using the Crossref API. Stay updated on your preferred journals by following them and receive new article abstracts in your main feed. No account required. The integration of Unpaywall ensures convenient access to open-access articles, while EZproxy helps overcome subscription barriers.
+Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly searches scientific journals and articles using the Crossref API. Stay updated on your preferred journals by following them and receive new article abstracts in your main feed. No account required. The integration of Unpaywall ensures convenient access to open-access articles, while EZproxy helps overcome subscription barriers.
Wispar is still under development and is not ready yet. APK files can be obtained from the workflow artifacts (must be signed in).
@@ -34,6 +39,7 @@ Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly s
## Features overview
- [x] Search and follow journals
+ - [x] Search for articles and save the queries for easy access later
- [x] Download articles for offline access *
- [x] EZproxy and Unpaywall integration
- [x] Send articles to Zotero
@@ -45,9 +51,11 @@ Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly s
Wispar uses Weblate to manage translations. You can find the hosted instance at https://hosted.weblate.org/engage/wispar/
-Translation status:
-
+A huge thank you to Weblate for hosting the translations for free :heart:
+
+Translation status:
+
diff --git a/android/app/build.gradle b/android/app/build.gradle
index e139288a..50446f4e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -25,7 +25,7 @@ if (flutterVersionName == null) {
android {
namespace "app.wispar.wispar"
compileSdkVersion 34
- ndkVersion "25.1.8937393"
+ ndkVersion "27.0.12077973"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index f1eea43b..0a051e8d 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,8 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,9 +45,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 3b67f8ca..fedcdb5e 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -31,13 +31,18 @@
"@unfollow": {
"description": "The button text shown on journal cards when it is followed."
},
+ "library": "Library",
+ "@library": {
+ "description": "The library menu button and the app bar title when in the library screen."
+ },
+
"journals": "Journals",
"@journals": {
"description": "The journals menu button and the app bar title when in the journals screen."
},
- "search": "Search…",
+ "search": "Search",
"@search": {
- "description": "Placeholder text shown inside the search bar."
+ "description": "Text shown inside the search screen app bar and for the seach button."
},
"publisher": "Publisher",
"@publisher": {},
diff --git a/lib/main.dart b/lib/main.dart
index 92700bcb..f7f7bb70 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -5,8 +5,9 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:salomon_bottom_bar/salomon_bottom_bar.dart';
import 'theme_provider.dart';
import 'screens/home_screen.dart';
+import 'screens/search_screen.dart';
import 'screens/favorites_screen.dart';
-import 'screens/journals_screen.dart';
+import 'screens/library_screen.dart';
import 'screens/downloads_screen.dart';
void main() {
@@ -34,6 +35,7 @@ class _WisparState extends State {
var _currentIndex = 0;
final List _pages = [
const HomeScreen(),
+ const SearchScreen(),
const LibraryScreen(),
const FavoritesScreen(),
const DownloadsScreen(),
@@ -42,8 +44,10 @@ class _WisparState extends State {
switch (key) {
case 'home':
return AppLocalizations.of(context)!.home;
- case 'journals':
- return AppLocalizations.of(context)!.journals;
+ case 'search':
+ return AppLocalizations.of(context)!.search;
+ case 'library':
+ return AppLocalizations.of(context)!.library;
case 'favorites':
return AppLocalizations.of(context)!.favorites;
case 'downloads':
@@ -99,8 +103,13 @@ class _WisparState extends State {
selectedColor: Colors.deepPurpleAccent,
),
SalomonBottomBarItem(
- icon: const Icon(Icons.library_books_outlined),
- title: Text(getLocalizedText(bottomBarContext, 'journals')),
+ icon: const Icon(Icons.search_outlined),
+ title: Text(getLocalizedText(bottomBarContext, 'search')),
+ selectedColor: Colors.deepPurpleAccent,
+ ),
+ SalomonBottomBarItem(
+ icon: const Icon(Icons.my_library_books_outlined),
+ title: Text(getLocalizedText(bottomBarContext, 'library')),
selectedColor: Colors.deepPurpleAccent,
),
SalomonBottomBarItem(
diff --git a/lib/models/crossref_journals_models.dart b/lib/models/crossref_journals_models.dart
index aa94cbbf..99cc5dc9 100644
--- a/lib/models/crossref_journals_models.dart
+++ b/lib/models/crossref_journals_models.dart
@@ -78,7 +78,6 @@ class Item {
String publisher;
Map coverage;
String title;
- List subjects;
CoverageType coverageType;
Map flags;
List issn;
@@ -91,7 +90,6 @@ class Item {
required this.publisher,
required this.coverage,
required this.title,
- required this.subjects,
required this.coverageType,
required this.flags,
required this.issn,
@@ -99,21 +97,20 @@ class Item {
});
factory Item.fromJson(Map json) => Item(
- lastStatusCheckTime: json["last-status-check-time"],
- counts: Counts.fromJson(json["counts"]),
- breakdowns: Breakdowns.fromJson(json["breakdowns"]),
- publisher: json["publisher"],
- coverage: Map.from(json["coverage"])
- .map((k, v) => MapEntry(k, v?.toDouble())),
- title: json["title"],
- subjects: List.from(
- json["subjects"].map((x) => Subject.fromJson(x))),
- coverageType: CoverageType.fromJson(json["coverage-type"]),
- flags:
- Map.from(json["flags"]).map((k, v) => MapEntry(k, v)),
- issn: List.from(json["ISSN"].map((x) => x)),
+ 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: List.from(json["ISSN"]?.map((x) => x) ?? []),
issnType: List.from(
- json["issn-type"].map((x) => IssnType.fromJson(x))),
+ (json["issn-type"] ?? []).map((x) => IssnType.fromJson(x)),
+ ),
);
Map toJson() => {
@@ -124,7 +121,6 @@ class Item {
"coverage":
Map.from(coverage).map((k, v) => MapEntry(k, v)),
"title": title,
- "subjects": List.from(subjects.map((x) => x.toJson())),
"coverage-type": coverageType.toJson(),
"flags": Map.from(flags).map((k, v) => MapEntry(k, v)),
"ISSN": List.from(issn.map((x) => x)),
@@ -140,8 +136,10 @@ class Breakdowns {
});
factory Breakdowns.fromJson(Map json) => Breakdowns(
- doisByIssuedYear: List>.from(json["dois-by-issued-year"]
- .map((x) => List.from(x.map((x) => x)))),
+ doisByIssuedYear: List>.from(
+ (json["dois-by-issued-year"] ?? [])
+ .map((x) => List.from(x.map((x) => x ?? 0))),
+ ),
);
Map toJson() => {
@@ -162,9 +160,9 @@ class Counts {
});
factory Counts.fromJson(Map json) => Counts(
- currentDois: json["current-dois"],
- backfileDois: json["backfile-dois"],
- totalDois: json["total-dois"],
+ currentDois: json["current-dois"] ?? 0,
+ backfileDois: json["backfile-dois"] ?? 0,
+ totalDois: json["total-dois"] ?? 0,
);
Map toJson() => {
@@ -186,12 +184,12 @@ class CoverageType {
});
factory CoverageType.fromJson(Map json) => CoverageType(
- all: Map.from(json["all"])
- .map((k, v) => MapEntry(k, v?.toDouble())),
- backfile: Map.from(json["backfile"])
- .map((k, v) => MapEntry(k, v?.toDouble())),
- current: Map.from(json["current"])
- .map((k, v) => MapEntry(k, v?.toDouble())),
+ 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() => {
@@ -213,8 +211,8 @@ class IssnType {
});
factory IssnType.fromJson(Map json) => IssnType(
- value: json["value"],
- type: typeValues.map[json["type"]]!,
+ value: json["value"] ?? "",
+ type: typeValues.map[json["type"]] ?? Type.ELECTRONIC,
);
Map toJson() => {
@@ -228,26 +226,6 @@ enum Type { ELECTRONIC, PRINT }
final typeValues =
EnumValues({"electronic": Type.ELECTRONIC, "print": Type.PRINT});
-class Subject {
- int asjc;
- String name;
-
- Subject({
- required this.asjc,
- required this.name,
- });
-
- factory Subject.fromJson(Map json) => Subject(
- asjc: json["ASJC"],
- name: json["name"],
- );
-
- Map toJson() => {
- "ASJC": asjc,
- "name": name,
- };
-}
-
class Query {
int startIndex;
String searchTerms;
@@ -258,8 +236,8 @@ class Query {
});
factory Query.fromJson(Map json) => Query(
- startIndex: json["start-index"],
- searchTerms: json["search-terms"],
+ startIndex: json["start-index"] ?? 0,
+ searchTerms: json["search-terms"] ?? "",
);
Map toJson() => {
diff --git a/lib/models/crossref_journals_works_models.dart b/lib/models/crossref_journals_works_models.dart
index 86078ffe..c1312c2f 100644
--- a/lib/models/crossref_journals_works_models.dart
+++ b/lib/models/crossref_journals_works_models.dart
@@ -77,6 +77,7 @@ class Item {
final String primaryUrl;
final String license;
final String licenseName;
+ final String issn;
Item({
required this.publisher,
@@ -90,6 +91,7 @@ class Item {
required this.primaryUrl,
required this.license,
required this.licenseName,
+ required this.issn,
});
factory Item.fromJson(Map json) {
@@ -102,16 +104,23 @@ class Item {
String licenseUrl = '';
String licenseName = '';
- if (json.containsKey('license')) {
- if (json['license'] != null &&
- json['license'] is List &&
- (json['license'] as List).isNotEmpty) {
- licenseUrl = json['license'][0]['URL'] ?? '';
+
+ // Check if 'license' is available in the JSON and is a non-empty list
+ if (json.containsKey('license') &&
+ json['license'] is List &&
+ (json['license'] as List).isNotEmpty) {
+ final licenseData = json['license'][0];
+ if (licenseData is Map &&
+ licenseData.containsKey('URL')) {
+ licenseUrl = licenseData['URL'];
licenseName = licenseNames[normalizeLicenseUrl(licenseUrl)] ?? '';
}
- } else {
- licenseUrl = '';
- licenseName = '';
+ }
+
+ String journalTitle = '';
+ if (json['container-title'] is List &&
+ (json['container-title'] as List).isNotEmpty) {
+ journalTitle = (json['container-title'] as List).first ?? '';
}
return Item(
@@ -119,15 +128,14 @@ class Item {
abstract: _cleanAbstract(json['abstract'] ?? ''),
title: _extractTitle(json['title']),
publishedDate: _parseDate(json['created']),
- journalTitle: (json['container-title'] as List).isNotEmpty
- ? (json['container-title'] as List).first ?? ''
- : '',
+ journalTitle: journalTitle,
doi: json['DOI'] ?? '',
authors: authors,
url: json['URL'] ?? '',
primaryUrl: json['resource']['primary']['URL'] ?? '',
license: licenseUrl,
licenseName: licenseName,
+ issn: json['issn'] ?? '',
);
}
static Map licenseNames = {
@@ -151,7 +159,17 @@ class Item {
'https://opensource.org/licenses/Apache-2.0': 'Apache License 2.0',
'https://www.elsevier.com/tdm/userlicense/1.0':
'Elsevier Text and Data Mining (TDM) License',
- 'https://www.springer.com/tdm': 'Springer Nature TDM policy'
+ 'https://www.springer.com/tdm': 'Springer Nature TDM policy',
+ 'https://www.springernature.com/gp/researchers/text-and-data-mining':
+ 'Springer Nature TDM policy',
+ 'https://onlinelibrary.wiley.com/termsAndConditions#vor':
+ 'Wiley Online Library Terms of Use',
+ 'https://doi.wiley.com/10.1002/tdm_license_1.1': 'Wiley TDM policy',
+ 'https://iopscience.iop.org/page/copyright': 'IOP copyright protection',
+ 'https://ieeexplore.ieee.org/Xplorehelp/downloads/license-information/IEEE.html':
+ 'IEE copyright policy',
+ 'https://creativecommons.org/licenses/by-nc-nd/4.0':
+ 'Creative Commons Attribution-NonCommercial-Nonderivatives 4.0 International'
};
static String _cleanAbstract(String rawAbstract) {
rawAbstract = rawAbstract
@@ -165,10 +183,8 @@ class Item {
static String _extractTitle(dynamic title) {
// Extract the title if it's not null and is a non-empty list
- return (title != null &&
- title is List &&
- (title as List).isNotEmpty)
- ? (title as List).first ?? ''
+ return title != null && title is List && (title.isNotEmpty)
+ ? title.first ?? ''
: '';
}
diff --git a/lib/models/journal_entity.dart b/lib/models/journal_entity.dart
index 94d492c6..24801167 100644
--- a/lib/models/journal_entity.dart
+++ b/lib/models/journal_entity.dart
@@ -3,7 +3,6 @@ class Journal {
final String issn;
final String title;
final String publisher;
- final String subjects;
final String? dateFollowed;
final String? lastUpdated;
@@ -12,7 +11,6 @@ class Journal {
required this.issn,
required this.title,
required this.publisher,
- required this.subjects,
this.dateFollowed,
this.lastUpdated,
});
@@ -22,7 +20,6 @@ class Journal {
'issn': issn,
'title': title,
'publisher': publisher,
- 'subjects': subjects,
'dateFollowed': DateTime.now().toIso8601String().substring(0, 10),
'lastUpdated': lastUpdated,
};
diff --git a/lib/screens/article_screen.dart b/lib/screens/article_screen.dart
index 52241509..afabcd88 100644
--- a/lib/screens/article_screen.dart
+++ b/lib/screens/article_screen.dart
@@ -120,8 +120,6 @@ class _ArticleScreenState extends State {
if (journalInfo != null) {
String journalPublisher =
journalInfo['publisher'];
- List journalSubjects =
- (journalInfo['subjects'] ?? '').split(',');
Navigator.push(
context,
@@ -130,7 +128,6 @@ class _ArticleScreenState extends State {
title: widget.journalTitle,
publisher: journalPublisher,
issn: widget.issn,
- subjects: journalSubjects,
),
),
);
@@ -295,7 +292,7 @@ class _ArticleScreenState extends State {
final db = await databaseHelper.database;
final List