├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .zenodo.json ├── LICENSE ├── PRIVACY.md ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── Gemfile ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── app │ │ │ │ └── wispar │ │ │ │ └── wispar │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ └── ic_bg_service_small.png │ │ │ ├── drawable-mdpi │ │ │ └── ic_bg_service_small.png │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable-xhdpi │ │ │ └── ic_bg_service_small.png │ │ │ ├── drawable-xxhdpi │ │ │ └── ic_bg_service_small.png │ │ │ ├── drawable-xxxhdpi │ │ │ └── ic_bg_service_small.png │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── fastlane │ ├── Appfile │ ├── Fastfile │ ├── README.md │ ├── metadata │ │ └── android │ │ │ ├── en-US │ │ │ ├── full_description.txt │ │ │ ├── images │ │ │ │ ├── featureGraphic.png │ │ │ │ ├── icon.png │ │ │ │ ├── phoneScreenshots │ │ │ │ │ ├── 1_en-US.png │ │ │ │ │ ├── 2_en-US.png │ │ │ │ │ ├── 3_en-US.png │ │ │ │ │ ├── 4_en-US.png │ │ │ │ │ ├── 5_en-US.png │ │ │ │ │ ├── 6_en-US.png │ │ │ │ │ └── 7_en-US.png │ │ │ │ └── tenInchScreenshots │ │ │ │ │ ├── 1_en-US.png │ │ │ │ │ ├── 2_en-US.png │ │ │ │ │ ├── 3_en-US.png │ │ │ │ │ ├── 4_en-US.png │ │ │ │ │ ├── 5_en-US.png │ │ │ │ │ ├── 6_en-US.png │ │ │ │ │ └── 7_en-US.png │ │ │ ├── short_description.txt │ │ │ ├── title.txt │ │ │ └── video.txt │ │ │ └── fr-CA │ │ │ ├── full_description.txt │ │ │ ├── short_description.txt │ │ │ ├── title.txt │ │ │ └── video.txt │ └── report.xml ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets └── icon │ └── icon.png ├── devtools_options.yaml ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── PrivacyInfo.xcprivacy ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings ├── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-50x50@1x.png │ │ │ ├── Icon-App-50x50@2x.png │ │ │ ├── Icon-App-57x57@1x.png │ │ │ ├── Icon-App-57x57@2x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-72x72@1x.png │ │ │ ├── Icon-App-72x72@2x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ └── Icon-App-83.5x83.5@2x.png │ │ └── LaunchImage.imageset │ │ │ ├── Contents.json │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ └── README.md │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ ├── Runner-Bridging-Header.h │ ├── Runner.entitlements │ └── Runner.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme └── RunnerTests │ └── RunnerTests.swift ├── l10n.yaml ├── lib ├── l10n │ ├── app_de.arb │ ├── app_en.arb │ ├── app_es.arb │ ├── app_fa.arb │ ├── app_fr.arb │ ├── app_id.arb │ ├── app_ja.arb │ ├── app_nb.arb │ ├── app_nl.arb │ ├── app_pt.arb │ ├── app_ru.arb │ ├── app_ta.arb │ ├── app_tr.arb │ ├── app_zh.arb │ └── app_zh_Hans.arb ├── locale_provider.dart ├── main.dart ├── models │ ├── crossref_journals_models.dart │ ├── crossref_journals_works_models.dart │ ├── journal_entity.dart │ ├── openAlex_works_models.dart │ └── unpaywall_models.dart ├── screens │ ├── article_screen.dart │ ├── article_search_results_screen.dart │ ├── article_website.dart │ ├── database_settings_screen.dart │ ├── display_settings_screen.dart │ ├── downloads_screen.dart │ ├── favorites_screen.dart │ ├── hidden_articles_screen.dart │ ├── home_screen.dart │ ├── institutions_screen.dart │ ├── introduction_screen.dart │ ├── journals_details_screen.dart │ ├── journals_search_results_screen.dart │ ├── library_screen.dart │ ├── logs_screen.dart │ ├── pdf_reader.dart │ ├── search_screen.dart │ ├── settings_screen.dart │ └── zotero_settings_screen.dart ├── services │ ├── abstract_helper.dart │ ├── abstract_scraper.dart │ ├── background_service.dart │ ├── crossref_api.dart │ ├── database_helper.dart │ ├── feed_api.dart │ ├── feed_service.dart │ ├── libproxydb_api.dart │ ├── logs_helper.dart │ ├── mathml_converter.dart │ ├── openAlex_api.dart │ ├── string_format_helper.dart │ ├── unpaywall_api.dart │ └── zotero_api.dart ├── theme_provider.dart └── widgets │ ├── article_crossref_search_form.dart │ ├── article_doi_search_form.dart │ ├── article_openAlex_search_form.dart │ ├── article_query_search_form.dart │ ├── article_search_form.dart │ ├── author_search_form.dart │ ├── downloaded_card.dart │ ├── journal_card.dart │ ├── journal_follow_button.dart │ ├── journal_header.dart │ ├── journal_search_form.dart │ ├── journal_search_results_card.dart │ ├── journals_tab_content.dart │ ├── latest_works_header.dart │ ├── publication_card.dart │ ├── queries_tab_content.dart │ ├── search_query_card.dart │ ├── sortbydialog.dart │ └── sortorderdialog.dart ├── macos └── Podfile ├── metadata └── en-US │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ ├── phoneScreenshots │ │ ├── 1_en-US.png │ │ ├── 2_en-US.png │ │ ├── 3_en-US.png │ │ ├── 4_en-US.png │ │ ├── 5_en-US.png │ │ ├── 6_en-US.png │ │ └── 7_en-US.png │ └── tenInchScreenshots │ │ ├── 1_en-US.png │ │ ├── 2_en-US.png │ │ ├── 3_en-US.png │ │ ├── 4_en-US.png │ │ ├── 5_en-US.png │ │ ├── 6_en-US.png │ │ └── 7_en-US.png │ └── short_description.txt ├── pubspec.lock ├── pubspec.yaml ├── renovate.json └── wispar.iml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: scriptbash # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Device (please complete the following information):** 27 | - Device: [e.g. iPhone6, Pixel 7] 28 | - OS: [e.g. iOS8.1, Android 14] 29 | - App version [e.g. 0.1.0] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build-android: 9 | name: Build for Android 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install Ninja 14 | run: sudo apt-get install -y ninja-build 15 | - uses: actions/setup-java@v4 16 | with: 17 | distribution: 'temurin' 18 | java-version: '21' 19 | - uses: subosito/flutter-action@v2 20 | with: 21 | channel: 'stable' 22 | - run: flutter pub get 23 | - name: Patch pdfrx 24 | run: | 25 | PDFRX_PATH=$(find $HOME/.pub-cache -type d -name "pdfrx*" | head -n 1) 26 | CMAKE_FILE="$PDFRX_PATH/android/CMakeLists.txt" 27 | if [ -f "$CMAKE_FILE" ]; then 28 | sed -i '2i add_link_options("LINKER:--build-id=none")' "$CMAKE_FILE" 29 | echo "Patched CMakeLists.txt in $CMAKE_FILE" 30 | else 31 | echo "CMakeLists.txt not found in expected location" 32 | exit 1 33 | fi 34 | - run: flutter build apk --debug 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: release-apk 38 | path: build/app/outputs/apk/debug/app-debug.apk 39 | build-ios: 40 | name: Build for iOS 41 | runs-on: macos-13 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: maxim-lobanov/setup-xcode@v1 45 | with: 46 | xcode-version: '15.1' 47 | - uses: subosito/flutter-action@v2 48 | with: 49 | channel: 'stable' 50 | architecture: x64 51 | - run: flutter pub get 52 | - run: flutter build ios --release --no-codesign 53 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Signed builds and upload to Play Store 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build-android: 8 | name: Build for Android 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install Ninja 13 | run: sudo apt-get install -y ninja-build 14 | - uses: actions/setup-java@v4 15 | with: 16 | distribution: 'temurin' 17 | java-version: '21' 18 | - uses: subosito/flutter-action@v2 19 | with: 20 | channel: 'stable' 21 | - run: flutter pub get 22 | - name: Patch pdfrx 23 | run: | 24 | PDFRX_PATH=$(find $HOME/.pub-cache -type d -name "pdfrx*" | head -n 1) 25 | CMAKE_FILE="$PDFRX_PATH/android/CMakeLists.txt" 26 | if [ -f "$CMAKE_FILE" ]; then 27 | sed -i '2i add_link_options("LINKER:--build-id=none")' "$CMAKE_FILE" 28 | echo "Patched CMakeLists.txt in $CMAKE_FILE" 29 | else 30 | echo "CMakeLists.txt not found in expected location" 31 | exit 1 32 | fi 33 | - name: Decode Keystore 34 | run: | 35 | echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks 36 | - name: Create key.properties 37 | run: | 38 | echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties 39 | echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties 40 | echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties 41 | echo "storeFile=keystore.jks" >> android/key.properties 42 | - name: Build APK 43 | run: flutter build apk --release 44 | - name: Build appBundle 45 | run: flutter build appbundle 46 | - name: Upload Artifacts 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: Releases 50 | path: | 51 | build/app/outputs/flutter-apk/app-release.apk 52 | build/app/outputs/bundle/release/app-release.aab 53 | - name: Extract version from pubspec.yaml 54 | id: extract_version 55 | run: | 56 | version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r') 57 | echo "VERSION=$version" >> $GITHUB_ENV 58 | - name: Create GitHub Release 59 | uses: ncipollo/release-action@v1 60 | with: 61 | artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/app/outputs/bundle/release/app-release.aab" 62 | tag: v${{ env.VERSION }} 63 | token: ${{ secrets.TOKEN }} 64 | draft: true 65 | generateReleaseNotes: true 66 | - name: Create google_service_account.json 67 | run: | 68 | echo "${{ secrets.GOOGLE_SERVICE_ACCOUNT }}" | base64 --decode > android/google_service_account.json 69 | - name: Setup ruby 70 | uses: ruby/setup-ruby@v1 71 | with: 72 | ruby-version: '3.3.0' 73 | bundler-cache: true 74 | working-directory: 'android' 75 | - name: Deploy to Play Store 76 | uses: maierj/fastlane-action@v3.1.0 77 | with: 78 | lane: deploy 79 | subdirectory: android 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Do not remove or rename entries in this file, only add new ones 2 | # See https://github.com/flutter/flutter/issues/128635 for more context. 3 | 4 | # Miscellaneous 5 | *.class 6 | *.lock 7 | *.log 8 | *.pyc 9 | *.swp 10 | .DS_Store 11 | .atom/ 12 | .buildlog/ 13 | .history 14 | .svn/ 15 | 16 | # IntelliJ related 17 | *.iml 18 | *.ipr 19 | *.iws 20 | .idea/ 21 | 22 | # Visual Studio Code related 23 | .classpath 24 | .project 25 | .settings/ 26 | .vscode/* 27 | 28 | # Flutter repo-specific 29 | /bin/cache/ 30 | /bin/internal/bootstrap.bat 31 | /bin/internal/bootstrap.sh 32 | /bin/mingit/ 33 | /dev/benchmarks/mega_gallery/ 34 | /dev/bots/.recipe_deps 35 | /dev/bots/android_tools/ 36 | /dev/devicelab/ABresults*.json 37 | /dev/docs/doc/ 38 | /dev/docs/api_docs.zip 39 | /dev/docs/flutter.docs.zip 40 | /dev/docs/lib/ 41 | /dev/docs/pubspec.yaml 42 | /dev/integration_tests/**/xcuserdata 43 | /dev/integration_tests/**/Pods 44 | /packages/flutter/coverage/ 45 | version 46 | analysis_benchmark.json 47 | 48 | # packages file containing multi-root paths 49 | .packages.generated 50 | /lib/generated_l10n/ 51 | 52 | # Flutter/Dart/Pub related 53 | **/doc/api/ 54 | .dart_tool/ 55 | .flutter-plugins 56 | .flutter-plugins-dependencies 57 | **/generated_plugin_registrant.dart 58 | .packages 59 | .pub-preload-cache/ 60 | .pub-cache/ 61 | .pub/ 62 | build/ 63 | flutter_*.png 64 | linked_*.ds 65 | unlinked.ds 66 | unlinked_spec.ds 67 | 68 | # Android related 69 | **/android/**/gradle-wrapper.jar 70 | .gradle/ 71 | **/android/captures/ 72 | **/android/gradlew 73 | **/android/gradlew.bat 74 | **/android/local.properties 75 | **/android/**/GeneratedPluginRegistrant.java 76 | **/android/key.properties 77 | *.jks 78 | 79 | # iOS/XCode related 80 | **/ios/**/*.mode1v3 81 | **/ios/**/*.mode2v3 82 | **/ios/**/*.moved-aside 83 | **/ios/**/*.pbxuser 84 | **/ios/**/*.perspectivev3 85 | **/ios/**/*sync/ 86 | **/ios/**/.sconsign.dblite 87 | **/ios/**/.tags* 88 | **/ios/**/.vagrant/ 89 | **/ios/**/DerivedData/ 90 | **/ios/**/Icon? 91 | **/ios/**/Pods/ 92 | **/ios/**/.symlinks/ 93 | **/ios/**/profile 94 | **/ios/**/xcuserdata 95 | **/ios/.generated/ 96 | **/ios/Flutter/.last_build_id 97 | **/ios/Flutter/App.framework 98 | **/ios/Flutter/Flutter.framework 99 | **/ios/Flutter/Flutter.podspec 100 | **/ios/Flutter/Generated.xcconfig 101 | **/ios/Flutter/ephemeral 102 | **/ios/Flutter/app.flx 103 | **/ios/Flutter/app.zip 104 | **/ios/Flutter/flutter_assets/ 105 | **/ios/Flutter/flutter_export_environment.sh 106 | **/ios/ServiceDefinitions.json 107 | **/ios/Runner/GeneratedPluginRegistrant.* 108 | 109 | # macOS 110 | **/Flutter/ephemeral/ 111 | **/Pods/ 112 | **/macos/Flutter/GeneratedPluginRegistrant.swift 113 | **/macos/Flutter/ephemeral 114 | **/xcuserdata/ 115 | 116 | # Windows 117 | **/windows/flutter/generated_plugin_registrant.cc 118 | **/windows/flutter/generated_plugin_registrant.h 119 | **/windows/flutter/generated_plugins.cmake 120 | 121 | # Linux 122 | **/linux/flutter/generated_plugin_registrant.cc 123 | **/linux/flutter/generated_plugin_registrant.h 124 | **/linux/flutter/generated_plugins.cmake 125 | 126 | # Coverage 127 | coverage/ 128 | 129 | # Symbols 130 | app.*.symbols 131 | 132 | # Exceptions to above rules. 133 | !**/ios/**/default.mode1v3 134 | !**/ios/**/default.mode2v3 135 | !**/ios/**/default.pbxuser 136 | !**/ios/**/default.perspectivev3 137 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 138 | !/dev/ci/**/Gemfile.lock 139 | !.vscode/settings.json 140 | !pubspec.lock 141 | 142 | -------------------------------------------------------------------------------- /.zenodo.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Wispar", 3 | "description": "Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly searches scientific journals and articles using the Crossref and OpenAlex APIs. 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.", 4 | "license": {"id": "GPL-3.0"}, 5 | "creators": [ 6 | { 7 | "affiliation": "Université de Sherbrooke", 8 | "name": "Lapointe, Francis", 9 | "orcid": "0000-0002-7638-4018" 10 | }, 11 | { 12 | "affiliation": "Université de Sherbrooke", 13 | "name": "Redondo, Sergio Andrés", 14 | "orcid": "0000-0002-2364-5667" 15 | }, 16 | { 17 | "affiliation": "Université de Sherbrooke", 18 | "name": "Amani, Alireza", 19 | "orcid": "0000-0002-0011-1945" 20 | }, 21 | { 22 | "name": "Nordhøy, Allan" 23 | }, 24 | { 25 | "name": "தமிழ் நேரம்" 26 | }, 27 | { 28 | "name": "Tachi44d" 29 | }, 30 | { 31 | "name": "MMignolet" 32 | }, 33 | { 34 | "name": "Ale" 35 | }, 36 | { 37 | "name": "Holi" 38 | }, 39 | { 40 | "name": "Xapitonov" 41 | }, 42 | { 43 | "name": "Arslanbakan, Arda" 44 | }, 45 | { 46 | "name": "bio278" 47 | }, 48 | { 49 | "name": "Wibowo, Fitri" 50 | } 51 | ], 52 | "access_right": "open", 53 | "upload_type": "software", 54 | "keywords": ["Android", "iOS", "Crossref", "OpenAlex", "Unpaywall", "EZproxy", "Zotero", "science", "journals", "research"] 55 | } 56 | 57 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | Wispar is an open-source mobile app that prioritizes user privacy. Our app does not collect any data or access personal information. 2 | 3 | However, Wispar integrates with external services to enhance functionality. Users should be aware that these services could be collecting information such as IP addresses and device-related data. We encourage users to review the privacy policies of each service for a comprehensive understanding of their data collection practices. 4 | 5 | Third-party services used by Wispar: 6 | 7 | - Crossref: https://www.crossref.org/operations-and-sustainability/privacy 8 | - Unpaywall: https://unpaywall.org/legal/privacy 9 | - Zotero: https://www.zotero.org/support/privacy 10 | - OpenAlex: https://openalex.org/OpenAlex_privacy_policy.pdf 11 | 12 | Please review the privacy policies of these services to understand how they handle data. Wispar does not have control over the data collection practices of these external services. 13 | 14 | Inquiries can be submitted to wispar-app@protonmail.com -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Wispar 3 |

4 |

Stay up-to-date with academic journals and the latest research articles!

5 |

6 | 7 | GitHub Workflow Status 8 | 9 | 10 | Translation status 11 | 12 | DOI 13 |
14 | 15 | Get it on Google Play 16 | 17 | 18 | Download on the App Store 19 | 20 |
21 | 22 | Get it on F-Droid 25 | 26 |

27 | Buy Me a Coffee at ko-fi.com 28 |

29 | 30 | --- 31 | 32 | ## Description 33 |

34 | Wispar is a user-friendly and privacy-friendly Android/iOS app that seamlessly searches scientific journals and articles using the Crossref and OpenAlex APIs. 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. 35 |

36 | 37 | ## Features overview 38 | 50 | * The download feature is currently limited to some publishers. Flutter tools and publishers like Elsevier and Wiley make it hard to get the PDF file. 51 | 52 | ### Planned features 53 | 57 | 58 | 59 | ## Translations 60 | 61 |

62 | Wispar uses Weblate to manage translations. You can find the hosted instance at https://hosted.weblate.org/engage/wispar/ 63 | 64 | A huge thank you to Weblate for hosting the translations for free :heart:. 65 | 66 | Translation status: 67 |

68 | 69 | Translation status 70 | 71 | 72 | ## Contribute 73 |

74 |

79 | If you contribute to the project, feel free to add yourself to the .zenodo.json file to be credited! 80 |

81 | 82 | 83 | ## Help 84 |

85 | If you run into any issue while using Wispar, have a question or want to share your feedback, please open an issue here : https://github.com/Scriptbash/Wispar/issues 86 |

87 | 88 | ## Credits 89 | 96 | 97 | ## Screenshots 98 | 99 | 100 | 101 | | ![Feed](android/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png) | ![Abstract](android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png) | ![Search](android/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png) | 102 | |---|---|---| 103 | | ![Journal latest publications](android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png) | ![JournalDetails](android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png) | ![Settings](android/fastlane/metadata/android/en-US/images/phoneScreenshots/7_en-US.png) | 104 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | id "dev.flutter.flutter-gradle-plugin" 5 | } 6 | 7 | def localProperties = new Properties() 8 | def localPropertiesFile = rootProject.file('local.properties') 9 | if (localPropertiesFile.exists()) { 10 | localPropertiesFile.withReader('UTF-8') { reader -> 11 | localProperties.load(reader) 12 | } 13 | } 14 | 15 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 16 | if (flutterVersionCode == null) { 17 | flutterVersionCode = '1' 18 | } 19 | 20 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 21 | if (flutterVersionName == null) { 22 | flutterVersionName = '1.0' 23 | } 24 | 25 | def keystoreProperties = new Properties() 26 | def keystorePropertiesFile = rootProject.file('key.properties') 27 | if (keystorePropertiesFile.exists()) { 28 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 29 | } 30 | 31 | android { 32 | namespace "app.wispar.wispar" 33 | compileSdkVersion 35 34 | ndkVersion "27.0.12077973" 35 | 36 | compileOptions { 37 | coreLibraryDesugaringEnabled true 38 | sourceCompatibility JavaVersion.VERSION_1_8 39 | targetCompatibility JavaVersion.VERSION_1_8 40 | } 41 | 42 | kotlinOptions { 43 | jvmTarget = '1.8' 44 | } 45 | 46 | sourceSets { 47 | main.java.srcDirs += 'src/main/kotlin' 48 | } 49 | 50 | defaultConfig { 51 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 52 | applicationId "app.wispar.wispar" 53 | // You can update the following values to match your application needs. 54 | // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. 55 | minSdkVersion flutter.minSdkVersion 56 | targetSdkVersion flutter.targetSdkVersion 57 | versionCode flutterVersionCode.toInteger() 58 | versionName flutterVersionName 59 | } 60 | 61 | signingConfigs { 62 | release { 63 | keyAlias = keystoreProperties['keyAlias'] 64 | keyPassword = keystoreProperties['keyPassword'] 65 | storeFile = keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 66 | storePassword = keystoreProperties['storePassword'] 67 | } 68 | } 69 | 70 | buildTypes { 71 | release { 72 | signingConfig = signingConfigs.release 73 | } 74 | } 75 | 76 | dependenciesInfo { 77 | // Disables dependency metadata when building APKs. 78 | includeInApk = false 79 | // Disables dependency metadata when building Android App Bundles. 80 | includeInBundle = false 81 | } 82 | } 83 | 84 | flutter { 85 | source '../..' 86 | } 87 | 88 | allprojects { 89 | repositories { 90 | google() 91 | mavenCentral() 92 | // [required] background_fetch 93 | maven { url "${project(':background_fetch').projectDir}/libs" } 94 | } 95 | } 96 | 97 | dependencies {coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'} 98 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 20 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/app/wispar/wispar/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package app.wispar.wispar 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.os.Build 6 | import android.os.Bundle 7 | import io.flutter.embedding.android.FlutterActivity 8 | 9 | class MainActivity: FlutterActivity() { 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | 13 | // Create notification channel for foreground service on Android 8.0 and above 14 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 15 | val channel = NotificationChannel( 16 | "wispar_channel", 17 | "Wispar Updates", 18 | NotificationManager.IMPORTANCE_HIGH 19 | ).apply { 20 | description = "Notification when new articles from followed journals and saved queries are available" 21 | } 22 | 23 | val manager = getSystemService(NotificationManager::class.java) 24 | manager.createNotificationChannel(channel) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_bg_service_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/drawable-hdpi/ic_bg_service_small.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_bg_service_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/drawable-mdpi/ic_bg_service_small.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_bg_service_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/drawable-xhdpi/ic_bg_service_small.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_bg_service_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/drawable-xxhdpi/ic_bg_service_small.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_bg_service_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/drawable-xxxhdpi/ic_bg_service_small.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-hdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-mdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '2.1.21' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 10 | } 11 | } 12 | 13 | allprojects { 14 | repositories { 15 | google() 16 | mavenCentral() 17 | } 18 | } 19 | 20 | rootProject.buildDir = '../build' 21 | subprojects { 22 | project.buildDir = "${rootProject.buildDir}/${project.name}" 23 | } 24 | subprojects { 25 | project.evaluationDependsOn(':app') 26 | } 27 | 28 | tasks.register("clean", Delete) { 29 | delete rootProject.buildDir 30 | } 31 | -------------------------------------------------------------------------------- /android/fastlane/Appfile: -------------------------------------------------------------------------------- 1 | package_name("app.wispar.wispar") # e.g. com.krausefx.app 2 | -------------------------------------------------------------------------------- /android/fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:android) 17 | 18 | platform :android do 19 | desc "Deploy a new version to Google Play" 20 | lane :deploy do 21 | #version = flutter_version() 22 | upload_to_play_store( 23 | track: 'production', # Can be 'internal', 'alpha', 'beta', 'production' 24 | json_key: 'google_service_account.json', 25 | skip_upload_apk: true, 26 | validate_only: false, 27 | skip_upload_metadata: true, # Skip uploading metadata 28 | skip_upload_images: true, # Skip uploading screenshots 29 | skip_upload_screenshots: true, # Skip uploading screenshots 30 | release_status: "draft", # Can be 'draft', 'completed', 'halted' 31 | aab: '../build/app/outputs/bundle/release/app-release.aab', # Path to your AAB file 32 | #version_code: version["version_code"], # From pubspec.yaml 33 | #version_name: version["version_name"] + version["version_code"], 34 | ) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /android/fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## Android 17 | 18 | ### android deploy 19 | 20 | ```sh 21 | [bundle exec] fastlane android deploy 22 | ``` 23 | 24 | Deploy a new version to Google Play 25 | 26 | ---- 27 | 28 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 29 | 30 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 31 | 32 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 33 | -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Wispar is your personal research companion — a privacy-friendly app for exploring academic journals and scientific articles. 2 | 3 | Using the Crossref and OpenAlex APIs, Wispar helps you stay updated on your favourite journals. Follow journals to receive the latest research abstracts in your main feed — no account required. 4 | 5 | - Search scholarly articles and journals 6 | - Follow journals to stay updated 7 | - Add custom keywords and queries to your feed 8 | - Access open-access articles via Unpaywall 9 | - Use EZproxy to unlock paywalled research 10 | - Send articles to your Zotero library 11 | - Built for researchers, students, and the academically curious 12 | 13 | Wispar is user-friendly, open-source, and designed to respect your privacy. 14 | 15 | As a community-built project, contributions are always welcome! If you'd like to contribute, please visit our GitHub repository: https://github.com/Scriptbash/Wispar 16 | 17 | If the app isn't available in your language, please consider helping with translations by visiting our hosted Weblate instance: https://hosted.weblate.org/engage/wispar -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/5_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/6_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/6_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/phoneScreenshots/7_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/phoneScreenshots/7_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/1_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/2_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/2_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/3_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/3_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/4_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/4_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/5_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/5_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/6_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/6_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/images/tenInchScreenshots/7_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/images/tenInchScreenshots/7_en-US.png -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Stay up-to-date with academic journals and the latest research articles! -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Wispar: Research Butler -------------------------------------------------------------------------------- /android/fastlane/metadata/android/en-US/video.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/en-US/video.txt -------------------------------------------------------------------------------- /android/fastlane/metadata/android/fr-CA/full_description.txt: -------------------------------------------------------------------------------- 1 | Wispar is a user-friendly and privacy-friendly 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. 2 | 3 | This is an open-source project, and contributions are welcome! If you'd like to contribute, please visit our GitHub repository: https://github.com/Scriptbash/Wispar 4 | 5 | If the app isn't available in your language, please consider helping with translations by visiting our hosted Weblate instance: https://hosted.weblate.org/engage/wispar -------------------------------------------------------------------------------- /android/fastlane/metadata/android/fr-CA/short_description.txt: -------------------------------------------------------------------------------- 1 | Stay up-to-date with articles in your field of study! -------------------------------------------------------------------------------- /android/fastlane/metadata/android/fr-CA/title.txt: -------------------------------------------------------------------------------- 1 | Wispar -------------------------------------------------------------------------------- /android/fastlane/metadata/android/fr-CA/video.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/android/fastlane/metadata/android/fr-CA/video.txt -------------------------------------------------------------------------------- /android/fastlane/report.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | } 9 | settings.ext.flutterSdkPath = flutterSdkPath() 10 | 11 | includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | gradlePluginPortal() 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false 21 | } 22 | } 23 | 24 | plugins { 25 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 26 | id "com.android.application" version "8.10.1" apply false 27 | } 28 | 29 | include ":app" 30 | -------------------------------------------------------------------------------- /assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/assets/icon/icon.png -------------------------------------------------------------------------------- /devtools_options.yaml: -------------------------------------------------------------------------------- 1 | extensions: 2 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 16.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '12.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | target 'RunnerTests' do 36 | inherit! :search_paths 37 | end 38 | end 39 | 40 | post_install do |installer| 41 | installer.pods_project.targets.each do |target| 42 | flutter_additional_ios_build_settings(target) 43 | target.build_configurations.each do |config| 44 | # You can remove unused permissions here 45 | # for more information: https://github.com/Baseflow/flutter-permission-handler/blob/main/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h 46 | # e.g. when you don't need camera permission, just add 'PERMISSION_CAMERA=0' 47 | config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ 48 | '$(inherited)', 49 | 50 | ## dart: PermissionGroup.notification 51 | 'PERMISSION_NOTIFICATIONS=1', 52 | ] 53 | 54 | end 55 | 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /ios/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | NSPrivacyAccessedAPITypes 13 | 14 | 15 | 16 | NSPrivacyAccessedAPIType 17 | NSPrivacyAccessedAPICategoryUserDefaults 18 | 19 | NSPrivacyAccessedAPITypeReasons 20 | 21 | CA92.1 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 44 | 50 | 51 | 52 | 53 | 54 | 66 | 68 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | if #available(iOS 10.0, *) { 11 | UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate 12 | } 13 | GeneratedPluginRegistrant.register(with: self) 14 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleDisplayName 10 | Wispar 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | wispar 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(FLUTTER_BUILD_NAME) 23 | CFBundleSignature 24 | ???? 25 | CFBundleVersion 26 | $(FLUTTER_BUILD_NUMBER) 27 | LSRequiresIPhoneOS 28 | 29 | NSPhotoLibraryUsageDescription 30 | A dependency of the app requires access to the photo library. The app uses this permission to allow users to backup and restore their database. 31 | UIApplicationSupportsIndirectInputEvents 32 | 33 | UIBackgroundModes 34 | 35 | fetch 36 | 37 | BGTaskSchedulerPermittedIdentifiers 38 | 39 | com.transistorsoft.fetch 40 | 41 | UILaunchStoryboardName 42 | LaunchScreen 43 | UIMainStoryboardFile 44 | Main 45 | UISupportedInterfaceOrientations 46 | 47 | UIInterfaceOrientationPortrait 48 | UIInterfaceOrientationLandscapeLeft 49 | UIInterfaceOrientationLandscapeRight 50 | 51 | UISupportedInterfaceOrientations~ipad 52 | 53 | UIInterfaceOrientationPortrait 54 | UIInterfaceOrientationPortraitUpsideDown 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ios/Runner/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /ios/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/l10n 2 | template-arb-file: app_en.arb 3 | output-localization-file: app_localizations.dart 4 | output-dir: lib/generated_l10n 5 | synthetic-package: false -------------------------------------------------------------------------------- /lib/l10n/app_de.arb: -------------------------------------------------------------------------------- 1 | { 2 | "welcomeWispar": "Willkommen bei Wispar!", 3 | "@welcomeWispar": {} 4 | } 5 | -------------------------------------------------------------------------------- /lib/l10n/app_fa.arb: -------------------------------------------------------------------------------- 1 | { 2 | "welcomeWispar": "به ویسپار خوش آمدید!", 3 | "@welcomeWispar": {}, 4 | "appDescription": "با این برنامهٔ متن‌باز، از آخرین مقالات علمی باخبر باشید. سریع راه‌اندازیش می‌کنیم!", 5 | "@appDescription": {}, 6 | "setupInstitutionalAccess": "راه اندازی دسترسی از طریق مؤسسه‌ یا دانشگاه شما (اختیاری)", 7 | "@setupInstitutionalAccess": {} 8 | } 9 | -------------------------------------------------------------------------------- /lib/l10n/app_ja.arb: -------------------------------------------------------------------------------- 1 | { 2 | "save": "保存", 3 | "@save": {}, 4 | "home": "ホーム", 5 | "@home": { 6 | "description": "The home menu button and the app bar title when in the home screen." 7 | }, 8 | "getStarted": "始める", 9 | "@getStarted": {}, 10 | "light": "明るい", 11 | "@light": {}, 12 | "setupOtherSettings": "その他の設定", 13 | "@setupOtherSettings": {}, 14 | "setupLinkZotero": "Zoteroをリンクする", 15 | "@setupLinkZotero": {}, 16 | "favorites": "お気に入り", 17 | "@favorites": { 18 | "description": "The favorites menu button and the app bar title when in the favorites screen." 19 | }, 20 | "downloads": "ダウンロード", 21 | "@downloads": { 22 | "description": "The downloads menu button and the app bar title when in the downloads screen." 23 | }, 24 | "downloadSuccessful": "論文がダウンロードされました!", 25 | "@downloadSuccessful": {}, 26 | "library": "ライブラリ", 27 | "@library": { 28 | "description": "The library menu button and the app bar title when in the library screen." 29 | }, 30 | "settings": "設定", 31 | "@settings": { 32 | "description": "The settings option menu button and the app bar title when in the settings screen." 33 | }, 34 | "noDownloads": "ダウンロードがありません。", 35 | "@noDownloads": {}, 36 | "skip": "スキップ", 37 | "@skip": {}, 38 | "delete": "削除", 39 | "@delete": {}, 40 | "everything": "すべて", 41 | "@everything": {}, 42 | "viewarticle": "論文を読む", 43 | "@viewarticle": {}, 44 | "dark": "暗い", 45 | "@dark": {}, 46 | "zoteroSettings": "Zoteroの設定", 47 | "@zoteroSettings": {}, 48 | "savedOn": "{date}に保存済み", 49 | "@savedOn": { 50 | "placeholders": { 51 | "date": { 52 | "type": "DateTime", 53 | "format": "yMMMMd" 54 | } 55 | } 56 | }, 57 | "hours": "時間", 58 | "@hours": {}, 59 | "saveSettings": "設定を保存", 60 | "@saveSettings": {}, 61 | "settingsSaved": "設定を保存しました!", 62 | "@settingsSaved": {}, 63 | "download": "ダウンロードする", 64 | "@download": { 65 | "description": "The verb to download, without the 'to'." 66 | }, 67 | "welcomeWispar": "Wisparにようこそ!", 68 | "@welcomeWispar": {}, 69 | "articles": "論文", 70 | "@articles": { 71 | "description": "As in scientific articles." 72 | }, 73 | "sendToZotero": "Zoteroに送る", 74 | "@sendToZotero": {}, 75 | "setupLinkMyZotero": "私のZoteroアカウントをリンクする", 76 | "@setupLinkMyZotero": {}, 77 | "abstract": "抄録", 78 | "@abstract": {}, 79 | "language": "言語", 80 | "@language": {}, 81 | "search": "検索", 82 | "@search": { 83 | "description": "Text shown inside the search screen app bar and for the seach button." 84 | }, 85 | "searchPlaceholder": "検索…", 86 | "@searchPlaceholder": { 87 | "description": "Place holder text in the search input widget." 88 | }, 89 | "theme": "テーマ", 90 | "@theme": {}, 91 | "systemtheme": "システムのテーマ", 92 | "@systemtheme": {}, 93 | "database": "データベース", 94 | "@database": {}, 95 | "sourceCode": "ソースコード", 96 | "@sourceCode": {}, 97 | "databaseSettings": "データベースの設定", 98 | "@databaseSettings": {}, 99 | "system": "システム", 100 | "@system": {}, 101 | "queries": "クエリ", 102 | "@queries": { 103 | "description": "Title of the saved search queries tab." 104 | }, 105 | "saveQuery": "クエリを保存", 106 | "@saveQuery": {}, 107 | "queryName": "クエリの名前", 108 | "@queryName": {}, 109 | "noSavedQueries": "保存されたクエリがありません。", 110 | "@noSavedQueries": {}, 111 | "zoteroValidKey": "APIキーが保存された!", 112 | "@zoteroValidKey": { 113 | "description": "Snackbar shown when a valid Zotero API key has been saved." 114 | }, 115 | "journals": "学術誌", 116 | "@journals": { 117 | "description": "The journals menu button and the app bar title when in the journals screen." 118 | }, 119 | "follow": "フォロー", 120 | "@follow": { 121 | "description": "The button text shown on journal cards when it is not followed." 122 | }, 123 | "unfollow": "フォロー解除", 124 | "@unfollow": { 125 | "description": "The button text shown on journal cards when it is followed." 126 | }, 127 | "titleAndAbstract": "論文名と抄録", 128 | "@titleAndAbstract": {}, 129 | "title": "論文名", 130 | "@title": {}, 131 | "journaltitle": "学術誌名", 132 | "@journaltitle": {}, 133 | "articletitle": "論文名", 134 | "@articletitle": {}, 135 | "notificationContent": "新しい論文があります!", 136 | "@notificationContent": {} 137 | } 138 | -------------------------------------------------------------------------------- /lib/l10n/app_zh.arb: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /lib/locale_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | 4 | class LocaleProvider extends ChangeNotifier { 5 | Locale? _locale; 6 | 7 | Locale? get locale => _locale; 8 | 9 | LocaleProvider() { 10 | _loadLocale(); 11 | } 12 | 13 | Future _loadLocale() async { 14 | final prefs = await SharedPreferences.getInstance(); 15 | final code = prefs.getString('locale'); 16 | if (code != null) { 17 | _locale = Locale(code); 18 | notifyListeners(); 19 | } 20 | } 21 | 22 | Future setLocale(String code) async { 23 | _locale = Locale(code); 24 | final prefs = await SharedPreferences.getInstance(); 25 | await prefs.setString('locale', code); 26 | notifyListeners(); 27 | } 28 | 29 | Future clearLocale() async { 30 | _locale = null; 31 | final prefs = await SharedPreferences.getInstance(); 32 | await prefs.remove('locale'); 33 | notifyListeners(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/models/journal_entity.dart: -------------------------------------------------------------------------------- 1 | class Journal { 2 | final int? id; 3 | final List issn; 4 | final String title; 5 | final String publisher; 6 | final String? dateFollowed; 7 | final String? lastUpdated; 8 | 9 | Journal({ 10 | this.id, 11 | required this.issn, 12 | required this.title, 13 | required this.publisher, 14 | this.dateFollowed, 15 | this.lastUpdated, 16 | }); 17 | 18 | Map toMap() { 19 | return { 20 | 'title': title, 21 | 'publisher': publisher, 22 | 'dateFollowed': DateTime.now().toIso8601String().substring(0, 10), 23 | 'lastUpdated': lastUpdated, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/models/openAlex_works_models.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import '../services/string_format_helper.dart'; 3 | 4 | class OpenAlexWorks { 5 | final String title; 6 | final String? doi; 7 | final String? url; 8 | final List authors; 9 | final String? abstract; 10 | final String? journalTitle; 11 | final String? publishedDate; 12 | final String? landingPageUrl; 13 | final String? displayName; 14 | final List? issn; 15 | final String? publisher; 16 | final String? license; 17 | 18 | OpenAlexWorks({ 19 | required this.title, 20 | this.doi, 21 | this.url, 22 | required this.authors, 23 | this.abstract, 24 | this.journalTitle, 25 | this.publishedDate, 26 | this.landingPageUrl, 27 | this.displayName, 28 | this.issn, 29 | this.publisher, 30 | this.license, 31 | }); 32 | 33 | factory OpenAlexWorks.fromJson(Map json) { 34 | final primaryLocation = json['primary_location']; 35 | 36 | String? extractedDoi; 37 | if (json['doi'] != null && json['doi'].startsWith('https://doi.org/')) { 38 | extractedDoi = json['doi'].replaceFirst('https://doi.org/', ''); 39 | } 40 | 41 | String? license = primaryLocation?["license"]; 42 | if (license == null) { 43 | license = "All rights reserved"; 44 | } else if (license.toLowerCase().contains("cc-by")) { 45 | license = "Creative-Commons"; 46 | } 47 | 48 | return OpenAlexWorks( 49 | title: cleanTitle(json['title']), 50 | doi: extractedDoi ?? json['doi'], 51 | url: primaryLocation?['landing_page_url'], 52 | authors: (json['authorships'] as List?) 53 | ?.map((a) => a['author']?['display_name'] as String?) 54 | .whereType() 55 | .toList() ?? 56 | [], 57 | abstract: reconstructAbstract(json['abstract_inverted_index']), 58 | journalTitle: cleanText( 59 | primaryLocation?['source']?['display_name'], 60 | ), 61 | publishedDate: json['publication_date'], 62 | issn: 63 | (primaryLocation?['source']?['issn'] as List?)?.cast() ?? [], 64 | publisher: primaryLocation?['source']?['host_organization_name'], 65 | license: license, 66 | ); 67 | } 68 | } 69 | 70 | String? reconstructAbstract(Map? invertedIndex) { 71 | if (invertedIndex == null) return null; 72 | 73 | int maxIndex = invertedIndex.values 74 | .expand((positions) => positions) 75 | .reduce((a, b) => a > b ? a : b); 76 | 77 | List words = List.filled(maxIndex + 1, '', growable: false); 78 | 79 | invertedIndex.forEach((word, positions) { 80 | for (int pos in positions) { 81 | words[pos] = word; 82 | } 83 | }); 84 | 85 | String rawAbstract = words.join(' '); 86 | return cleanAbstract(rawAbstract); 87 | } 88 | 89 | String cleanText(String? text) { 90 | if (text == null) return ''; 91 | try { 92 | String decoded = utf8.decode(text.codeUnits, allowMalformed: true); 93 | return decoded.replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Remove non-ASCII 94 | } catch (e) { 95 | return text; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lib/screens/article_search_results_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../widgets/publication_card.dart'; 4 | import '../models/crossref_journals_works_models.dart' as journalsWorks; 5 | import '../services/crossref_api.dart'; 6 | import '../services/openAlex_api.dart'; 7 | import '../services/logs_helper.dart'; 8 | 9 | class ArticleSearchResultsScreen extends StatefulWidget { 10 | final List initialSearchResults; 11 | final bool initialHasMore; 12 | final Map queryParams; 13 | final String source; 14 | 15 | const ArticleSearchResultsScreen({ 16 | Key? key, 17 | required this.initialSearchResults, 18 | required this.initialHasMore, 19 | required this.queryParams, 20 | required this.source, 21 | }) : super(key: key); 22 | 23 | @override 24 | _ArticleSearchResultsScreenState createState() => 25 | _ArticleSearchResultsScreenState(); 26 | } 27 | 28 | class _ArticleSearchResultsScreenState 29 | extends State { 30 | final logger = LogsService().logger; 31 | late List _searchResults; 32 | final ScrollController _scrollController = ScrollController(); 33 | bool _isLoadingMore = false; 34 | bool _hasMoreResults = true; 35 | int _currentOpenAlexPage = 1; 36 | 37 | @override 38 | void initState() { 39 | super.initState(); 40 | _searchResults = widget.initialSearchResults; 41 | _hasMoreResults = widget.initialHasMore; 42 | 43 | _scrollController.addListener(() { 44 | if (_scrollController.position.pixels >= 45 | _scrollController.position.maxScrollExtent - 70 && 46 | !_isLoadingMore && 47 | _hasMoreResults) { 48 | _loadMoreResults(); 49 | } 50 | }); 51 | } 52 | 53 | Future _loadMoreResults() async { 54 | if (_isLoadingMore || !_hasMoreResults) return; 55 | 56 | setState(() { 57 | _isLoadingMore = true; 58 | }); 59 | 60 | try { 61 | List newResults; 62 | bool hasMore = false; 63 | 64 | if (widget.source == 'Crossref') { 65 | final ListAndMore response = 66 | await CrossRefApi.getWorksByQuery(widget.queryParams); 67 | newResults = response.list; 68 | hasMore = 69 | response.hasMore && _searchResults.length < response.totalResults; 70 | } else { 71 | newResults = await OpenAlexApi.getOpenAlexWorksByQuery( 72 | widget.queryParams['query'] ?? '', 73 | widget.queryParams['scope'] ?? 1, 74 | widget.queryParams['sortField'], 75 | widget.queryParams['sortOrder'], 76 | page: _currentOpenAlexPage, 77 | ); 78 | 79 | if (newResults.isNotEmpty) { 80 | _currentOpenAlexPage++; 81 | } else { 82 | hasMore = false; 83 | _isLoadingMore = false; 84 | } 85 | } 86 | 87 | setState(() { 88 | _searchResults.addAll(newResults); 89 | _hasMoreResults = hasMore; 90 | }); 91 | } catch (e, stackTrace) { 92 | ScaffoldMessenger.of(context).showSnackBar( 93 | SnackBar( 94 | content: Text(AppLocalizations.of(context)!.failedLoadMoreResults)), 95 | ); 96 | logger.severe( 97 | 'Failed to load more article search results.', e, stackTrace); 98 | } finally { 99 | setState(() { 100 | _isLoadingMore = false; 101 | }); 102 | } 103 | } 104 | 105 | @override 106 | void dispose() { 107 | _scrollController.dispose(); 108 | super.dispose(); 109 | } 110 | 111 | @override 112 | Widget build(BuildContext context) { 113 | return Scaffold( 114 | appBar: AppBar( 115 | title: Text(AppLocalizations.of(context)!.searchresults), 116 | ), 117 | body: _searchResults.isNotEmpty 118 | ? ListView.builder( 119 | controller: _scrollController, 120 | itemCount: _searchResults.length + (_hasMoreResults ? 1 : 0), 121 | cacheExtent: 1000.0, 122 | itemBuilder: (context, index) { 123 | if (index == _searchResults.length) { 124 | return Center( 125 | child: Padding( 126 | padding: const EdgeInsets.all(8.0), 127 | child: CircularProgressIndicator(), 128 | ), 129 | ); 130 | } 131 | 132 | final item = _searchResults[index]; 133 | return PublicationCard( 134 | title: item.title, 135 | abstract: item.abstract, 136 | journalTitle: item.journalTitle, 137 | issn: item.issn, 138 | publishedDate: item.publishedDate, 139 | doi: item.doi, 140 | authors: item.authors, 141 | url: item.url, 142 | license: item.license, 143 | licenseName: item.licenseName, 144 | publisher: item.publisher, 145 | ); 146 | }, 147 | ) 148 | : Center( 149 | child: Text(AppLocalizations.of(context)!.noresultsfound), 150 | ), 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /lib/screens/hidden_articles_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../services/database_helper.dart'; 3 | import '../widgets/publication_card.dart'; 4 | import '../generated_l10n/app_localizations.dart'; 5 | 6 | class HiddenArticlesScreen extends StatefulWidget { 7 | const HiddenArticlesScreen({super.key}); 8 | 9 | @override 10 | _HiddenArticlesScreenState createState() => _HiddenArticlesScreenState(); 11 | } 12 | 13 | class _HiddenArticlesScreenState extends State { 14 | final DatabaseHelper dbHelper = DatabaseHelper(); 15 | List _hiddenPublications = []; 16 | 17 | @override 18 | void initState() { 19 | super.initState(); 20 | _loadHiddenPublications(); 21 | } 22 | 23 | Future _loadHiddenPublications() async { 24 | final hidden = await dbHelper.getHiddenPublications(); 25 | 26 | setState(() { 27 | _hiddenPublications = hidden.map((card) { 28 | return PublicationCard( 29 | doi: card.doi, 30 | title: card.title, 31 | issn: card.issn, 32 | abstract: card.abstract, 33 | journalTitle: card.journalTitle, 34 | publishedDate: card.publishedDate, 35 | authors: card.authors, 36 | url: card.url, 37 | license: card.license, 38 | licenseName: card.licenseName, 39 | showHideBtn: true, 40 | isHidden: true, 41 | onHide: () async { 42 | await dbHelper.unhideArticle(card.doi); 43 | _loadHiddenPublications(); 44 | }, 45 | ); 46 | }).toList(); 47 | }); 48 | } 49 | 50 | @override 51 | Widget build(BuildContext context) { 52 | return Scaffold( 53 | appBar: AppBar(title: Text(AppLocalizations.of(context)!.hiddenArticles)), 54 | body: _hiddenPublications.isEmpty 55 | ? Center(child: Text(AppLocalizations.of(context)!.noHiddenArticles)) 56 | : ListView.builder( 57 | itemCount: _hiddenPublications.length, 58 | itemBuilder: (context, index) { 59 | return _hiddenPublications[index]; 60 | }, 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/screens/institutions_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../services/libproxydb_api.dart'; 4 | import '../services/logs_helper.dart'; 5 | 6 | class InstitutionScreen extends StatefulWidget { 7 | @override 8 | _InstitutionScreenState createState() => _InstitutionScreenState(); 9 | } 10 | 11 | class _InstitutionScreenState extends State { 12 | final logger = LogsService().logger; 13 | List allProxies = []; 14 | List filteredProxies = []; 15 | TextEditingController searchController = TextEditingController(); 16 | 17 | @override 18 | void initState() { 19 | super.initState(); 20 | fetchProxies(); 21 | } 22 | 23 | void fetchProxies() async { 24 | try { 25 | List proxies = await ProxyService.fetchProxies(); 26 | setState(() { 27 | // Appends "No institution" at the top of the proxy list 28 | proxies = [ 29 | ProxyData(name: AppLocalizations.of(context)!.noinstitution, url: ''), 30 | ...proxies, 31 | ]; 32 | allProxies = proxies; 33 | filteredProxies = proxies; 34 | }); 35 | } catch (e, stackTrace) { 36 | logger.severe('Unable to load the proxy list', e, stackTrace); 37 | } 38 | } 39 | 40 | void filterProxies(String query) { 41 | List filteredList = allProxies 42 | .where((proxy) => 43 | proxy.name.toLowerCase().contains(query.toLowerCase()) || 44 | proxy.url.toLowerCase().contains(query.toLowerCase())) 45 | .toList(); 46 | setState(() { 47 | filteredProxies = filteredList; 48 | }); 49 | } 50 | 51 | void onInstitutionSelected(String name, String url) { 52 | Navigator.pop(context, {'name': name, 'url': url}); 53 | } 54 | 55 | @override 56 | Widget build(BuildContext context) { 57 | return Scaffold( 58 | appBar: AppBar( 59 | title: Text(AppLocalizations.of(context)!.selectinstitution), 60 | ), 61 | body: Column( 62 | children: [ 63 | Padding( 64 | padding: const EdgeInsets.all(8.0), 65 | child: TextField( 66 | controller: searchController, 67 | onChanged: (query) { 68 | filterProxies(query); 69 | }, 70 | decoration: InputDecoration( 71 | hintText: AppLocalizations.of(context)!.searchPlaceholder, 72 | prefixIcon: Icon(Icons.search), 73 | border: OutlineInputBorder( 74 | borderRadius: BorderRadius.circular(30.0), 75 | ), 76 | ), 77 | ), 78 | ), 79 | Expanded( 80 | child: ListView.builder( 81 | itemCount: filteredProxies.length, 82 | itemBuilder: (context, index) { 83 | return ListTile( 84 | title: Text(filteredProxies[index].name), 85 | subtitle: Text(filteredProxies[index].url.isNotEmpty 86 | ? filteredProxies[index].url 87 | : ''), 88 | onTap: () { 89 | if (filteredProxies[index].url.isEmpty) { 90 | // Handle "No institution" 91 | onInstitutionSelected('None', 'None'); 92 | } else { 93 | // Handle selected proxy 94 | onInstitutionSelected( 95 | filteredProxies[index].name, 96 | filteredProxies[index].url, 97 | ); 98 | } 99 | }, 100 | ); 101 | }, 102 | ), 103 | ), 104 | ], 105 | ), 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/screens/journals_search_results_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../services/crossref_api.dart'; 4 | import '../models/crossref_journals_models.dart' as Journals; 5 | import '../widgets/journal_search_results_card.dart'; 6 | import '../services/logs_helper.dart'; 7 | 8 | class SearchResultsScreen extends StatefulWidget { 9 | final ListAndMore searchResults; 10 | final String searchQuery; 11 | 12 | const SearchResultsScreen({ 13 | Key? key, 14 | required this.searchResults, 15 | required this.searchQuery, 16 | }) : super(key: key); 17 | 18 | @override 19 | _SearchResultsScreenState createState() => _SearchResultsScreenState(); 20 | } 21 | 22 | class _SearchResultsScreenState extends State { 23 | final logger = LogsService().logger; 24 | List items = []; 25 | bool isLoading = false; 26 | late ScrollController _scrollController; 27 | bool hasMoreResults = true; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | _scrollController = ScrollController(); 33 | _scrollController.addListener(_onScroll); 34 | 35 | if (widget.searchResults.list.isNotEmpty) { 36 | items = widget.searchResults.list; 37 | hasMoreResults = widget.searchResults.hasMore; 38 | } else { 39 | hasMoreResults = false; 40 | } 41 | } 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | return Scaffold( 46 | appBar: AppBar( 47 | title: Text(AppLocalizations.of(context)!.searchresults), 48 | ), 49 | body: items.isEmpty 50 | ? Center( 51 | child: Text(AppLocalizations.of(context)!.noPublicationFound), 52 | ) 53 | : ListView.builder( 54 | itemCount: items.length + (isLoading ? 1 : 0), 55 | itemBuilder: (context, index) { 56 | if (index == items.length && isLoading) { 57 | return Padding( 58 | padding: const EdgeInsets.all(16.0), 59 | child: Center(child: CircularProgressIndicator()), 60 | ); 61 | } else { 62 | Journals.Item currentItem = items[index]; 63 | 64 | // Skip invalid items 65 | if (currentItem.issn.isEmpty) return SizedBox.shrink(); 66 | 67 | return JournalsSearchResultCard( 68 | key: UniqueKey(), 69 | item: currentItem, 70 | isFollowed: false, 71 | ); 72 | } 73 | }, 74 | controller: _scrollController, 75 | ), 76 | ); 77 | } 78 | 79 | void _onScroll() { 80 | if (_scrollController.position.pixels >= 81 | _scrollController.position.maxScrollExtent * 0.8 && 82 | !isLoading && 83 | hasMoreResults) { 84 | loadMoreItems(widget.searchQuery); 85 | } 86 | } 87 | 88 | Future loadMoreItems(String query) async { 89 | setState(() => isLoading = true); 90 | 91 | try { 92 | ListAndMore newResults = 93 | await CrossRefApi.queryJournalsByName(query); 94 | 95 | setState(() { 96 | if (newResults.list.isNotEmpty) { 97 | items.addAll(newResults.list); 98 | hasMoreResults = newResults.hasMore; 99 | } else { 100 | hasMoreResults = false; 101 | } 102 | }); 103 | } catch (e, stackTrace) { 104 | logger.severe('Failed to load more journals.', e, stackTrace); 105 | ScaffoldMessenger.of(context).showSnackBar(SnackBar( 106 | content: 107 | Text(AppLocalizations.of(context)!.failLoadMorePublication))); 108 | } finally { 109 | setState(() => isLoading = false); 110 | } 111 | } 112 | 113 | @override 114 | void dispose() { 115 | _scrollController.dispose(); 116 | super.dispose(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /lib/screens/library_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../services/database_helper.dart'; 4 | import '../widgets/journals_tab_content.dart'; 5 | import '../widgets/queries_tab_content.dart'; 6 | 7 | class LibraryScreen extends StatefulWidget { 8 | const LibraryScreen({super.key}); 9 | 10 | @override 11 | _LibraryScreenState createState() => _LibraryScreenState(); 12 | } 13 | 14 | class _LibraryScreenState extends State 15 | with TickerProviderStateMixin { 16 | bool isSearching = false; 17 | late DatabaseHelper dbHelper; 18 | late FocusNode searchFocusNode; 19 | late final TabController _tabController; 20 | 21 | int journalsSortBy = 0; // Set the sort by option to Journal title by default 22 | int journalsSortOrder = 0; // Set the sort order to Ascending by default 23 | int authorsSortBy = 0; // Set the sort by option to Journal title by default 24 | int authorsSortOrder = 0; // Set the sort order to Ascending by default 25 | int queriesSortBy = 0; // Set the sort by option to Queriy name by default 26 | int queriesSortOrder = 0; // Set the sort order to Ascending by default 27 | 28 | @override 29 | void initState() { 30 | super.initState(); 31 | dbHelper = DatabaseHelper(); 32 | searchFocusNode = FocusNode(); 33 | _tabController = TabController(length: 2, vsync: this); 34 | } 35 | 36 | @override 37 | void dispose() { 38 | _tabController.dispose(); 39 | super.dispose(); 40 | } 41 | 42 | @override 43 | Widget build(BuildContext context) { 44 | return Scaffold( 45 | appBar: AppBar( 46 | centerTitle: false, 47 | title: Text(AppLocalizations.of(context)!.library), 48 | bottom: TabBar( 49 | controller: _tabController, 50 | tabs: [ 51 | Tab( 52 | icon: Icon(Icons.menu_book_rounded), 53 | text: AppLocalizations.of(context)!.journals, 54 | ), 55 | /*ab( 56 | icon: Icon(Icons.person_2_outlined), 57 | text: AppLocalizations.of(context)!.authors, 58 | ),*/ 59 | Tab( 60 | icon: Icon(Icons.format_quote_rounded), 61 | text: AppLocalizations.of(context)!.queries, 62 | ), 63 | ], 64 | ), 65 | ), 66 | body: TabBarView( 67 | controller: _tabController, 68 | children: [ 69 | JournalsTabContent( 70 | initialSortBy: journalsSortBy, 71 | initialSortOrder: journalsSortOrder, 72 | onSortByChanged: (int value) { 73 | setState(() { 74 | journalsSortBy = value; 75 | }); 76 | }, 77 | onSortOrderChanged: (int value) { 78 | setState(() { 79 | journalsSortOrder = value; 80 | }); 81 | }, 82 | ), 83 | /*AuthorsTabContent( 84 | initialSortBy: authorsSortBy, 85 | initialSortOrder: authorsSortOrder, 86 | onSortByChanged: (int value) { 87 | setState(() { 88 | authorsSortBy = value; 89 | }); 90 | }, 91 | onSortOrderChanged: (int value) { 92 | setState(() { 93 | authorsSortOrder = value; 94 | }); 95 | }, 96 | ),*/ 97 | QueriesTabContent( 98 | initialSortBy: queriesSortBy, 99 | initialSortOrder: queriesSortOrder, 100 | onSortByChanged: (int value) { 101 | setState(() { 102 | queriesSortBy = value; 103 | }); 104 | }, 105 | onSortOrderChanged: (int value) { 106 | setState(() { 107 | queriesSortOrder = value; 108 | }); 109 | }, 110 | ), 111 | ], 112 | ), 113 | ); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /lib/screens/logs_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:logging/logging.dart'; 3 | import 'package:flutter/services.dart'; 4 | import 'package:wispar/generated_l10n/app_localizations.dart'; 5 | import '../services/logs_helper.dart'; 6 | 7 | class LogsScreen extends StatelessWidget { 8 | const LogsScreen({Key? key}) : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return Scaffold( 13 | appBar: AppBar( 14 | title: Text(AppLocalizations.of(context)!.logs), 15 | centerTitle: false, 16 | actions: [ 17 | IconButton( 18 | icon: Icon(Icons.download, 19 | color: Theme.of(context).colorScheme.primary), 20 | tooltip: AppLocalizations.of(context)!.saveLogs, 21 | onPressed: () async { 22 | await LogsService().saveLogsToFile(context); 23 | }, 24 | ), 25 | IconButton( 26 | icon: 27 | Icon(Icons.share, color: Theme.of(context).colorScheme.primary), 28 | tooltip: AppLocalizations.of(context)!.shareLogs, 29 | onPressed: () async { 30 | await LogsService().shareLogs(context); 31 | }, 32 | ), 33 | IconButton( 34 | icon: Icon(Icons.delete, 35 | color: Theme.of(context).colorScheme.primary), 36 | tooltip: AppLocalizations.of(context)!.deleteLogs, 37 | onPressed: () { 38 | LogsService().clearLogs(); 39 | ScaffoldMessenger.of(context).showSnackBar( 40 | SnackBar( 41 | content: Text(AppLocalizations.of(context)!.logsDeleted), 42 | ), 43 | ); 44 | }, 45 | ), 46 | ], 47 | ), 48 | body: ValueListenableBuilder>( 49 | valueListenable: LogsService().logsNotifier, 50 | builder: (context, logs, _) { 51 | if (logs.isEmpty) { 52 | return Center( 53 | child: Text(AppLocalizations.of(context)!.logsUnavailable)); 54 | } 55 | 56 | return ListView.builder( 57 | padding: const EdgeInsets.all(8), 58 | itemCount: logs.length, 59 | itemBuilder: (context, index) { 60 | final log = logs[index]; 61 | return GestureDetector( 62 | onLongPress: () { 63 | final content = 64 | '[${log.level.name}] ${log.time.toLocal()} — ${log.message}' 65 | '${log.error != null ? '\nError: ${log.error}' : ''}' 66 | '${log.stackTrace != null ? '\nStack trace:\n${log.stackTrace}' : ''}'; 67 | Clipboard.setData(ClipboardData(text: content)); 68 | ScaffoldMessenger.of(context).showSnackBar( 69 | SnackBar( 70 | content: Text(AppLocalizations.of(context)!.logCopied)), 71 | ); 72 | }, 73 | child: Card( 74 | color: _logColor(log.level), 75 | child: Padding( 76 | padding: const EdgeInsets.all(8.0), 77 | child: Column( 78 | crossAxisAlignment: CrossAxisAlignment.start, 79 | children: [ 80 | Text( 81 | '[${log.level.name}] ${log.time.toLocal()} — ${log.message}', 82 | style: const TextStyle( 83 | fontWeight: FontWeight.bold, color: Colors.black), 84 | ), 85 | if (log.error != null) 86 | Padding( 87 | padding: const EdgeInsets.only(top: 4), 88 | child: Text( 89 | 'Error: ${log.error}', 90 | style: const TextStyle( 91 | color: Colors.red, 92 | fontWeight: FontWeight.bold), 93 | ), 94 | ), 95 | if (log.stackTrace != null) 96 | Padding( 97 | padding: const EdgeInsets.only(top: 4), 98 | child: Text( 99 | 'Stack trace:\n${log.stackTrace}', 100 | style: const TextStyle( 101 | fontSize: 12, color: Colors.black), 102 | ), 103 | ), 104 | ], 105 | ), 106 | ), 107 | ), 108 | ); 109 | }, 110 | ); 111 | }, 112 | ), 113 | ); 114 | } 115 | 116 | Color _logColor(Level level) { 117 | if (level >= Level.SEVERE) return Colors.red.shade100; 118 | if (level >= Level.WARNING) return Colors.orange.shade100; 119 | if (level >= Level.INFO) return Colors.blue.shade50; 120 | return Colors.grey.shade100; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lib/screens/pdf_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import 'package:pdfrx/pdfrx.dart'; 4 | import '../widgets/publication_card.dart'; 5 | import '../services/database_helper.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | import 'package:open_filex/open_filex.dart'; 8 | import 'package:path_provider/path_provider.dart'; 9 | import 'package:path/path.dart' as p; 10 | import '../services/logs_helper.dart'; 11 | 12 | class PdfReader extends StatefulWidget { 13 | final String pdfUrl; 14 | final PublicationCard publicationCard; 15 | 16 | PdfReader({Key? key, required this.pdfUrl, required this.publicationCard}) 17 | : super(key: key); 18 | 19 | @override 20 | _PdfReaderState createState() => _PdfReaderState(); 21 | } 22 | 23 | class _PdfReaderState extends State { 24 | final logger = LogsService().logger; 25 | final controller = PdfViewerController(); 26 | late DatabaseHelper databaseHelper; 27 | late String resolvedPdfPath = ""; 28 | bool isPathResolved = false; 29 | bool isDownloaded = false; 30 | 31 | @override 32 | void dispose() { 33 | super.dispose(); 34 | } 35 | 36 | @override 37 | void initState() { 38 | super.initState(); 39 | databaseHelper = DatabaseHelper(); 40 | resolvePdfPath(); 41 | } 42 | 43 | void resolvePdfPath() async { 44 | final directory = await getApplicationDocumentsDirectory(); 45 | final pdfFileName = p.basename(widget.pdfUrl); 46 | final newPdfPath = p.join(directory.path, pdfFileName); 47 | 48 | setState(() { 49 | resolvedPdfPath = newPdfPath; 50 | isPathResolved = true; 51 | }); 52 | 53 | checkIfDownloaded(); 54 | } 55 | 56 | @override 57 | Widget build(BuildContext context) { 58 | return Scaffold( 59 | appBar: AppBar( 60 | centerTitle: false, 61 | title: Text(AppLocalizations.of(context)!.articleViewer), 62 | actions: [ 63 | IconButton( 64 | icon: const Icon(Icons.open_in_browser), 65 | tooltip: AppLocalizations.of(context)!.openExternalPdfApp, 66 | onPressed: () async { 67 | try { 68 | OpenFilex.open(resolvedPdfPath); 69 | } catch (e, stackTrace) { 70 | logger.severe( 71 | 'Unable to open the PDF file in an external app.', 72 | e, 73 | stackTrace); 74 | ScaffoldMessenger.of(context).showSnackBar(SnackBar( 75 | content: Text(AppLocalizations.of(context)! 76 | .errorOpenExternalPdfApp))); 77 | } 78 | }, 79 | ), 80 | isDownloaded == false 81 | ? IconButton( 82 | icon: const Icon(Icons.download_outlined), 83 | tooltip: AppLocalizations.of(context)!.download, 84 | onPressed: () async { 85 | // Otherwise, insert a new article 86 | await databaseHelper.insertArticle( 87 | widget.publicationCard, 88 | isDownloaded: true, 89 | pdfPath: p.basename(widget.pdfUrl), 90 | ); 91 | setState(() { 92 | isDownloaded = true; 93 | }); 94 | ScaffoldMessenger.of(context).showSnackBar(SnackBar( 95 | content: Text(AppLocalizations.of(context)! 96 | .downloadSuccessful))); 97 | }) 98 | : IconButton( 99 | icon: const Icon(Icons.delete_outlined), 100 | tooltip: AppLocalizations.of(context)!.delete, 101 | onPressed: () async { 102 | await databaseHelper.removeDownloaded( 103 | widget.publicationCard.doi, 104 | ); 105 | setState(() { 106 | isDownloaded = false; 107 | }); 108 | ScaffoldMessenger.of(context).showSnackBar(SnackBar( 109 | content: Text( 110 | AppLocalizations.of(context)!.downloadDeleted))); 111 | }, 112 | ), 113 | ]), 114 | body: isPathResolved 115 | ? Stack(children: [ 116 | PdfViewer.file(resolvedPdfPath, 117 | controller: controller, 118 | params: PdfViewerParams( 119 | enableTextSelection: false, 120 | maxScale: 8, 121 | loadingBannerBuilder: 122 | (context, bytesDownloaded, totalBytes) { 123 | return Center( 124 | child: CircularProgressIndicator( 125 | value: totalBytes != null 126 | ? bytesDownloaded / totalBytes 127 | : null, 128 | backgroundColor: Colors.grey, 129 | ), 130 | ); 131 | }, 132 | linkHandlerParams: PdfLinkHandlerParams( 133 | linkColor: const Color.fromARGB(20, 255, 235, 59), 134 | onLinkTap: (link) { 135 | if (link.url != null) { 136 | launchUrl(link.url!); 137 | } else if (link.dest != null) { 138 | controller.goToDest(link.dest); 139 | } 140 | }, 141 | ), 142 | )) 143 | ]) 144 | : const Center(child: CircularProgressIndicator()), 145 | ); 146 | } 147 | 148 | void checkIfDownloaded() async { 149 | bool downloaded = 150 | await databaseHelper.isArticleDownloaded(widget.publicationCard.doi); 151 | setState(() { 152 | isDownloaded = downloaded; 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/screens/search_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../widgets/article_search_form.dart'; 4 | import '../widgets/journal_search_form.dart'; 5 | 6 | class SearchScreen extends StatefulWidget { 7 | const SearchScreen({super.key}); 8 | 9 | @override 10 | _SearchScreenState createState() => _SearchScreenState(); 11 | } 12 | 13 | class _SearchScreenState extends State { 14 | int selectedSearchType = 1; // Default to article type 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Scaffold( 19 | appBar: AppBar( 20 | centerTitle: false, 21 | title: Text(AppLocalizations.of(context)!.search), 22 | ), 23 | body: Padding( 24 | padding: const EdgeInsets.all(16.0), 25 | child: Column( 26 | crossAxisAlignment: CrossAxisAlignment.start, 27 | children: [ 28 | /*Text( 29 | AppLocalizations.of(context)!.category, 30 | style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), 31 | ), 32 | SizedBox(height: 8),*/ 33 | LayoutBuilder( 34 | builder: (context, constraints) { 35 | return ToggleButtons( 36 | borderRadius: BorderRadius.circular(8.0), 37 | isSelected: [ 38 | selectedSearchType == 1, 39 | selectedSearchType == 2, 40 | ], 41 | onPressed: (int index) { 42 | setState(() { 43 | selectedSearchType = index == 0 ? 1 : 2; 44 | }); 45 | }, 46 | children: [ 47 | Container( 48 | width: constraints.maxWidth / 2 - 1.5, 49 | alignment: Alignment.center, 50 | padding: EdgeInsets.symmetric(vertical: 10), 51 | child: Text( 52 | AppLocalizations.of(context)!.articles, 53 | textAlign: TextAlign.center, 54 | ), 55 | ), 56 | Container( 57 | width: constraints.maxWidth / 2 - 1.5, 58 | alignment: Alignment.center, 59 | padding: EdgeInsets.symmetric(vertical: 10), 60 | child: Text( 61 | AppLocalizations.of(context)!.journals, 62 | textAlign: TextAlign.center, 63 | ), 64 | ), 65 | ], 66 | ); 67 | }, 68 | ), 69 | SizedBox(height: 8), 70 | Divider(thickness: 1, color: Colors.grey[300]), 71 | SizedBox(height: 8), 72 | Expanded( 73 | child: 74 | _getSearchForm()), // Show form based on selected category 75 | ], 76 | ), 77 | ), 78 | ); 79 | } 80 | 81 | // Returns the form based on the selected search type 82 | Widget _getSearchForm() { 83 | switch (selectedSearchType) { 84 | case 1: 85 | return ArticleSearchScreen(); 86 | case 2: 87 | return JournalSearchForm(); 88 | default: 89 | return ArticleSearchScreen(); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/screens/zotero_settings_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | import 'package:shared_preferences/shared_preferences.dart'; 5 | import '../services/zotero_api.dart'; 6 | 7 | class ZoteroSettings extends StatefulWidget { 8 | const ZoteroSettings({Key? key}); 9 | 10 | @override 11 | _ZoteroSettingsState createState() => _ZoteroSettingsState(); 12 | } 13 | 14 | class _ZoteroSettingsState extends State { 15 | TextEditingController _apiKeyController = TextEditingController(); 16 | bool passwordVisible = false; 17 | 18 | @override 19 | void initState() { 20 | super.initState(); 21 | _loadApiKey(); 22 | } 23 | 24 | _loadApiKey() async { 25 | SharedPreferences prefs = await SharedPreferences.getInstance(); 26 | String? apiKey = prefs.getString('zoteroApiKey'); 27 | if (apiKey != null && apiKey.isNotEmpty) { 28 | setState(() { 29 | _apiKeyController.text = apiKey; 30 | }); 31 | } 32 | } 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return Scaffold( 37 | appBar: AppBar( 38 | centerTitle: false, 39 | title: Text(AppLocalizations.of(context)!.zoteroSettings), 40 | ), 41 | body: Padding( 42 | padding: const EdgeInsets.symmetric(horizontal: 16.0), 43 | child: Column( 44 | children: [ 45 | Expanded( 46 | child: SingleChildScrollView( 47 | child: Column( 48 | crossAxisAlignment: CrossAxisAlignment.start, 49 | children: [ 50 | Text(AppLocalizations.of(context)!.zoteroPermissions1), 51 | Text( 52 | '\n${AppLocalizations.of(context)!.zoteroPermissions2}\n', 53 | ), 54 | SizedBox( 55 | width: double.infinity, 56 | child: FilledButton.tonal( 57 | onPressed: () { 58 | launchUrl( 59 | Uri.parse( 60 | 'https://www.zotero.org/settings/keys/new'), 61 | ); 62 | }, 63 | child: 64 | Text(AppLocalizations.of(context)!.zoteroCreateKey), 65 | ), 66 | ), 67 | Text( 68 | '\n${AppLocalizations.of(context)!.zoteroPermissions3}\n', 69 | ), 70 | TextField( 71 | controller: _apiKeyController, 72 | obscureText: !passwordVisible, 73 | decoration: InputDecoration( 74 | hintText: AppLocalizations.of(context)!.zoteroEnterKey, 75 | suffixIcon: IconButton( 76 | icon: Icon( 77 | passwordVisible 78 | ? Icons.visibility_outlined 79 | : Icons.visibility_off_outlined, 80 | ), 81 | onPressed: () { 82 | setState(() { 83 | passwordVisible = !passwordVisible; 84 | }); 85 | }, 86 | ), 87 | ), 88 | onChanged: (value) {}, 89 | ), 90 | SizedBox(height: 16), 91 | SizedBox( 92 | width: double.infinity, 93 | child: FilledButton( 94 | onPressed: () async { 95 | String apiKey = _apiKeyController.text; 96 | if (apiKey.isNotEmpty) { 97 | int userId = await ZoteroService.getUserId(apiKey); 98 | if (userId != 0) { 99 | SharedPreferences prefs = 100 | await SharedPreferences.getInstance(); 101 | await prefs.setString('zoteroApiKey', apiKey); 102 | await prefs.setString( 103 | 'zoteroUserId', userId.toString()); 104 | ScaffoldMessenger.of(context).showSnackBar( 105 | SnackBar( 106 | content: Text( 107 | AppLocalizations.of(context)! 108 | .zoteroValidKey), 109 | duration: const Duration(seconds: 2))); 110 | } else { 111 | ScaffoldMessenger.of(context) 112 | .showSnackBar(SnackBar( 113 | content: Text(AppLocalizations.of(context)! 114 | .zoteroInvalidKey), 115 | duration: const Duration(seconds: 3), 116 | )); 117 | } 118 | } 119 | }, 120 | child: Text(AppLocalizations.of(context)!.save), 121 | ), 122 | ), 123 | ], 124 | ), 125 | ), 126 | ), 127 | ], 128 | ), 129 | ), 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/services/abstract_helper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | 5 | enum AbstractSetting { showAll, hideAll, hideMissing } 6 | 7 | class AbstractHelper { 8 | static Future getAbstractSetting() async { 9 | final prefs = await SharedPreferences.getInstance(); 10 | final int setting = prefs.getInt('publicationCardAbstractSetting') ?? 1; 11 | 12 | switch (setting) { 13 | case 1: 14 | return AbstractSetting.hideMissing; 15 | case 2: 16 | return AbstractSetting.hideAll; 17 | case 3: 18 | default: 19 | return AbstractSetting.showAll; 20 | } 21 | } 22 | 23 | static Future buildAbstract( 24 | BuildContext context, String abstract) async { 25 | AbstractSetting setting = await getAbstractSetting(); 26 | 27 | switch (setting) { 28 | case AbstractSetting.hideAll: 29 | return ''; // Return an empty string if abstracts should be hidden 30 | case AbstractSetting.hideMissing: 31 | return abstract.isNotEmpty ? abstract : ''; // Only show if not empty 32 | case AbstractSetting.showAll: 33 | default: 34 | return abstract.isNotEmpty 35 | ? abstract 36 | : AppLocalizations.of(context)! 37 | .abstractunavailable; // Show default message if empty 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/services/abstract_scraper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:flutter_inappwebview/flutter_inappwebview.dart'; 3 | import './string_format_helper.dart'; 4 | import './logs_helper.dart'; 5 | 6 | class AbstractScraper { 7 | Completer _completer = Completer(); 8 | 9 | Future scrapeAbstract(String url) async { 10 | final logger = LogsService().logger; 11 | _completer = Completer(); 12 | 13 | HeadlessInAppWebView? headlessWebView; 14 | 15 | headlessWebView = HeadlessInAppWebView( 16 | initialSettings: InAppWebViewSettings( 17 | userAgent: 18 | "Mozilla/5.0 (Android 15; Mobile; rv:133.0) Gecko/133.0 Firefox/133.0", 19 | ), 20 | initialUrlRequest: URLRequest(url: WebUri(url)), 21 | onLoadStop: (controller, loadedUrl) async { 22 | await Future.delayed(Duration(seconds: 3)); // Allow JS-loaded content 23 | 24 | if (_completer.isCompleted) return; 25 | 26 | try { 27 | logger 28 | .info("Attempting to scrape missing abstract from ${loadedUrl}"); 29 | String? abstractText = await controller.evaluateJavascript( 30 | source: """ 31 | (() => { 32 | function extractFullText(element) { 33 | if (!element) return null; 34 | return element.innerText.trim(); 35 | } 36 | 37 | // Special case: Springer (Abstract is inside c-article-section__content) 38 | let springerHeader = document.querySelector('h2.c-article-section__title'); 39 | if (springerHeader && /abstract/i.test(springerHeader.innerText)) { 40 | let springerAbstract = springerHeader.nextElementSibling; 41 | if (springerAbstract && springerAbstract.classList.contains('c-article-section__content')) { 42 | return extractFullText(springerAbstract); 43 | } 44 | } 45 | 46 | // Special case: Elsevier (abstract is inside '.abstract.author') 47 | let elsevierAbstract = document.querySelector('.abstract.author'); 48 | if (elsevierAbstract) { 49 | return extractFullText(elsevierAbstract); 50 | } 51 | 52 | // General case: Look for any div/section with 'abstract' in class or id 53 | let abstractDiv = [...document.querySelectorAll('div, section')] 54 | .find(el => /abstract/i.test(el.className) || /abstract/i.test(el.id)); 55 | 56 | if (abstractDiv) { 57 | return extractFullText(abstractDiv); 58 | } 59 | 60 | // Fallback to meta description 61 | let metaDesc = document.querySelector('meta[name="description"]'); 62 | if (metaDesc) return metaDesc.content.trim(); 63 | 64 | return null; 65 | })(); 66 | """, 67 | ); 68 | abstractText = cleanAbstract(abstractText!); 69 | logger.info('The missing abstract was successfully scraped.'); 70 | _completer.complete(abstractText); 71 | } catch (e, stackTrace) { 72 | logger.severe( 73 | 'The missing abstract could not be scraped.', e, stackTrace); 74 | _completer.completeError(e); 75 | } finally { 76 | await InAppWebViewController.clearAllCache(); 77 | headlessWebView?.dispose(); 78 | } 79 | }, 80 | ); 81 | 82 | await headlessWebView.run(); 83 | return _completer.future; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/services/feed_api.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart' as http; 2 | import 'dart:convert'; 3 | import '../models/crossref_journals_works_models.dart' as journalWorks; 4 | import '../models/openAlex_works_models.dart'; 5 | 6 | class FeedApi { 7 | static const String baseUrl = 'https://api.crossref.org'; 8 | static const String journalsEndpoint = '/journals'; 9 | static const String worksEndpoint = '/works'; 10 | static const String email = 'mailto=wispar-app@protonmail.com'; 11 | 12 | static const String baseUrlOpenAlex = 'https://api.openalex.org'; 13 | static const String worksEndpointOpenAlex = '/works?'; 14 | 15 | static Future> getRecentFeed( 16 | List issn) async { 17 | final String issnFilter = issn.map((e) => 'issn:$e').join(','); 18 | final response = await http.get(Uri.parse( 19 | '$baseUrl$worksEndpoint?rows=100&sort=created&order=desc&$email&filter=$issnFilter')); 20 | 21 | if (response.statusCode == 200) { 22 | final responseData = 23 | journalWorks.JournalWork.fromJson(json.decode(response.body)); 24 | List feedItems = responseData.message.items; 25 | return feedItems; 26 | } else { 27 | throw Exception('Failed to fetch recent feed'); 28 | } 29 | } 30 | 31 | static Future> getSavedQueryWorks( 32 | Map queryParams) async { 33 | String url = '$baseUrl$worksEndpoint'; 34 | String queryString = queryParams.entries 35 | .map((entry) => 36 | '${Uri.encodeQueryComponent(entry.key)}=${Uri.encodeQueryComponent(entry.value.toString())}') 37 | .join('&'); 38 | String apiUrl = '$url?$queryString&rows=50&$email'; 39 | 40 | final response = await http.get(Uri.parse(apiUrl)); 41 | 42 | if (response.statusCode == 200) { 43 | final responseData = 44 | journalWorks.JournalWork.fromJson(json.decode(response.body)); 45 | List feedItems = responseData.message.items; 46 | return feedItems; 47 | } else { 48 | throw Exception('Failed to fetch results'); 49 | } 50 | } 51 | 52 | static Future> getSavedQueryOpenAlex( 53 | String query) async { 54 | final url = Uri.parse( 55 | '$baseUrlOpenAlex$worksEndpointOpenAlex$query&per-page=50&$email'); 56 | 57 | final response = await http.get(url); 58 | 59 | if (response.statusCode == 200) { 60 | final jsonResponse = jsonDecode(response.body); 61 | final results = (jsonResponse['results'] as List?) 62 | ?.map((item) => OpenAlexWorks.fromJson(item)) 63 | .toList() ?? 64 | []; 65 | 66 | return results 67 | .map((result) => journalWorks.Item( 68 | title: result.title, 69 | abstract: result.abstract ?? '', 70 | journalTitle: result.journalTitle ?? '', 71 | publishedDate: result.publishedDate != null 72 | ? DateTime.tryParse(result.publishedDate!) ?? 73 | DateTime(1970, 1, 1) 74 | : DateTime(1970, 1, 1), 75 | doi: result.doi ?? '', 76 | authors: result.authors.map((fullName) { 77 | List parts = fullName.split(' '); 78 | String given = parts.isNotEmpty ? parts.first : ''; 79 | String family = 80 | parts.length > 1 ? parts.sublist(1).join(' ') : ''; 81 | return journalWorks.PublicationAuthor( 82 | given: given, family: family); 83 | }).toList(), 84 | url: result.url ?? '', 85 | primaryUrl: result.url ?? '', 86 | license: '', 87 | licenseName: result.license ?? '', 88 | publisher: result.publisher ?? '', 89 | issn: result.issn ?? [], 90 | )) 91 | .toList(); 92 | } else { 93 | throw Exception('Failed to fetch results: ${response.reasonPhrase}'); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/services/libproxydb_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:http/http.dart' as http; 3 | 4 | class ProxyData { 5 | final String name; 6 | final String url; 7 | 8 | ProxyData({required this.name, required this.url}); 9 | 10 | factory ProxyData.fromJson(Map json) { 11 | return ProxyData( 12 | name: json['name'] ?? '', 13 | url: json['url'] ?? '', 14 | ); 15 | } 16 | } 17 | 18 | class ProxyService { 19 | static Future> fetchProxies() async { 20 | final response = 21 | await http.get(Uri.parse('https://libproxy-db.org/proxies.json')); 22 | 23 | if (response.statusCode == 200) { 24 | final decoded = utf8.decode(response.bodyBytes); 25 | final List jsonList = json.decode(decoded); 26 | 27 | return jsonList.map((json) => ProxyData.fromJson(json)).toList(); 28 | } else { 29 | throw Exception('Failed to load proxy data'); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/services/logs_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:logging/logging.dart'; 5 | import 'package:file_picker/file_picker.dart'; 6 | import 'package:path_provider/path_provider.dart'; 7 | import 'package:share_plus/share_plus.dart'; 8 | import 'package:wispar/generated_l10n/app_localizations.dart'; 9 | 10 | class LogsService { 11 | static final LogsService _instance = LogsService._internal(); 12 | factory LogsService() => _instance; 13 | 14 | final Logger _logger = Logger('Wispar'); 15 | final ValueNotifier> logsNotifier = ValueNotifier([]); 16 | 17 | LogsService._internal() { 18 | _init(); 19 | } 20 | 21 | void _init() { 22 | Logger.root.level = Level.ALL; 23 | Logger.root.onRecord.listen((record) { 24 | logsNotifier.value = [...logsNotifier.value, record]; 25 | 26 | if (kDebugMode) { 27 | print( 28 | '${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); 29 | if (record.error != null) print('Error: ${record.error}'); 30 | if (record.stackTrace != null) 31 | print('Stacktrace: ${record.stackTrace}'); 32 | } 33 | }); 34 | } 35 | 36 | Logger get logger => _logger; 37 | 38 | void clearLogs() { 39 | logsNotifier.value = []; 40 | } 41 | 42 | Future saveLogsToFile(BuildContext context) async { 43 | final logs = LogsService().logsNotifier.value; 44 | final buffer = StringBuffer(); 45 | 46 | try { 47 | for (var record in logs) { 48 | buffer.writeln( 49 | '${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); 50 | if (record.error != null) buffer.writeln('Error: ${record.error}'); 51 | if (record.stackTrace != null) 52 | buffer.writeln('Stacktrace: ${record.stackTrace}'); 53 | buffer.writeln(); 54 | } 55 | 56 | final Uint8List logBytes = 57 | Uint8List.fromList(buffer.toString().codeUnits); 58 | 59 | final String? outputPath = await FilePicker.platform.saveFile( 60 | dialogTitle: AppLocalizations.of(context)!.selectLogsLocation, 61 | fileName: 'wispar_logs.txt', 62 | bytes: logBytes, 63 | ); 64 | 65 | if (outputPath == null) { 66 | return; 67 | } 68 | ScaffoldMessenger.of(context).showSnackBar( 69 | SnackBar( 70 | content: 71 | Text(AppLocalizations.of(context)!.logsExportedSuccessfully)), 72 | ); 73 | } catch (e, stackTrace) { 74 | logger.severe('Error saving logs.', e, stackTrace); 75 | ScaffoldMessenger.of(context).showSnackBar( 76 | SnackBar( 77 | content: Text(AppLocalizations.of(context)!.logsExportedError)), 78 | ); 79 | } 80 | } 81 | 82 | Future shareLogs(BuildContext context) async { 83 | final logs = LogsService().logsNotifier.value; 84 | final buffer = StringBuffer(); 85 | 86 | try { 87 | for (var record in logs) { 88 | buffer.writeln( 89 | '${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); 90 | if (record.error != null) buffer.writeln('Error: ${record.error}'); 91 | if (record.stackTrace != null) 92 | buffer.writeln('Stacktrace: ${record.stackTrace}'); 93 | buffer.writeln(); 94 | } 95 | 96 | final tempDir = await getTemporaryDirectory(); 97 | final tempFile = File('${tempDir.path}/wispar_logs.txt'); 98 | await tempFile.writeAsString(buffer.toString()); 99 | 100 | await SharePlus.instance.share(ShareParams( 101 | files: [XFile(tempFile.path)], 102 | text: AppLocalizations.of(context)!.logs)); 103 | 104 | await tempFile.delete(); 105 | } catch (e, stackTrace) { 106 | logger.severe('Error sharing logs.', e, stackTrace); 107 | ScaffoldMessenger.of(context).showSnackBar( 108 | SnackBar( 109 | content: Text(AppLocalizations.of(context)!.logsExportedError)), 110 | ); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/services/mathml_converter.dart: -------------------------------------------------------------------------------- 1 | import 'package:xml/xml.dart'; 2 | import './logs_helper.dart'; 3 | 4 | // Converts MathML to latex equations so that they can be rendered with Latext 5 | class MathmlToLatexConverter { 6 | final logger = LogsService().logger; 7 | String convert(String raw) { 8 | try { 9 | final wrapped = '$raw'; 10 | final document = XmlDocument.parse(wrapped); 11 | return _convertChildren(document.rootElement.children); 12 | } catch (e, stackTrace) { 13 | logger.warning('Unable to convert MathML to Latex.', e, stackTrace); 14 | return raw; 15 | } 16 | } 17 | 18 | String _convertChildren(List nodes) { 19 | final buffer = StringBuffer(); 20 | final formulaBuffer = StringBuffer(); 21 | bool insideFormula = false; 22 | 23 | for (int i = 0; i < nodes.length; i++) { 24 | final node = nodes[i]; 25 | 26 | if (node is XmlText) { 27 | final text = node.value; 28 | final trimmed = text.trimRight(); 29 | 30 | final isChemicalSymbol = 31 | RegExp(r'^[A-Z][a-z]?$').hasMatch(trimmed.trim()); 32 | 33 | if (isChemicalSymbol) { 34 | // Start formula buffering if not already 35 | if (!insideFormula) { 36 | insideFormula = true; 37 | formulaBuffer.clear(); 38 | } 39 | formulaBuffer.write(trimmed.trim()); 40 | } else { 41 | // Flush formula if any 42 | if (insideFormula) { 43 | buffer.write('\$${formulaBuffer.toString()}\$'); 44 | formulaBuffer.clear(); 45 | insideFormula = false; 46 | } 47 | buffer.write(trimmed); // regular text 48 | } 49 | } else if (node is XmlElement && node.name.local == 'math') { 50 | final mathContent = _convertChildren(node.children); 51 | if (!insideFormula) { 52 | insideFormula = true; 53 | formulaBuffer.clear(); 54 | } 55 | formulaBuffer.write(mathContent); 56 | 57 | // Look ahead, if next is not another , flush now 58 | final next = i + 1 < nodes.length ? nodes[i + 1] : null; 59 | final isNextMath = next is XmlElement && next.name.local == 'math'; 60 | final isNextChemText = next is XmlText && 61 | RegExp(r'^[A-Z][a-z]?$').hasMatch(next.value.trim()); 62 | 63 | if (!isNextMath && !isNextChemText) { 64 | buffer.write('\$${formulaBuffer.toString()}\$'); 65 | formulaBuffer.clear(); 66 | insideFormula = false; 67 | } 68 | } else { 69 | if (insideFormula) { 70 | buffer.write('\$${formulaBuffer.toString()}\$'); 71 | formulaBuffer.clear(); 72 | insideFormula = false; 73 | } 74 | buffer.write(_convertNode(node)); 75 | } 76 | } 77 | 78 | // Final flush 79 | if (insideFormula && formulaBuffer.isNotEmpty) { 80 | buffer.write('\$${formulaBuffer.toString()}\$'); 81 | } 82 | 83 | return buffer.toString(); 84 | } 85 | 86 | String _convertNode(XmlNode node) { 87 | if (node is XmlText) { 88 | return node.value; 89 | } 90 | 91 | if (node is XmlElement) { 92 | final tag = node.name.local; 93 | 94 | // Detect full tag 95 | if (tag == 'math') { 96 | final latex = _convertChildren(node.children); 97 | return '\$$latex\$'; // wrap with dollar signs for Latext 98 | } 99 | 100 | // Handle MathML elements inside 101 | switch (tag) { 102 | case 'mi': 103 | case 'mn': 104 | case 'mo': 105 | return node.innerText; 106 | case 'msup': 107 | final base = 108 | node.children.length > 0 ? _convertNode(node.children[0]) : ''; 109 | final sup = 110 | node.children.length > 1 ? _convertNode(node.children[1]) : ''; 111 | return '$base^{$sup}'; 112 | case 'msub': 113 | final base = 114 | node.children.length > 0 ? _convertNode(node.children[0]) : ''; 115 | 116 | final sub = 117 | node.children.length > 1 ? _convertNode(node.children[1]) : ''; 118 | return '${base}_{$sub}'; 119 | case 'mfrac': 120 | final numerator = 121 | node.children.length > 0 ? _convertNode(node.children[0]) : ''; 122 | final denominator = 123 | node.children.length > 1 ? _convertNode(node.children[1]) : ''; 124 | return '\\frac{$numerator}{$denominator}'; 125 | case 'msqrt': 126 | return '\\sqrt{${_convertChildren(node.children)}}'; 127 | case 'mrow': 128 | return _convertChildren(node.children); 129 | default: 130 | return _convertChildren(node.children); 131 | } 132 | } 133 | return ''; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /lib/services/openAlex_api.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart' as http; 2 | import 'dart:convert'; 3 | import '../models/openAlex_works_models.dart'; 4 | import '../models/crossref_journals_works_models.dart' as journalWorks; 5 | 6 | class OpenAlexApi { 7 | static const String baseUrl = 'https://api.openalex.org'; 8 | static const String worksEndpoint = '/works?'; 9 | static const String email = 'mailto=wispar-app@protonmail.com'; 10 | 11 | static Future> getOpenAlexWorksByQuery( 12 | String query, int scope, String? sortField, String? sortOrder, 13 | {int page = 1} // Default to page 1 14 | ) async { 15 | final scopeMap = { 16 | 1: 'search=', // Everything 17 | 2: 'filter=title_and_abstract.search:', // Title and Abstract 18 | 3: 'filter=title.search:', // Title only 19 | 4: 'filter=abstract.search:', // Abstract only 20 | }; 21 | 22 | String searchField = scopeMap[scope] ?? 'search='; 23 | String sortBy = sortField != null ? '&sort=$sortField' : ''; 24 | String orderBy = sortOrder != null ? ':$sortOrder' : ''; 25 | 26 | String apiUrl = 27 | '$baseUrl$worksEndpoint$searchField$query$sortBy$orderBy&$email&page=$page'; 28 | 29 | final response = await http.get(Uri.parse(apiUrl)); 30 | 31 | if (response.statusCode == 200) { 32 | final jsonResponse = jsonDecode(response.body); 33 | final results = (jsonResponse['results'] as List?) 34 | ?.map((item) => OpenAlexWorks.fromJson(item)) 35 | .toList() ?? 36 | []; 37 | 38 | return results 39 | .map((result) => journalWorks.Item( 40 | title: result.title, 41 | abstract: result.abstract ?? '', 42 | journalTitle: result.journalTitle ?? '', 43 | publishedDate: result.publishedDate != null 44 | ? DateTime.tryParse(result.publishedDate!) ?? 45 | DateTime(1970, 1, 1) 46 | : DateTime(1970, 1, 1), 47 | doi: result.doi ?? '', 48 | authors: result.authors.map((fullName) { 49 | List parts = fullName.split(' '); 50 | String given = parts.isNotEmpty ? parts.first : ''; 51 | String family = 52 | parts.length > 1 ? parts.sublist(1).join(' ') : ''; 53 | return journalWorks.PublicationAuthor( 54 | given: given, family: family); 55 | }).toList(), 56 | url: result.url ?? '', 57 | primaryUrl: result.url ?? '', 58 | license: '', 59 | licenseName: result.license ?? '', 60 | publisher: result.publisher ?? '', 61 | issn: result.issn ?? [], 62 | )) 63 | .toList(); 64 | } else { 65 | throw Exception('Failed to fetch results: ${response.reasonPhrase}'); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/services/string_format_helper.dart: -------------------------------------------------------------------------------- 1 | import '../models/crossref_journals_works_models.dart'; 2 | import './mathml_converter.dart'; 3 | import 'package:html/parser.dart' as html; 4 | 5 | // Formats a given date to yyyy-mm-dd 6 | String formatDate(DateTime? date) { 7 | if (date == null) return ''; 8 | return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; 9 | } 10 | 11 | String getAuthorsNames(List authors) { 12 | return authors.map((author) => '${author.given} ${author.family}').join(', '); 13 | } 14 | 15 | String cleanAbstract(String rawAbstract) { 16 | final converter = MathmlToLatexConverter(); 17 | rawAbstract = converter.convert(rawAbstract); 18 | rawAbstract = html.parse(rawAbstract).body!.text; 19 | rawAbstract = rawAbstract.replaceAll( 20 | RegExp(r'.*?', dotAll: true), 21 | '', 22 | ); 23 | 24 | // Replace LaTeX equations from with into $...$ 25 | rawAbstract = rawAbstract.replaceAllMapped( 26 | RegExp( 27 | r'.*?(.*?).*?', 28 | dotAll: true), 29 | (match) { 30 | String tex = match[1]?.trim() ?? ''; 31 | 32 | // Remove existing delimiters 33 | tex = tex.replaceAll(RegExp(r'^\$+|\$+$'), ''); 34 | 35 | return '\$$tex\$'; 36 | }, 37 | ); 38 | 39 | // Remove any other blocks like MathML 40 | rawAbstract = rawAbstract.replaceAll( 41 | RegExp(r'.*?', dotAll: true), 42 | '', 43 | ); 44 | 45 | // Remove remaining XML/HTML tags 46 | rawAbstract = rawAbstract.replaceAll(RegExp(r'<[^>]+>'), ''); 47 | 48 | // Remove leading "Abstract" 49 | rawAbstract = rawAbstract.replaceAll( 50 | RegExp(r'^\s*abstract[:.\s]*', caseSensitive: false), 51 | '', 52 | ); 53 | 54 | rawAbstract = rawAbstract 55 | .replaceAll('>', '>') 56 | .replaceAll('<', '<') 57 | .replaceAll('&', '&'); 58 | 59 | // Remove extra spaces 60 | rawAbstract = rawAbstract.replaceAll(RegExp(r'\s+'), ' '); 61 | 62 | return rawAbstract.trim(); 63 | } 64 | 65 | String cleanTitle(String rawTitle) { 66 | final converter = MathmlToLatexConverter(); 67 | rawTitle = converter.convert(rawTitle); 68 | rawTitle = html.parse(rawTitle).body!.text; 69 | // Remove all HTML tags 70 | rawTitle = rawTitle.replaceAll(RegExp(r'<[^>]+>'), ''); 71 | rawTitle = rawTitle 72 | .replaceAll('>', '>') 73 | .replaceAll('<', '<') 74 | .replaceAll('&', '&'); 75 | 76 | // Remove extra spaces 77 | rawTitle = rawTitle.replaceAll(RegExp(r'\s+'), ' '); 78 | 79 | return rawTitle.trim(); 80 | } 81 | -------------------------------------------------------------------------------- /lib/services/unpaywall_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:http/http.dart' as http; 3 | import './logs_helper.dart'; 4 | 5 | class Unpaywall { 6 | final String doiUrl; 7 | final String pdfUrl; 8 | 9 | Unpaywall({required this.doiUrl, required this.pdfUrl}); 10 | 11 | factory Unpaywall.fromJson(Map json) { 12 | return Unpaywall( 13 | doiUrl: json['doi_url'] ?? '', 14 | pdfUrl: json['best_oa_location']?['url_for_pdf'] ?? '', 15 | ); 16 | } 17 | } 18 | 19 | class UnpaywallService { 20 | static Future checkAvailability(String doi) async { 21 | final logger = LogsService().logger; 22 | 23 | try { 24 | final response = await http.get(Uri.parse( 25 | 'https://api.unpaywall.org/v2/$doi?email=wispar-app@protonmail.com')); 26 | 27 | if (response.statusCode == 200) { 28 | final Map jsonResponse = json.decode(response.body); 29 | 30 | return Unpaywall.fromJson(jsonResponse); 31 | } else { 32 | return Unpaywall.fromJson({}); 33 | } 34 | } catch (e, stackTrace) { 35 | logger.severe('Error querying Unpaywall for DOI: ${doi}.', e, stackTrace); 36 | return Unpaywall.fromJson({}); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/theme_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:shared_preferences/shared_preferences.dart'; 3 | 4 | class ThemeProvider extends ChangeNotifier { 5 | ThemeMode _themeMode = ThemeMode.system; 6 | 7 | ThemeMode get themeMode => _themeMode; 8 | 9 | // SharedPreferences key for storing the theme preference 10 | static const String _themeKey = 'theme_preference'; 11 | 12 | // Constructor to load the theme preference on initialization 13 | ThemeProvider() { 14 | _loadThemeMode(); 15 | } 16 | 17 | // Load the theme preference from SharedPreferences 18 | Future _loadThemeMode() async { 19 | final prefs = await SharedPreferences.getInstance(); 20 | final savedThemeMode = prefs.getString(_themeKey); 21 | 22 | if (savedThemeMode != null) { 23 | _themeMode = ThemeMode.values.firstWhere( 24 | (mode) => mode.toString() == savedThemeMode, 25 | orElse: () => ThemeMode.system, 26 | ); 27 | notifyListeners(); 28 | } 29 | } 30 | 31 | // Save the theme preference to SharedPreferences 32 | Future _saveThemeMode() async { 33 | final prefs = await SharedPreferences.getInstance(); 34 | prefs.setString(_themeKey, _themeMode.toString()); 35 | } 36 | 37 | // Set the theme mode and save it 38 | void setThemeMode(ThemeMode mode) { 39 | _themeMode = mode; 40 | notifyListeners(); 41 | _saveThemeMode(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/widgets/article_crossref_search_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import 'article_doi_search_form.dart'; 4 | import 'article_query_search_form.dart'; 5 | import '../services/crossref_api.dart'; 6 | import '../screens/article_screen.dart'; 7 | import '../services/logs_helper.dart'; 8 | 9 | class CrossRefSearchForm extends StatefulWidget { 10 | @override 11 | _CrossRefSearchFormState createState() => _CrossRefSearchFormState(); 12 | } 13 | 14 | class _CrossRefSearchFormState extends State { 15 | final logger = LogsService().logger; 16 | int selectedSearchIndex = 0; // 0 for Query, 1 for DOI 17 | final TextEditingController doiController = TextEditingController(); 18 | final GlobalKey _queryFormKey = 19 | GlobalKey(); // GlobalKey for QuerySearchForm 20 | 21 | @override 22 | void dispose() { 23 | doiController.dispose(); 24 | super.dispose(); 25 | } 26 | 27 | void _handleSearch() async { 28 | try { 29 | showDialog( 30 | context: context, 31 | barrierDismissible: false, 32 | builder: (BuildContext context) { 33 | return Center( 34 | child: CircularProgressIndicator(), 35 | ); 36 | }, 37 | ); 38 | if (selectedSearchIndex == 0) { 39 | // Query search 40 | if (_queryFormKey.currentState != null) { 41 | _queryFormKey.currentState! 42 | .submitForm(); // Call the search function in QuerySearchForm 43 | } else {} 44 | Navigator.pop(context); 45 | } else { 46 | // DOI-based search 47 | String doi = doiController.text.trim(); 48 | 49 | if (doi.isEmpty) { 50 | ScaffoldMessenger.of(context).showSnackBar( 51 | SnackBar( 52 | content: Text(AppLocalizations.of(context)!.emptyDOIError)), 53 | ); 54 | return; 55 | } 56 | try { 57 | String extractedDoi; 58 | if (doi.startsWith('https://doi.org/')) { 59 | extractedDoi = doi.replaceFirst('https://doi.org/', ''); 60 | } else { 61 | extractedDoi = doi; 62 | } 63 | final article = await CrossRefApi.getWorkByDOI(extractedDoi); 64 | 65 | // Dismiss the loading dialog 66 | Navigator.pop(context); 67 | Navigator.push( 68 | context, 69 | MaterialPageRoute( 70 | builder: (context) => ArticleScreen( 71 | doi: article.doi, 72 | title: article.title, 73 | issn: article.issn, 74 | abstract: article.abstract, 75 | journalTitle: article.journalTitle, 76 | publishedDate: article.publishedDate, 77 | authors: article.authors, 78 | url: article.url, 79 | license: article.license, 80 | licenseName: article.licenseName, 81 | publisher: article.publisher, 82 | ), 83 | ), 84 | ); 85 | } catch (e, stackTrace) { 86 | logger.severe( 87 | 'Error searching by DOI for DOI ${doi}.', e, stackTrace); 88 | Navigator.pop(context); // Close loading dialog 89 | ScaffoldMessenger.of(context).showSnackBar( 90 | SnackBar(content: Text(AppLocalizations.of(context)!.errorOccured)), 91 | ); 92 | } 93 | } 94 | } catch (e, stackTrace) { 95 | logger.severe('Error searching articles using ${selectedSearchIndex}.', e, 96 | stackTrace); 97 | Navigator.pop(context); // Close loading dialog 98 | ScaffoldMessenger.of(context).showSnackBar( 99 | SnackBar(content: Text(AppLocalizations.of(context)!.errorOccured)), 100 | ); 101 | } 102 | } 103 | 104 | @override 105 | Widget build(BuildContext context) { 106 | Widget searchForm = selectedSearchIndex == 0 107 | ? QuerySearchForm(key: _queryFormKey) 108 | : DOISearchForm(doiController: doiController); 109 | 110 | return Scaffold( 111 | body: SingleChildScrollView( 112 | padding: const EdgeInsets.all(16.0), 113 | child: Column( 114 | crossAxisAlignment: CrossAxisAlignment.start, 115 | children: [ 116 | SizedBox(height: 8), 117 | Center( 118 | child: LayoutBuilder( 119 | builder: (context, constraints) { 120 | return ToggleButtons( 121 | children: [ 122 | Container( 123 | width: constraints.maxWidth / 2 - 1.5, 124 | alignment: Alignment.center, 125 | child: 126 | Text(AppLocalizations.of(context)!.searchByQuery), 127 | ), 128 | Container( 129 | width: constraints.maxWidth / 2 - 1.5, 130 | alignment: Alignment.center, 131 | child: Text(AppLocalizations.of(context)!.searchByDOI), 132 | ), 133 | ], 134 | isSelected: [ 135 | selectedSearchIndex == 0, 136 | selectedSearchIndex == 1, 137 | ], 138 | onPressed: (int index) { 139 | setState(() { 140 | selectedSearchIndex = index; 141 | }); 142 | }, 143 | borderRadius: BorderRadius.circular(8.0), 144 | ); 145 | }, 146 | ), 147 | ), 148 | SizedBox(height: 16), 149 | // Display the selected search form here 150 | searchForm, 151 | SizedBox(height: 16), 152 | ], 153 | ), 154 | ), 155 | floatingActionButton: FloatingActionButton( 156 | onPressed: _handleSearch, 157 | child: Icon(Icons.search), 158 | shape: CircleBorder(), 159 | ), 160 | ); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /lib/widgets/article_doi_search_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DOISearchForm extends StatelessWidget { 4 | final TextEditingController doiController; 5 | 6 | DOISearchForm({required this.doiController}); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Column( 11 | crossAxisAlignment: CrossAxisAlignment.start, 12 | children: [ 13 | TextField( 14 | controller: doiController, 15 | decoration: InputDecoration( 16 | labelText: 'DOI', 17 | border: OutlineInputBorder(), 18 | ), 19 | ), 20 | ], 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/widgets/article_search_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import './article_crossref_search_form.dart'; 3 | import './article_openAlex_search_form.dart'; 4 | 5 | class ArticleSearchScreen extends StatefulWidget { 6 | @override 7 | _ArticleSearchScreenState createState() => _ArticleSearchScreenState(); 8 | } 9 | 10 | class _ArticleSearchScreenState extends State { 11 | int selectedProviderIndex = 0; // 0 = OpenAlex, 1 = Crossref 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Column( 16 | children: [ 17 | ToggleButtons( 18 | borderRadius: BorderRadius.circular(8.0), 19 | isSelected: [selectedProviderIndex == 0, selectedProviderIndex == 1], 20 | onPressed: (int index) { 21 | setState(() { 22 | selectedProviderIndex = index; 23 | }); 24 | }, 25 | children: [ 26 | Padding( 27 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 28 | child: Text('OpenAlex'), 29 | ), 30 | Padding( 31 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 32 | child: Text('Crossref'), 33 | ), 34 | ], 35 | ), 36 | SizedBox(height: 20), 37 | Expanded( 38 | child: selectedProviderIndex == 0 39 | ? OpenAlexSearchForm() 40 | : CrossRefSearchForm(), 41 | ), 42 | ], 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/widgets/author_search_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class AuthorSearchForm extends StatefulWidget { 4 | @override 5 | _AuthorSearchFormState createState() => _AuthorSearchFormState(); 6 | } 7 | 8 | class _AuthorSearchFormState extends State { 9 | bool saveQuery = false; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Column( 14 | crossAxisAlignment: CrossAxisAlignment.start, 15 | children: [ 16 | Text("Search Authors"), 17 | TextField( 18 | decoration: InputDecoration( 19 | labelText: 'Author Name', 20 | border: OutlineInputBorder(), 21 | ), 22 | ), 23 | SizedBox(height: 16), 24 | Text('Save this query', style: TextStyle(fontWeight: FontWeight.bold)), 25 | Switch( 26 | value: saveQuery, 27 | onChanged: (bool value) { 28 | setState(() { 29 | saveQuery = value; 30 | }); 31 | }, 32 | ), 33 | SizedBox(height: 16), 34 | Center( 35 | child: ElevatedButton( 36 | onPressed: () { 37 | print("Search for Authors"); 38 | }, 39 | child: Text('Search'), 40 | ), 41 | ), 42 | ], 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/widgets/downloaded_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../screens/pdf_reader.dart'; 3 | import '../generated_l10n/app_localizations.dart'; 4 | import '../screens/journals_details_screen.dart'; 5 | import 'publication_card.dart'; 6 | import '../services/database_helper.dart'; 7 | import '../services/string_format_helper.dart'; 8 | import 'package:latext/latext.dart'; 9 | 10 | class DownloadedCard extends StatefulWidget { 11 | final pdfPath; 12 | final PublicationCard publicationCard; 13 | final VoidCallback onDelete; 14 | 15 | const DownloadedCard({ 16 | Key? key, 17 | required this.pdfPath, 18 | required this.publicationCard, 19 | required this.onDelete, 20 | }) : super(key: key); 21 | 22 | @override 23 | _DownloadedCardState createState() => _DownloadedCardState(); 24 | } 25 | 26 | class _DownloadedCardState extends State { 27 | bool isLiked = false; 28 | late DatabaseHelper databaseHelper; 29 | 30 | @override 31 | void initState() { 32 | super.initState(); 33 | databaseHelper = DatabaseHelper(); 34 | } 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | return GestureDetector( 39 | onTap: () { 40 | // Navigate to the pdfReader when the card is tapped 41 | Navigator.of(context).push(MaterialPageRoute( 42 | builder: (context) => PdfReader( 43 | pdfUrl: widget.pdfPath, 44 | publicationCard: widget.publicationCard, 45 | ), 46 | )); 47 | }, 48 | child: Card( 49 | elevation: 2.0, 50 | margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), 51 | child: ListTile( 52 | contentPadding: EdgeInsets.all(16.0), 53 | title: Column( 54 | crossAxisAlignment: CrossAxisAlignment.stretch, 55 | children: [ 56 | Row( 57 | mainAxisSize: MainAxisSize.max, 58 | children: [ 59 | Flexible( 60 | flex: 4, 61 | child: Align( 62 | alignment: Alignment.centerLeft, 63 | child: TextButton( 64 | onPressed: () async { 65 | Map? journalInfo = 66 | await getJournalDetails( 67 | widget.publicationCard.issn); 68 | 69 | if (journalInfo != null) { 70 | String journalPublisher = journalInfo['publisher']; 71 | Navigator.push( 72 | context, 73 | MaterialPageRoute( 74 | builder: (context) => JournalDetailsScreen( 75 | title: widget.publicationCard.journalTitle, 76 | publisher: journalPublisher, 77 | issn: widget.publicationCard.issn, 78 | ), 79 | ), 80 | ); 81 | } 82 | }, 83 | child: Text( 84 | widget.publicationCard.journalTitle, 85 | style: TextStyle(fontSize: 16), 86 | softWrap: true, 87 | ), 88 | style: TextButton.styleFrom( 89 | minimumSize: Size.zero, 90 | padding: EdgeInsets.zero, 91 | tapTargetSize: MaterialTapTargetSize.shrinkWrap, 92 | ), 93 | ), 94 | ), 95 | ), 96 | IconButton( 97 | onPressed: () async { 98 | await databaseHelper 99 | .removeDownloaded(widget.publicationCard.doi); 100 | ScaffoldMessenger.of(context).showSnackBar( 101 | SnackBar( 102 | content: Text( 103 | AppLocalizations.of(context)!.downloadDeleted), 104 | ), 105 | ); 106 | widget.onDelete(); 107 | }, 108 | icon: Icon(Icons.delete_outline), 109 | ), 110 | ], 111 | ), 112 | Text( 113 | formatDate(widget.publicationCard.publishedDate!), 114 | style: TextStyle( 115 | color: Colors.grey, 116 | fontSize: 13, 117 | ), 118 | ), 119 | SizedBox(height: 8.0), 120 | LaTexT( 121 | breakDelimiter: r'\nl', 122 | laTeXCode: Text( 123 | widget.publicationCard.title, 124 | style: TextStyle(fontWeight: FontWeight.bold), 125 | ), 126 | ), 127 | SizedBox(height: 5.0), 128 | ], 129 | ), 130 | subtitle: 131 | Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 132 | SizedBox( 133 | height: 8, 134 | ), 135 | Row( 136 | mainAxisSize: MainAxisSize.max, 137 | children: [ 138 | Expanded( 139 | child: Column( 140 | crossAxisAlignment: CrossAxisAlignment.start, 141 | children: [], 142 | )), 143 | ], 144 | ), 145 | ]), 146 | ), 147 | ), 148 | ); 149 | } 150 | 151 | Future?> getJournalDetails(List issn) async { 152 | final db = await databaseHelper.database; 153 | final id = await databaseHelper.getJournalIdByIssns(issn); 154 | final List> rows = await db.query( 155 | 'journals', 156 | columns: ['publisher'], 157 | where: 'journal_id = ?', 158 | whereArgs: [id], 159 | ); 160 | 161 | return rows.isNotEmpty ? rows.first : null; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /lib/widgets/journal_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import '../generated_l10n/app_localizations.dart'; 4 | import '../screens/journals_details_screen.dart'; 5 | import '../models/journal_entity.dart'; 6 | 7 | class JournalCard extends StatelessWidget { 8 | final Journal journal; 9 | final Function(BuildContext, Journal) unfollowCallback; 10 | 11 | const JournalCard({required this.journal, required this.unfollowCallback}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | final lastUpdatedText = journal.lastUpdated != null 16 | ? () { 17 | final updated = DateTime.parse(journal.lastUpdated!); 18 | final now = DateTime.now(); 19 | final diff = now.difference(updated); 20 | 21 | if (diff.inMinutes < 60) { 22 | return AppLocalizations.of(context)! 23 | .lastUpdatedMinutes(diff.inMinutes); 24 | } else if (diff.inHours < 24) { 25 | return AppLocalizations.of(context)! 26 | .lastUpdatedHours(diff.inHours); 27 | } else { 28 | return AppLocalizations.of(context)!.lastUpdatedDays(diff.inDays); 29 | } 30 | }() 31 | : AppLocalizations.of(context)!.pendingUpdate; 32 | return Card( 33 | margin: EdgeInsets.all(8.0), 34 | child: ListTile( 35 | onTap: () { 36 | Navigator.push( 37 | context, 38 | MaterialPageRoute( 39 | builder: (context) => JournalDetailsScreen( 40 | title: journal.title, 41 | publisher: journal.publisher, 42 | issn: journal.issn, 43 | ), 44 | ), 45 | ); 46 | }, 47 | onLongPress: () { 48 | Clipboard.setData( 49 | ClipboardData(text: journal.issn.toSet().join(', '))); 50 | ScaffoldMessenger.of(context).showSnackBar( 51 | SnackBar(content: Text(AppLocalizations.of(context)!.issnCopied)), 52 | ); 53 | }, 54 | title: Row( 55 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 56 | children: [ 57 | Expanded( 58 | child: Text( 59 | journal.title, 60 | style: TextStyle(fontWeight: FontWeight.bold), 61 | overflow: TextOverflow.ellipsis, 62 | ), 63 | ), 64 | TextButton( 65 | onPressed: () { 66 | // Perform the unfollow action 67 | unfollowCallback(context, journal); 68 | }, 69 | child: Text(AppLocalizations.of(context)!.unfollow), 70 | ), 71 | ], 72 | ), 73 | subtitle: Column( 74 | crossAxisAlignment: CrossAxisAlignment.start, 75 | children: [ 76 | Text( 77 | '${AppLocalizations.of(context)!.publisher}: ${journal.publisher}'), 78 | Text('ISSN: ${journal.issn.toSet().join(', ')}'), 79 | Text(AppLocalizations.of(context)! 80 | .followingsince(DateTime.parse(journal.dateFollowed!))), 81 | Text(lastUpdatedText), 82 | ], 83 | ), 84 | ), 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/widgets/journal_follow_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../models/crossref_journals_models.dart' as Journals; 4 | import '../services/database_helper.dart'; 5 | import '../models/journal_entity.dart'; 6 | 7 | enum ButtonType { text, outlined } 8 | 9 | class FollowButton extends StatelessWidget { 10 | final Journals.Item item; 11 | final bool isFollowed; 12 | final Function(bool) onFollowStatusChanged; 13 | final ButtonType buttonType; 14 | 15 | const FollowButton({ 16 | Key? key, 17 | required this.item, 18 | required this.isFollowed, 19 | required this.onFollowStatusChanged, 20 | this.buttonType = ButtonType.text, 21 | }) : super(key: key); 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | Widget button; 26 | 27 | if (buttonType == ButtonType.text) { 28 | button = TextButton( 29 | onPressed: () { 30 | toggleFollowStatus(context); 31 | }, 32 | child: Text(isFollowed 33 | ? AppLocalizations.of(context)!.unfollow 34 | : AppLocalizations.of(context)!.follow), 35 | ); 36 | } else { 37 | button = OutlinedButton( 38 | onPressed: () { 39 | toggleFollowStatus(context); 40 | }, 41 | child: Text(isFollowed 42 | ? AppLocalizations.of(context)!.unfollow 43 | : AppLocalizations.of(context)!.follow), 44 | ); 45 | } 46 | 47 | return button; 48 | } 49 | 50 | void toggleFollowStatus(BuildContext context) async { 51 | final dbHelper = DatabaseHelper(); 52 | int? journalId = await dbHelper.getJournalIdByIssns(item.issn); 53 | bool currentlyFollowed = false; 54 | if (journalId != null) { 55 | // Check if the journal is currently followed 56 | currentlyFollowed = await dbHelper.isJournalFollowed(journalId); 57 | } 58 | 59 | if (currentlyFollowed) { 60 | // Unfollow 61 | await dbHelper.removeJournal(item.issn); 62 | } else { 63 | // Follow 64 | await dbHelper.insertJournal( 65 | Journal( 66 | issn: item.issn, 67 | title: item.title, 68 | publisher: item.publisher, 69 | ), 70 | ); 71 | } 72 | 73 | //await dbHelper.clearCachedPublications(); 74 | onFollowStatusChanged(!currentlyFollowed); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/widgets/journal_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../models/crossref_journals_models.dart' as Journals; 4 | import './journal_follow_button.dart'; 5 | 6 | class JournalInfoHeader extends SliverPersistentHeaderDelegate { 7 | final String title; 8 | final String publisher; 9 | final String issn; 10 | final bool isFollowed; 11 | final Function(bool) onFollowStatusChanged; 12 | 13 | JournalInfoHeader({ 14 | required this.title, 15 | required this.publisher, 16 | required this.issn, 17 | required this.isFollowed, 18 | required this.onFollowStatusChanged, 19 | }); 20 | 21 | @override 22 | double get maxExtent => 120.0; 23 | 24 | @override 25 | double get minExtent => 60.0; 26 | 27 | @override 28 | Widget build( 29 | BuildContext context, double shrinkOffset, bool overlapsContent) { 30 | // a little bit hacky, but oh well :( 31 | final journalItem = Journals.Item( 32 | lastStatusCheckTime: 0, 33 | counts: Journals.Counts(currentDois: 0, backfileDois: 0, totalDois: 0), 34 | breakdowns: Journals.Breakdowns(doisByIssuedYear: [ 35 | [0] 36 | ]), 37 | publisher: publisher, 38 | coverage: {}, 39 | title: title, 40 | coverageType: Journals.CoverageType(all: {}, backfile: {}, current: {}), 41 | flags: {}, 42 | issn: issn.split(','), 43 | issnType: [], 44 | ); 45 | 46 | return SingleChildScrollView( 47 | child: Container( 48 | width: double.infinity, 49 | padding: EdgeInsets.symmetric(horizontal: 15.0), 50 | child: Column( 51 | crossAxisAlignment: CrossAxisAlignment.start, 52 | children: [ 53 | Row( 54 | children: [ 55 | Expanded( 56 | child: Column( 57 | crossAxisAlignment: CrossAxisAlignment.start, 58 | children: [ 59 | SizedBox(height: 8.0), 60 | Text( 61 | '${AppLocalizations.of(context)!.publisher}: ${publisher}'), 62 | Text('ISSN: ${issn}'), 63 | SizedBox(height: 8.0), 64 | ], 65 | ), 66 | ), 67 | title.isNotEmpty && publisher.isNotEmpty && issn.isNotEmpty 68 | ? FollowButton( 69 | item: journalItem, 70 | isFollowed: isFollowed, 71 | onFollowStatusChanged: onFollowStatusChanged, 72 | buttonType: ButtonType.outlined, 73 | ) 74 | : SizedBox.shrink(), 75 | ], 76 | ), 77 | SizedBox(height: 8.0), 78 | Container( 79 | height: 1, 80 | margin: EdgeInsets.symmetric(horizontal: 60), 81 | color: Colors.grey, 82 | ), 83 | ], 84 | ), 85 | ), 86 | ); 87 | } 88 | 89 | @override 90 | bool shouldRebuild(covariant JournalInfoHeader oldDelegate) { 91 | return oldDelegate.isFollowed != isFollowed || 92 | oldDelegate.issn != issn || 93 | oldDelegate.publisher != publisher; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/widgets/journal_search_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../screens/journals_search_results_screen.dart'; 4 | import '../services/crossref_api.dart'; 5 | import '../models/crossref_journals_models.dart' as Journals; 6 | import '../services/logs_helper.dart'; 7 | 8 | class JournalSearchForm extends StatefulWidget { 9 | @override 10 | _JournalSearchFormState createState() => _JournalSearchFormState(); 11 | } 12 | 13 | class _JournalSearchFormState extends State { 14 | final logger = LogsService().logger; 15 | bool saveQuery = false; 16 | int selectedSearchIndex = 0; // 0 for 'name', 1 for 'issn' 17 | late Journals.Item selectedJournal; 18 | TextEditingController _searchController = TextEditingController(); 19 | 20 | @override 21 | void initState() { 22 | super.initState(); 23 | 24 | _searchController.addListener(() { 25 | if (selectedSearchIndex == 1) { 26 | /*String text = _searchController.text; 27 | 28 | // Limit input to 9 characters 29 | if (text.length > 9) { 30 | _searchController.value = TextEditingValue( 31 | text: text.substring(0, 9), 32 | selection: TextSelection.collapsed(offset: 9), 33 | ); 34 | return; 35 | } 36 | 37 | // Automatically add a dash after the first 4 digits 38 | if (text.length == 4 && !text.contains('-')) { 39 | _searchController.value = TextEditingValue( 40 | text: '${text}-', 41 | selection: TextSelection.collapsed(offset: text.length + 1), 42 | ); 43 | }*/ 44 | } 45 | }); 46 | } 47 | 48 | @override 49 | void dispose() { 50 | _searchController.dispose(); 51 | super.dispose(); 52 | } 53 | 54 | @override 55 | Widget build(BuildContext context) { 56 | return Scaffold( 57 | body: Padding( 58 | padding: const EdgeInsets.all(16.0), 59 | child: Column( 60 | crossAxisAlignment: CrossAxisAlignment.start, 61 | children: [ 62 | SizedBox(height: 16), 63 | Center( 64 | child: LayoutBuilder( 65 | builder: (context, constraints) { 66 | return ToggleButtons( 67 | isSelected: [ 68 | selectedSearchIndex == 0, 69 | selectedSearchIndex == 1, 70 | ], 71 | onPressed: (int index) { 72 | setState(() { 73 | selectedSearchIndex = index; 74 | _searchController.clear(); 75 | }); 76 | }, 77 | children: [ 78 | Container( 79 | width: constraints.maxWidth / 2 - 1.5, 80 | alignment: Alignment.center, 81 | child: 82 | Text(AppLocalizations.of(context)!.searchByTitle), 83 | ), 84 | Container( 85 | width: constraints.maxWidth / 2 - 1.5, 86 | alignment: Alignment.center, 87 | child: Text(AppLocalizations.of(context)!.searchByISSN), 88 | ), 89 | ], 90 | borderRadius: BorderRadius.circular(8.0), 91 | ); 92 | }, 93 | ), 94 | ), 95 | SizedBox(height: 32), 96 | TextField( 97 | controller: _searchController, 98 | decoration: InputDecoration( 99 | labelText: selectedSearchIndex == 0 100 | ? AppLocalizations.of(context)!.journaltitle 101 | : 'ISSN', 102 | border: OutlineInputBorder(), 103 | ), 104 | ), 105 | SizedBox(height: 16), 106 | ], 107 | ), 108 | ), 109 | floatingActionButton: FloatingActionButton( 110 | onPressed: () { 111 | String query = _searchController.text.trim(); 112 | if (query.isNotEmpty) { 113 | _handleSearch(query); 114 | } else { 115 | ScaffoldMessenger.of(context).showSnackBar( 116 | SnackBar( 117 | content: 118 | Text(AppLocalizations.of(context)!.emptySearchQuery)), 119 | ); 120 | } 121 | }, 122 | child: Icon(Icons.search), 123 | shape: CircleBorder(), 124 | ), 125 | ); 126 | } 127 | 128 | void _handleSearch(String query) async { 129 | try { 130 | showDialog( 131 | context: context, 132 | barrierDismissible: false, 133 | builder: (BuildContext context) { 134 | return Center( 135 | child: CircularProgressIndicator(), 136 | ); 137 | }, 138 | ); 139 | 140 | CrossRefApi.resetJournalCursor(); 141 | 142 | ListAndMore searchResults; 143 | if (selectedSearchIndex == 0) { 144 | searchResults = await CrossRefApi.queryJournalsByName(query); 145 | } else if (selectedSearchIndex == 1) { 146 | searchResults = await CrossRefApi.queryJournalsByISSN(query); 147 | } else { 148 | throw Exception('Invalid search type selected'); 149 | } 150 | 151 | Navigator.pop(context); 152 | 153 | Navigator.push( 154 | context, 155 | MaterialPageRoute( 156 | builder: (context) => SearchResultsScreen( 157 | searchResults: searchResults, 158 | searchQuery: query, 159 | ), 160 | ), 161 | ); 162 | } catch (e, stackTrace) { 163 | logger.severe("Unable to search for journals.", e, stackTrace); 164 | ScaffoldMessenger.of(context).showSnackBar( 165 | SnackBar( 166 | content: Text(AppLocalizations.of(context)!.journalSearchError)), 167 | ); 168 | Navigator.pop(context); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lib/widgets/journal_search_results_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | import '../models/crossref_journals_models.dart' as Journals; 4 | import '../services/database_helper.dart'; 5 | import '../screens/journals_details_screen.dart'; 6 | import './journal_follow_button.dart'; 7 | 8 | class JournalsSearchResultCard extends StatefulWidget { 9 | final Journals.Item item; 10 | final bool isFollowed; 11 | 12 | const JournalsSearchResultCard( 13 | {Key? key, required this.item, required this.isFollowed}) 14 | : super(key: key); 15 | 16 | @override 17 | _JournalsSearchResultCardState createState() => 18 | _JournalsSearchResultCardState(); 19 | } 20 | 21 | class _JournalsSearchResultCardState extends State { 22 | late bool _isFollowed = false; // Initialize with false by default 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | _initFollowStatus(); 28 | } 29 | 30 | Future _initFollowStatus() async { 31 | final dbHelper = DatabaseHelper(); 32 | int? journalId = await dbHelper.getJournalIdByIssns(widget.item.issn); 33 | bool isFollowed = false; 34 | if (journalId != null) { 35 | isFollowed = await dbHelper.isJournalFollowed(journalId); 36 | } 37 | 38 | if (mounted) { 39 | setState(() { 40 | _isFollowed = isFollowed; 41 | }); 42 | } 43 | } 44 | 45 | @override 46 | Widget build(BuildContext context) { 47 | return Card( 48 | margin: EdgeInsets.all(8.0), 49 | child: ListTile( 50 | title: Text( 51 | widget.item.title, 52 | style: TextStyle( 53 | fontWeight: FontWeight.bold, 54 | ), 55 | ), 56 | subtitle: Column( 57 | crossAxisAlignment: CrossAxisAlignment.start, 58 | children: [ 59 | Text( 60 | '${AppLocalizations.of(context)!.publisher}: ${widget.item.publisher}'), 61 | if (widget.item.issn.isNotEmpty) 62 | Text('ISSN: ${widget.item.issn.toSet().join(', ')}'), 63 | ], 64 | ), 65 | trailing: FollowButton( 66 | item: widget.item, 67 | isFollowed: _isFollowed, 68 | onFollowStatusChanged: (isFollowed) { 69 | // Handle follow status changes 70 | setState(() { 71 | _isFollowed = isFollowed; 72 | }); 73 | }, 74 | ), 75 | onTap: () { 76 | // Navigate to the detailed screen when the card is tapped 77 | Navigator.push( 78 | context, 79 | MaterialPageRoute( 80 | builder: (context) => JournalDetailsScreen( 81 | title: widget.item.title, 82 | publisher: widget.item.publisher, 83 | issn: widget.item.issn, 84 | onFollowStatusChanged: (isFollowed) { 85 | setState(() { 86 | _isFollowed = isFollowed; 87 | }); 88 | }, 89 | ), 90 | ), 91 | ); 92 | }, 93 | ), 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/widgets/latest_works_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | 4 | class PersistentLatestPublicationsHeader 5 | extends SliverPersistentHeaderDelegate { 6 | @override 7 | double get maxExtent => 40.0; 8 | 9 | @override 10 | double get minExtent => 40.0; 11 | 12 | @override 13 | Widget build( 14 | BuildContext context, double shrinkOffset, bool overlapsContent) { 15 | return Container( 16 | color: Theme.of(context).scaffoldBackgroundColor, 17 | child: Center( 18 | child: Text( 19 | AppLocalizations.of(context)!.latestpublications, 20 | style: TextStyle( 21 | fontWeight: FontWeight.bold, 22 | ), 23 | ), 24 | ), 25 | ); 26 | } 27 | 28 | @override 29 | bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) { 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/widgets/sortbydialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | 4 | class SortByDialog extends StatefulWidget { 5 | final int initialSortBy; 6 | final ValueChanged onSortByChanged; 7 | final List sortOptions; 8 | 9 | SortByDialog({ 10 | required this.initialSortBy, 11 | required this.onSortByChanged, 12 | required this.sortOptions, 13 | }); 14 | 15 | @override 16 | _SortByDialogState createState() => _SortByDialogState(); 17 | } 18 | 19 | class _SortByDialogState extends State { 20 | late int selectedSortBy; 21 | 22 | @override 23 | void initState() { 24 | super.initState(); 25 | selectedSortBy = widget.initialSortBy; 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return AlertDialog( 31 | title: Text(AppLocalizations.of(context)!.sortby), 32 | content: SingleChildScrollView( 33 | child: Column( 34 | children: widget.sortOptions.asMap().entries.map((entry) { 35 | int index = entry.key; 36 | String sortOption = entry.value; 37 | 38 | return RadioListTile( 39 | value: index, 40 | groupValue: selectedSortBy, 41 | onChanged: (int? value) { 42 | if (value != null) { 43 | setState(() { 44 | selectedSortBy = value; 45 | widget.onSortByChanged(selectedSortBy); 46 | }); 47 | Navigator.pop(context); // Close the dialog 48 | } 49 | }, 50 | title: Text(sortOption), 51 | ); 52 | }).toList(), 53 | ), 54 | ), 55 | ); 56 | } 57 | } 58 | 59 | // Utility function to show the SortByDialog 60 | Future showSortByDialog({ 61 | required BuildContext context, 62 | required int initialSortBy, 63 | required ValueChanged onSortByChanged, 64 | required List sortOptions, 65 | }) { 66 | return showDialog( 67 | context: context, 68 | builder: (BuildContext context) { 69 | return SortByDialog( 70 | initialSortBy: initialSortBy, 71 | onSortByChanged: onSortByChanged, 72 | sortOptions: sortOptions, 73 | ); 74 | }, 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /lib/widgets/sortorderdialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import '../generated_l10n/app_localizations.dart'; 3 | 4 | class SortOrderDialog extends StatelessWidget { 5 | final int initialSortOrder; 6 | final ValueChanged onSortOrderChanged; 7 | final List sortOrderOptions; 8 | 9 | SortOrderDialog({ 10 | required this.initialSortOrder, 11 | required this.onSortOrderChanged, 12 | required this.sortOrderOptions, 13 | }); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return AlertDialog( 18 | title: Text(AppLocalizations.of(context)!.sortorder), 19 | content: SingleChildScrollView( 20 | child: Column( 21 | children: List.generate( 22 | sortOrderOptions.length, 23 | (index) { 24 | return RadioListTile( 25 | value: index, 26 | groupValue: initialSortOrder, 27 | onChanged: (int? value) { 28 | if (value != null) { 29 | onSortOrderChanged(value); 30 | Navigator.pop(context); 31 | } 32 | }, 33 | title: Text(sortOrderOptions[index]), 34 | ); 35 | }, 36 | ), 37 | ), 38 | ), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /macos/Podfile: -------------------------------------------------------------------------------- 1 | platform :osx, '10.14' 2 | 3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 5 | 6 | project 'Runner', { 7 | 'Debug' => :debug, 8 | 'Profile' => :release, 9 | 'Release' => :release, 10 | } 11 | 12 | def flutter_root 13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) 14 | unless File.exist?(generated_xcode_build_settings_path) 15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" 16 | end 17 | 18 | File.foreach(generated_xcode_build_settings_path) do |line| 19 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 20 | return matches[1].strip if matches 21 | end 22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" 23 | end 24 | 25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 26 | 27 | flutter_macos_podfile_setup 28 | 29 | target 'Runner' do 30 | use_frameworks! 31 | use_modular_headers! 32 | 33 | flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) 34 | target 'RunnerTests' do 35 | inherit! :search_paths 36 | end 37 | end 38 | 39 | post_install do |installer| 40 | installer.pods_project.targets.each do |target| 41 | flutter_additional_macos_build_settings(target) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Wispar is your personal research companion — a privacy-friendly app for exploring academic journals and scientific articles. 2 | 3 | Using the Crossref and OpenAlex APIs, Wispar helps you stay updated on your favourite journals. Follow journals to receive the latest research abstracts in your main feed — no account required. 4 | 5 | - Search scholarly articles and journals 6 | - Follow journals to stay updated 7 | - Add custom keywords and queries to your feed 8 | - Access open-access articles via Unpaywall 9 | - Use EZproxy to unlock paywalled research 10 | - Send articles to your Zotero library 11 | - Built for researchers, students, and the academically curious 12 | 13 | Wispar is user-friendly, open-source, and designed to respect your privacy. 14 | 15 | As a community-built project, contributions are always welcome! If you'd like to contribute, please visit our GitHub repository: https://github.com/Scriptbash/Wispar 16 | 17 | If the app isn't available in your language, please consider helping with translations by visiting our hosted Weblate instance: https://hosted.weblate.org/engage/wispar -------------------------------------------------------------------------------- /metadata/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/1_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/2_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/3_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/4_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/4_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/5_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/5_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/6_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/6_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/7_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/phoneScreenshots/7_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/1_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/1_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/2_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/2_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/3_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/3_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/4_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/4_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/5_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/5_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/6_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/6_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/images/tenInchScreenshots/7_en-US.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scriptbash/Wispar/8fd34601c20731cdcb484b752c17090cc201ea35/metadata/en-US/images/tenInchScreenshots/7_en-US.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Stay up-to-date with academic journals and the latest research articles! -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: wispar 2 | description: "Stay up-to-date with articles in your field of study!" 3 | # The following line prevents the package from being accidentally published to 4 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 6 | 7 | # The following defines the version and build number for your application. 8 | # A version number is three numbers separated by dots, like 1.2.43 9 | # followed by an optional build number separated by a +. 10 | # Both the version and the builder number may be overridden in flutter 11 | # build by specifying --build-name and --build-number, respectively. 12 | # In Android, build-name is used as versionName while build-number used as versionCode. 13 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 14 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 | # Read more about iOS versioning at 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 | # In Windows, build-name is used as the major, minor, and patch parts 18 | # of the product and file versions while build-number is used as the build suffix. 19 | version: 0.4.1+22 20 | 21 | environment: 22 | sdk: '>=3.2.3 <4.0.0' 23 | flutter: '3.32.2' 24 | 25 | # Dependencies specify other packages that your package needs in order to work. 26 | # To automatically upgrade your package dependencies to the latest versions 27 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 28 | # dependencies can be manually updated by changing the version numbers below to 29 | # the latest version available on pub.dev. To see which dependencies have newer 30 | # versions available, run `flutter pub outdated`. 31 | dependencies: 32 | flutter: 33 | sdk: flutter 34 | 35 | 36 | # The following adds the Cupertino Icons font to your application. 37 | # Use with the CupertinoIcons class for iOS style icons. 38 | cupertino_icons: ^1.0.8 39 | shared_preferences: ^2.3.3 40 | provider: ^6.1.2 41 | json_annotation: ^4.9.0 42 | sqflite: ^2.4.1 43 | flutter_localizations: 44 | sdk: flutter 45 | intl: ^0.20.2 46 | flutter_inappwebview: ^6.1.5 47 | url_launcher: ^6.3.1 48 | http: ^1.2.2 49 | pdfrx: ^1.0.88 50 | package_info_plus: ^8.1.1 51 | share_plus: ^11.0.0 52 | introduction_screen: ^3.1.14 53 | open_filex: ^4.7.0 54 | google_nav_bar: ^5.0.7 55 | file_picker: ^10.0.0 56 | flutter_local_notifications: ^19.1.0 57 | permission_handler: ^12.0.0+1 58 | latext: ^0.5.0 59 | html: ^0.15.6 60 | xml: ^6.5.0 61 | logging: ^1.3.0 62 | background_fetch: ^1.3.8 63 | 64 | dev_dependencies: 65 | flutter_test: 66 | sdk: flutter 67 | flutter_launcher_icons: ^0.14.1 68 | 69 | 70 | flutter_launcher_icons: 71 | android: true 72 | ios: true 73 | remove_alpha_ios: true 74 | image_path: "assets/icon/icon.png" 75 | min_sdk_android: 21 # android min sdk min:16, default 21 76 | 77 | # The "flutter_lints" package below contains a set of recommended lints to 78 | # encourage good coding practices. The lint set provided by the package is 79 | # activated in the `analysis_options.yaml` file located at the root of your 80 | # package. See that file for information about deactivating specific lint 81 | # rules and activating additional ones. 82 | flutter_lints: ^3.0.1 83 | 84 | # For information on the generic Dart part of this file, see the 85 | # following page: https://dart.dev/tools/pub/pubspec 86 | 87 | # The following section is specific to Flutter packages. 88 | flutter: 89 | 90 | # The following line ensures that the Material Icons font is 91 | # included with your application, so that you can use the icons in 92 | # the material Icons class. 93 | uses-material-design: true 94 | generate: true # Used for localizations 95 | 96 | # To add assets to your application, add an assets section, like this: 97 | assets: 98 | - assets/icon/icon.png 99 | # - images/a_dot_ham.jpeg 100 | 101 | # An image asset can refer to one or more resolution-specific "variants", see 102 | # https://flutter.dev/assets-and-images/#resolution-aware 103 | 104 | # For details regarding adding assets from package dependencies, see 105 | # https://flutter.dev/assets-and-images/#from-packages 106 | 107 | # To add custom fonts to your application, add a fonts section here, 108 | # in this "flutter" section. Each entry in this list should have a 109 | # "family" key with the font family name, and a "fonts" key with a 110 | # list giving the asset and other descriptors for the font. For 111 | # example: 112 | # fonts: 113 | # - family: Schyler 114 | # fonts: 115 | # - asset: fonts/Schyler-Regular.ttf 116 | # - asset: fonts/Schyler-Italic.ttf 117 | # style: italic 118 | # - family: Trajan Pro 119 | # fonts: 120 | # - asset: fonts/TrajanPro.ttf 121 | # - asset: fonts/TrajanPro_Bold.ttf 122 | # weight: 700 123 | # 124 | # For details regarding fonts from package dependencies, 125 | # see https://flutter.dev/custom-fonts/#from-packages 126 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /wispar.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | --------------------------------------------------------------------------------