From 9533742d09ede5cc8e20e046cac4244b114c5b91 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:42:10 -0500 Subject: [PATCH 1/2] Improve Zotero feature --- lib/l10n/app_en.arb | 18 + lib/models/zotero_models.dart | 59 +++ lib/screens/article_screen.dart | 102 +++-- lib/screens/zotero_settings_screen.dart | 302 +++++++++----- lib/services/zotero_api.dart | 363 ++++++++-------- .../publication_card/publication_card.dart | 57 ++- lib/widgets/zotero_bottomsheet.dart | 386 ++++++++++++++++++ 7 files changed, 958 insertions(+), 329 deletions(-) create mode 100644 lib/models/zotero_models.dart create mode 100644 lib/widgets/zotero_bottomsheet.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3803cb6d..1df06930 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -67,6 +67,8 @@ "@clearAll": {}, "selectAll": "Select all", "@selectAll": {}, + "select": "Select", + "@select":{}, "customizeFeed": "Customize feed", "@customizeFeed": {}, "feedName": "Feed name", @@ -506,6 +508,22 @@ "@zoteroApiKeyEmpty": {}, "zoteroArticleSent": "The article was sent to Zotero.", "@zoteroArticleSent": {}, + "zoteroSpecificCollection": "Always send to a specific collection", + "@zoteroSpecificCollection":{}, + "zoteroSelectCollection": "Select a collection", + "@zoteroSelectCollection":{}, + "noZoteroCollectionSelected": "No collection selected", + "@noZoteroCollectionSelected":{}, + "zoteroSpecificCollection2": "Always send to this collection", + "@zoteroSpecificCollection2":{}, + "zoteroNewCollection":"New collection", + "@zoteroNewCollection":{}, + "zoteroCollectionName":"Collection name", + "@zoteroCollectionName":{}, + "create":"Create", + "@create":{}, + "send": "Send", + "@send":{}, "save": "Save", "@save": {}, "savedOn": "Saved on {date}", diff --git a/lib/models/zotero_models.dart b/lib/models/zotero_models.dart new file mode 100644 index 00000000..04b6d96d --- /dev/null +++ b/lib/models/zotero_models.dart @@ -0,0 +1,59 @@ +class ZoteroCollection { + final String key; + final String name; + final String? parentKey; + final bool isGroupLibrary; + final String? libraryId; + + ZoteroCollection( + {required this.key, + required this.name, + this.parentKey, + required this.isGroupLibrary, + this.libraryId}); + + factory ZoteroCollection.fromJson( + Map json, { + bool isGroupLibrary = false, + String? libraryId, + }) { + if (json.containsKey('name') && json.containsKey('key')) { + return ZoteroCollection( + key: json['key'] ?? '', + name: json['name'] ?? '', + parentKey: json['parentKey'] as String?, + isGroupLibrary: json['isGroupLibrary'] ?? false, + libraryId: json['libraryId'] as String?, + ); + } + + final data = json['data'] as Map?; + + return ZoteroCollection( + key: json['key'] ?? '', + name: data?['name'] ?? '', + parentKey: data != null && data['parentCollection'] is String + ? data['parentCollection'] as String + : null, + isGroupLibrary: isGroupLibrary, + libraryId: libraryId, + ); + } + Map toJson() => { + 'key': key, + 'name': name, + 'parentKey': parentKey, + 'isGroupLibrary': isGroupLibrary, + 'libraryId': libraryId, + }; +} + +class ZoteroItem { + final String key; + final String name; + + ZoteroItem({ + required this.key, + required this.name, + }); +} diff --git a/lib/screens/article_screen.dart b/lib/screens/article_screen.dart index a7ed674a..4de699ef 100644 --- a/lib/screens/article_screen.dart +++ b/lib/screens/article_screen.dart @@ -1,22 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:wispar/screens/article_website.dart'; -import '../models/crossref_journals_works_models.dart'; -import '../services/database_helper.dart'; -import '../widgets/publication_card/publication_card.dart'; -import './journals_details_screen.dart'; -import '../services/zotero_api.dart'; -import '../services/string_format_helper.dart'; -import '../services/abstract_scraper.dart'; +import 'package:wispar/models/crossref_journals_works_models.dart'; +import 'package:wispar/services/database_helper.dart'; +import 'package:wispar/widgets/publication_card/publication_card.dart'; +import 'package:wispar/widgets/zotero_bottomsheet.dart'; +import 'package:wispar/models/zotero_models.dart'; +import 'package:wispar/screens/journals_details_screen.dart'; +import 'package:wispar/services/zotero_api.dart'; +import 'package:wispar/services/string_format_helper.dart'; +import 'package:wispar/services/abstract_scraper.dart'; import 'package:share_plus/share_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:latext/latext.dart'; -import '../services/logs_helper.dart'; +import 'package:wispar/services/logs_helper.dart'; import 'dart:async'; import 'package:wispar/widgets/translate_sheet.dart'; -import '../services/graphical_abstract_manager.dart'; -import '../screens/graphical_abstract_screen.dart'; +import 'package:wispar/services/graphical_abstract_manager.dart'; +import 'package:wispar/screens/graphical_abstract_screen.dart'; +import 'dart:convert'; import 'dart:io'; class ArticleScreen extends StatefulWidget { @@ -955,27 +958,8 @@ class ArticleScreenState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: () { - List> authorsData = []; - for (PublicationAuthor author in widget.authors) { - authorsData.add({ - 'creatorType': 'author', - 'firstName': author.given, - 'lastName': author.family, - }); - } - ZoteroService.sendToZotero( - context, - authorsData, - widget.title, - _showTranslatedAbstract - ? _accumulatedTranslatedAbstract - : (abstract ?? widget.abstract), - widget.journalTitle, - widget.publishedDate, - widget.doi, - widget.issn, - ); + onTap: () async { + await _sendToZotero(); }, borderRadius: BorderRadius.circular(8), splashColor: @@ -1033,4 +1017,58 @@ class ArticleScreenState extends State { return rows.isNotEmpty ? rows.first : null; } + + Future _sendToZotero() async { + final prefs = await SharedPreferences.getInstance(); + + final bool alwaysSend = prefs.getBool('zoteroAlwaysSend') ?? false; + final String? savedCollectionRaw = + prefs.getString('zoteroDefaultCollection'); + + final authorsData = widget.authors + .map((author) => { + 'creatorType': 'author', + 'firstName': author.given, + 'lastName': author.family, + }) + .toList(); + + if (alwaysSend && savedCollectionRaw != null) { + final ZoteroCollection savedCollection = + ZoteroCollection.fromJson(jsonDecode(savedCollectionRaw)); + + await ZoteroService.sendToZotero( + context, + savedCollection, + authorsData, + widget.title, + widget.abstract, + widget.journalTitle, + widget.publishedDate, + widget.doi, + widget.issn, + ); + + return; + } + + final ZoteroCollection? selectedCollection = + await selectZoteroCollection(context); + + if (selectedCollection == null) return; + + await ZoteroService.sendToZotero( + context, + selectedCollection, + authorsData, + widget.title, + _showTranslatedAbstract + ? _accumulatedTranslatedAbstract + : (abstract ?? widget.abstract), + widget.journalTitle, + widget.publishedDate, + widget.doi, + widget.issn, + ); + } } diff --git a/lib/screens/zotero_settings_screen.dart b/lib/screens/zotero_settings_screen.dart index 74c409cb..c1b9f8e5 100644 --- a/lib/screens/zotero_settings_screen.dart +++ b/lib/screens/zotero_settings_screen.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../services/zotero_api.dart'; +import 'package:wispar/widgets/zotero_bottomsheet.dart'; +import 'package:wispar/services/zotero_api.dart'; +import 'package:wispar/models/zotero_models.dart'; +import 'dart:convert'; class ZoteroSettings extends StatefulWidget { const ZoteroSettings({super.key}); @@ -14,11 +17,15 @@ class ZoteroSettings extends StatefulWidget { class ZoteroSettingsState extends State { final TextEditingController _apiKeyController = TextEditingController(); bool passwordVisible = false; + bool _alwaysSendToCollection = false; + String? _defaultCollectionKey; + String? _defaultCollectionName; @override void initState() { super.initState(); _loadApiKey(); + _loadCollectionSettings(); } Future _loadApiKey() async { @@ -31,134 +38,209 @@ class ZoteroSettingsState extends State { } } + Future _loadCollectionSettings() async { + final prefs = await SharedPreferences.getInstance(); + + final raw = prefs.getString('zoteroDefaultCollection'); + + ZoteroCollection? collection; + + if (raw != null) { + try { + collection = ZoteroCollection.fromJson( + jsonDecode(raw), + ); + } catch (_) { + collection = null; + } + } + + setState(() { + _alwaysSendToCollection = prefs.getBool('zoteroAlwaysSend') ?? false; + + _defaultCollectionKey = collection?.key; + _defaultCollectionName = collection?.name; + }); + } + @override Widget build(BuildContext context) { - const double kMaxContentWidth = 400; - return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.zoteroSettings), ), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: LayoutBuilder( - builder: (context, constraints) { - final double contentWidth = - constraints.maxWidth < kMaxContentWidth - ? constraints.maxWidth - : kMaxContentWidth; - - return Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: contentWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(AppLocalizations.of(context)! - .zoteroPermissions1), - Text( - '\n${AppLocalizations.of(context)!.zoteroPermissions2}\n', - ), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () { - launchUrl( - Uri.parse( - 'https://www.zotero.org/settings/keys/new'), - ); - }, - child: Text( - AppLocalizations.of(context)!.zoteroCreateKey, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: LayoutBuilder( + builder: (context, constraints) { + return Center( + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(AppLocalizations.of(context)! + .zoteroPermissions1), + Text( + '\n${AppLocalizations.of(context)!.zoteroPermissions2}\n', + ), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + launchUrl( + Uri.parse( + 'https://www.zotero.org/settings/keys/new'), + ); + }, + child: Text( + AppLocalizations.of(context)! + .zoteroCreateKey, + ), ), ), - ), - Text( - '\n${AppLocalizations.of(context)!.zoteroPermissions3}\n', - ), - SizedBox( - width: double.infinity, - child: TextField( - controller: _apiKeyController, - obscureText: !passwordVisible, - decoration: InputDecoration( - hintText: AppLocalizations.of(context)! - .zoteroEnterKey, - suffixIcon: IconButton( - icon: Icon( - passwordVisible - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, + Text( + '\n${AppLocalizations.of(context)!.zoteroPermissions3}\n', + ), + SizedBox( + width: double.infinity, + child: TextField( + controller: _apiKeyController, + obscureText: !passwordVisible, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)! + .zoteroEnterKey, + suffixIcon: IconButton( + icon: Icon( + passwordVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + passwordVisible = !passwordVisible; + }); + }, ), - onPressed: () { - setState(() { - passwordVisible = !passwordVisible; - }); - }, ), + onChanged: (value) {}, ), - onChanged: (value) {}, ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () async { - final apiKey = _apiKeyController.text; - if (apiKey.isNotEmpty) { - final userId = - await ZoteroService.getUserId(apiKey); - if (userId != 0) { - final prefs = - await SharedPreferences.getInstance(); - await prefs.setString( - 'zoteroApiKey', apiKey); - await prefs.setString( - 'zoteroUserId', userId.toString()); - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)! - .zoteroValidKey, + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () async { + final apiKey = _apiKeyController.text; + if (apiKey.isNotEmpty) { + final userId = + await ZoteroService.getUserId(apiKey); + if (userId != 0) { + final prefs = await SharedPreferences + .getInstance(); + await prefs.setString( + 'zoteroApiKey', apiKey); + await prefs.setString( + 'zoteroUserId', userId.toString()); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)! + .zoteroValidKey, + ), + duration: + const Duration(seconds: 2), ), - duration: const Duration(seconds: 2), - ), - ); - } else { - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)! - .zoteroInvalidKey, + ); + } else { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)! + .zoteroInvalidKey, + ), + duration: + const Duration(seconds: 3), ), - duration: const Duration(seconds: 3), - ), - ); + ); + } } - } + }, + child: + Text(AppLocalizations.of(context)!.save), + ), + ), + const SizedBox(height: 32), + SwitchListTile( + title: Text(AppLocalizations.of(context)! + .zoteroSpecificCollection), + value: _alwaysSendToCollection, + onChanged: (value) async { + final prefs = + await SharedPreferences.getInstance(); + await prefs.setBool( + 'zoteroAlwaysSend', value); + + setState(() { + _alwaysSendToCollection = value; + }); }, - child: Text(AppLocalizations.of(context)!.save), ), - ), - ], + if (_alwaysSendToCollection) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.folder, + color: Theme.of(context) + .colorScheme + .primary), + title: Text( + _defaultCollectionName ?? + AppLocalizations.of(context)! + .noZoteroCollectionSelected, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final selectedCollection = + await selectZoteroCollection(context, + isSelectionMode: true); + + if (selectedCollection != null) { + final prefs = + await SharedPreferences.getInstance(); + + await prefs.setString( + 'zoteroDefaultCollection', + jsonEncode(selectedCollection.toJson()), + ); + + setState(() { + _defaultCollectionKey = + selectedCollection.key; + _defaultCollectionName = + selectedCollection.name; + }); + } + }, + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/services/zotero_api.dart b/lib/services/zotero_api.dart index 1c15b624..62f59fa7 100644 --- a/lib/services/zotero_api.dart +++ b/lib/services/zotero_api.dart @@ -2,72 +2,24 @@ import 'package:flutter/material.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; -import './string_format_helper.dart'; -import './logs_helper.dart'; -import '../generated_l10n/app_localizations.dart'; - -class Zotero { - final String doiUrl; - final String pdfUrl; - - Zotero({required this.doiUrl, required this.pdfUrl}); - - factory Zotero.fromJson(Map json) { - return Zotero( - doiUrl: json['doi_url'] ?? '', - pdfUrl: json['best_oa_location']?['url_for_pdf'] ?? '', - ); - } -} - -class ZoteroCollection { - final String key; - final String name; - //final bool isSubCollection; - - ZoteroCollection({ - required this.key, - required this.name, - //this.isSubCollection = false, - }); - - ZoteroCollection.subCollection({ - required String key, - required String name, - }) : this( - key: key, - name: name, - ); //isSubCollection: true); - - factory ZoteroCollection.fromJson(Map json) { - return ZoteroCollection( - key: json['key'] ?? '', - name: json['data']['name'] ?? '', - ); - } -} - -class ZoteroItem { - final String key; - final String name; - /* final String itemType; - final String title; - final String abstractNote; - final String publiciationTitle; - final String doi; - final String url;*/ - - ZoteroItem({ - required this.key, - required this.name, - }); -} +import 'package:wispar/services/string_format_helper.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; +import 'package:wispar/models/zotero_models.dart'; class ZoteroService { static const String baseUrl = 'https://api.zotero.org'; static const String keyEndpoint = '/keys'; static const String collectionEndpoint = '/collections'; static const String itemsEndpoint = '/items'; + static const String groupsEndpoint = '/groups'; + static List? _cachedCollections; + static bool _hasFetchedCollections = false; + + static void clearCollectionsCache() { + _cachedCollections = null; + _hasFetchedCollections = false; + } // Function to load the API key from shared preferences static Future loadApiKey() async { @@ -94,6 +46,36 @@ class ZoteroService { } } + static Future> getAllCollections( + String apiKey, + String userId, + ) async { + // Return cached collections if already fetched + if (_hasFetchedCollections && _cachedCollections != null) { + return _cachedCollections!; + } + + final personalResponse = await http.get( + Uri.parse('$baseUrl/users/$userId$collectionEndpoint'), + headers: {'Authorization': 'Bearer $apiKey'}, + ); + + List all = []; + + if (personalResponse.statusCode == 200) { + final List raw = json.decode(personalResponse.body); + all.addAll(raw.map((e) => ZoteroCollection.fromJson(e)).toList()); + } + + final groupCollections = await getGroupCollections(apiKey, userId); + all.addAll(groupCollections); + + _cachedCollections = all; + _hasFetchedCollections = true; + + return all; + } + // Function to get Zotero top collections static Future> getTopCollections( String apiKey, String userId) async { @@ -101,62 +83,95 @@ class ZoteroService { Uri.parse('$baseUrl/users/$userId$collectionEndpoint/top'), headers: {'Authorization': 'Bearer $apiKey'}, ); + if (response.statusCode == 200) { List rawData = json.decode(response.body); - List collections = rawData + return rawData .map((collectionJson) => ZoteroCollection.fromJson(collectionJson)) .toList(); - - return collections; } else { - //print('Failed to load collections. Status code: ${response.statusCode}'); - //print('Response body: ${response.body}'); - throw Exception('Failed to load collections'); + throw Exception('Failed to load top collections'); } } - // Function to get Zotero subcollections - static Future> getSubCollections( + static Future> getGroupCollections( String apiKey, String userId, - String collectionKey, ) async { - final response = await http.get( - Uri.parse( - '$baseUrl/users/$userId$collectionEndpoint/$collectionKey$collectionEndpoint', - ), + final logger = LogsService().logger; + + final groupResponse = await http.get( + Uri.parse('$baseUrl/users/$userId$groupsEndpoint'), headers: {'Authorization': 'Bearer $apiKey'}, ); - if (response.statusCode == 200) { - List rawData = json.decode(response.body); - List collections = rawData - .map((collectionJson) => ZoteroCollection.subCollection( - key: collectionJson['key'] ?? '', - name: collectionJson['data']['name'] ?? '', + + if (groupResponse.statusCode != 200) { + logger.info("No Zotero group found. Code: ${groupResponse.statusCode}"); + return []; + } + + final List groups = json.decode(groupResponse.body); + List allGroupCollections = []; + + for (var group in groups) { + final groupId = group['id'].toString(); + + final response = await http.get( + Uri.parse('$baseUrl$groupsEndpoint/$groupId$collectionEndpoint'), + headers: {'Authorization': 'Bearer $apiKey'}, + ); + + if (response.statusCode != 200) { + logger.warning( + "Group $groupId collections fetch failed: ${response.statusCode}"); + continue; + } + + final List raw = json.decode(response.body); + + final collections = raw + .map((e) => ZoteroCollection.fromJson( + e, + libraryId: groupId, + isGroupLibrary: true, )) .toList(); - return collections; - } else { - // Handle error - //print('Failed to load collections. Status code: ${response.statusCode}'); - //print('Response body: ${response.body}'); - throw Exception('Failed to load collections'); + + allGroupCollections.addAll(collections); } + + return allGroupCollections; } static Future createZoteroCollection( - String apiKey, String userId, String collectionName) async { + String apiKey, + String userId, + String collectionName, { + ZoteroCollection? parentCollection, + }) async { final logger = LogsService().logger; - final url = 'https://api.zotero.org/users/$userId/collections'; + + String url; + + if (parentCollection != null && + parentCollection.isGroupLibrary && + parentCollection.libraryId != null) { + url = + 'https://api.zotero.org/groups/${parentCollection.libraryId}$collectionEndpoint'; + } else { + url = 'https://api.zotero.org/users/$userId$collectionEndpoint'; + } + final headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer $apiKey', - //'Zotero-Write-Token': apiKey, }; + final body = jsonEncode([ { 'name': collectionName, - //'parentCollection': parentCollectionKey, + if (parentCollection?.key != null) + 'parentCollection': parentCollection!.key, } ]); @@ -167,19 +182,29 @@ class ZoteroService { ); if (response.statusCode == 200) { - logger.info("Wispar collection created successfully"); + logger.info("Collection created successfully"); + clearCollectionsCache(); } else { logger.severe( - "Failed to create Wispar collection.", - "Status code:${response.statusCode}", - StackTrace.fromString("Response body: ${response.body}")); + "Failed to create collection", + "Status: ${response.statusCode}", + StackTrace.fromString("Body: ${response.body}"), + ); } } - static Future createZoteroItem( - String apiKey, String userId, Map itemData) async { + static Future createZoteroItem(String apiKey, String userId, + ZoteroCollection targetCollection, Map itemData) async { final logger = LogsService().logger; - final url = 'https://api.zotero.org/users/$userId/items'; + String url; + + if (targetCollection.isGroupLibrary && targetCollection.libraryId != null) { + url = + 'https://api.zotero.org/groups/${targetCollection.libraryId}$itemsEndpoint'; + } else { + url = 'https://api.zotero.org/users/$userId$itemsEndpoint'; + } + final headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer $apiKey', @@ -192,8 +217,6 @@ class ZoteroService { ); if (response.statusCode == 200) { - //print('Article item created successfully'); - //print(response.body); } else { logger.severe( "Failed to create Zotero item.", @@ -202,8 +225,9 @@ class ZoteroService { } } - static void sendToZotero( - context, + static Future sendToZotero( + BuildContext context, + ZoteroCollection targetCollection, List> authorsData, String title, String? abstract, @@ -214,95 +238,78 @@ class ZoteroService { final logger = LogsService().logger; String? apiKey = await ZoteroService.loadApiKey(); String? userId = await ZoteroService.loadUserId(); - String? wisparCollectionKey; - - if (apiKey != null && apiKey.isNotEmpty && userId != null) { - List collections = - await ZoteroService.getTopCollections(apiKey, userId); - - bool collectionExists = false; - for (ZoteroCollection collection in collections) { - if (collection.name == "Wispar") { - collectionExists = true; - wisparCollectionKey = collection.key; // Extract the key - break; - } - } - - if (!collectionExists) { - logger.warning("Wispar collection does not exist yet... creating it."); - // Create the "Wispar" collection - await ZoteroService.createZoteroCollection(apiKey, userId, 'Wispar'); + logger.info("Sending article to Zotero..."); - // Retrieve the updated list of collections - collections = await ZoteroService.getTopCollections(apiKey, userId); - - // Extract the key of the "Wispar" collection from the updated list - for (ZoteroCollection collection in collections) { - if (collection.name == "Wispar") { - wisparCollectionKey = collection.key; - break; + try { + if (apiKey != null && apiKey.isNotEmpty && userId != null) { + // Prepare the article information + Map articleData = { + 'data': { + 'itemType': 'journalArticle', + 'title': title, + 'abstractNote': abstract, + 'publicationTitle': journalTitle, + 'volume': '', //'Volume Number', + 'issue': '', //'Issue Number', + 'pages': '', //'Page Numbers', + 'date': publishedDate?.toIso8601String() ?? '', + 'series': '', //'Series', + 'seriesTitle': '', //'Series Title', + 'seriesText': '', //'Series Text', + 'journalAbbreviation': '', //'Journal Abbreviation', + 'language': '', //'Language', + 'DOI': doi, + 'ISSN': + issn.first, // Todo check the format since ISSN is now a list + 'shortTitle': '', //'Short Title', + 'url': '', + 'accessDate': formatDate(DateTime.now()), + 'archive': '', //'Archive', + 'archiveLocation': '', //'Archive Location', + 'libraryCatalog': '', //'Library Catalog', + 'callNumber': '', //'Call Number', + 'rights': '', //'Rights', + 'extra': '', //'Extra Information', + 'creators': authorsData, + 'collections': [targetCollection.key], + 'tags': [ + {'tag': 'Wispar'}, + ], + 'relations': {}, } - } - } - - // Prepare the article information - - Map articleData = { - 'data': { - 'itemType': 'journalArticle', - 'title': title, - 'abstractNote': abstract, - 'publicationTitle': journalTitle, - 'volume': '', //'Volume Number', - 'issue': '', //'Issue Number', - 'pages': '', //'Page Numbers', - 'date': publishedDate!.toIso8601String(), - 'series': '', //'Series', - 'seriesTitle': '', //'Series Title', - 'seriesText': '', //'Series Text', - 'journalAbbreviation': '', //'Journal Abbreviation', - 'language': '', //'Language', - 'DOI': doi, - 'ISSN': issn.first, // Todo check the format since ISSN is now a list - 'shortTitle': '', //'Short Title', - 'url': '', - 'accessDate': formatDate(DateTime.now()), - 'archive': '', //'Archive', - 'archiveLocation': '', //'Archive Location', - 'libraryCatalog': '', //'Library Catalog', - 'callNumber': '', //'Call Number', - 'rights': '', //'Rights', - 'extra': '', //'Extra Information', - 'creators': authorsData, - 'collections': [wisparCollectionKey], - 'tags': [ - {'tag': 'Wispar'}, - ], - 'relations': {}, - } - /*'creatorTypes': [ + /*'creatorTypes': [ {'creatorType': 'author', 'primary': true}, {'creatorType': 'contributor'}, {'creatorType': 'editor'}, {'creatorType': 'translator'}, {'creatorType': 'reviewedAuthor'} ]*/ - }; - await ZoteroService.createZoteroItem(apiKey, userId, articleData); + }; + await ZoteroService.createZoteroItem( + apiKey, userId, targetCollection, articleData); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.zoteroArticleSent), - duration: const Duration(seconds: 1), - )); - } else { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - AppLocalizations.of(context)!.zoteroApiKeyEmpty, + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.zoteroArticleSent), + duration: const Duration(seconds: 1), + )); + logger.info("Successfully sent the article to Zotero"); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.zoteroApiKeyEmpty, + ), + duration: const Duration(seconds: 3), + )); + } + } catch (e, stackTrace) { + logger.severe("Failed to send item to Zotero", e, stackTrace); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Failed to send to Zotero"), ), - duration: const Duration(seconds: 3), - )); + ); } } } diff --git a/lib/widgets/publication_card/publication_card.dart b/lib/widgets/publication_card/publication_card.dart index 94784689..40c3caa4 100644 --- a/lib/widgets/publication_card/publication_card.dart +++ b/lib/widgets/publication_card/publication_card.dart @@ -10,11 +10,14 @@ import 'package:wispar/services/database_helper.dart'; import 'package:wispar/services/zotero_api.dart'; import 'package:wispar/widgets/publication_card/publication_card_content.dart'; import 'package:wispar/widgets/publication_card/card_swipe_background.dart'; +import 'package:wispar/widgets/zotero_bottomsheet.dart'; +import 'package:wispar/models/zotero_models.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; enum SwipeAction { none, @@ -488,17 +491,53 @@ class PublicationCardState extends State return rows.isNotEmpty ? rows.first : null; } - void _sendToZotero() { - List> authorsData = widget.authors - .map((author) => { - 'creatorType': 'author', - 'firstName': author.given, - 'lastName': author.family, - }) - .toList(); + Future _sendToZotero() async { + final prefs = await SharedPreferences.getInstance(); + + final bool alwaysSend = prefs.getBool('zoteroAlwaysSend') ?? false; + final String? savedCollectionJson = + prefs.getString('zoteroDefaultCollection'); + + if (alwaysSend && savedCollectionJson != null) { + final ZoteroCollection savedCollection = + ZoteroCollection.fromJson(jsonDecode(savedCollectionJson)); + + await ZoteroService.sendToZotero( + context, + savedCollection, + widget.authors + .map((author) => { + 'creatorType': 'author', + 'firstName': author.given, + 'lastName': author.family, + }) + .toList(), + widget.title, + widget.abstract, + widget.journalTitle, + widget.publishedDate, + widget.doi, + widget.issn, + ); + + return; + } + + final ZoteroCollection? selectedCollection = + await selectZoteroCollection(context); + + if (selectedCollection == null) return; + ZoteroService.sendToZotero( context, - authorsData, + selectedCollection, + widget.authors + .map((author) => { + 'creatorType': 'author', + 'firstName': author.given, + 'lastName': author.family, + }) + .toList(), widget.title, widget.abstract, widget.journalTitle, diff --git a/lib/widgets/zotero_bottomsheet.dart b/lib/widgets/zotero_bottomsheet.dart new file mode 100644 index 00000000..b50d4809 --- /dev/null +++ b/lib/widgets/zotero_bottomsheet.dart @@ -0,0 +1,386 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wispar/services/zotero_api.dart'; +import 'package:wispar/models/zotero_models.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; + +Future selectZoteroCollection( + BuildContext context, { + bool isSelectionMode = false, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => _ZoteroCollectionSheet( + isSelectionMode: isSelectionMode, + ), + ); +} + +List _buildTree( + BuildContext context, + Map> tree, + String? parentKey, + ZoteroCollection? selectedCollection, + ValueChanged onSelect, +) { + final children = tree[parentKey] ?? []; + + return children.map((collection) { + final hasChildren = tree.containsKey(collection.key); + + return _ExpandableCollectionTile( + collection: collection, + hasChildren: hasChildren, + selectedCollection: selectedCollection, + onSelect: onSelect, + childrenBuilder: hasChildren + ? () => _buildTree( + context, + tree, + collection.key, + selectedCollection, + onSelect, + ) + : null, + ); + }).toList(); +} + +class _ExpandableCollectionTile extends StatefulWidget { + final ZoteroCollection collection; + final bool hasChildren; + final ZoteroCollection? selectedCollection; + final ValueChanged onSelect; + final List Function()? childrenBuilder; + + const _ExpandableCollectionTile({ + required this.collection, + required this.hasChildren, + required this.selectedCollection, + required this.onSelect, + this.childrenBuilder, + }); + + @override + State<_ExpandableCollectionTile> createState() => + _ExpandableCollectionTileState(); +} + +class _ExpandableCollectionTileState extends State<_ExpandableCollectionTile> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final isSelected = widget.selectedCollection?.key == widget.collection.key; + + return Column( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: Icon( + widget.collection.isGroupLibrary + ? Icons.people + : (widget.hasChildren ? Icons.folder : Icons.folder_outlined), + color: Theme.of(context).colorScheme.primary, + ), + title: Text(widget.collection.name), + trailing: widget.hasChildren + ? Icon(_expanded ? Icons.expand_more : Icons.chevron_right) + : null, + selected: isSelected, + selectedTileColor: + Theme.of(context).colorScheme.primary.withAlpha(50), + onTap: () { + final alreadySelected = isSelected; + + if (alreadySelected) { + widget.onSelect(null); + } else { + widget.onSelect(widget.collection); + } + + if (widget.hasChildren) { + setState(() { + _expanded = !_expanded; + }); + } + }, + ), + if (_expanded && widget.hasChildren) + Padding( + padding: const EdgeInsets.only(left: 24), + child: Column( + children: widget.childrenBuilder!(), + ), + ), + ], + ); + } +} + +class _ZoteroCollectionSheet extends StatefulWidget { + final bool isSelectionMode; + + const _ZoteroCollectionSheet({ + required this.isSelectionMode, + }); + + @override + State<_ZoteroCollectionSheet> createState() => _ZoteroCollectionSheetState(); +} + +class _ZoteroCollectionSheetState extends State<_ZoteroCollectionSheet> { + bool alwaysSend = false; + String? defaultCollectionKey; + String? defaultCollectionName; + List collections = []; + final Map> tree = {}; + ZoteroCollection? selectedCollection; + + String? apiKey; + String? userId; + + bool isLoading = true; + + late final bool isSelectionMode; + + @override + void initState() { + super.initState(); + isSelectionMode = widget.isSelectionMode; + _initialize(); + } + + Future _initialize() async { + apiKey = await ZoteroService.loadApiKey(); + userId = await ZoteroService.loadUserId(); + + if (apiKey == null || userId == null) { + if (mounted) Navigator.pop(context); + return; + } + + final prefs = await SharedPreferences.getInstance(); + + alwaysSend = prefs.getBool('zoteroAlwaysSend') ?? false; + + final raw = prefs.getString('zoteroDefaultCollection'); + if (raw != null) { + try { + final col = ZoteroCollection.fromJson(jsonDecode(raw)); + defaultCollectionKey = col.key; + defaultCollectionName = col.name; + } catch (_) {} + } + + await _loadCollections(); + } + + Future _loadCollections() async { + if (!mounted) return; + + setState(() => isLoading = true); + + final loaded = await ZoteroService.getAllCollections(apiKey!, userId!); + + if (!mounted) return; + + final rebuiltTree = >{}; + + for (var c in loaded) { + rebuiltTree.putIfAbsent(c.parentKey, () => []).add(c); + } + + for (var list in rebuiltTree.values) { + list.sort((a, b) => a.name.compareTo(b.name)); + } + + setState(() { + collections = loaded; + tree + ..clear() + ..addAll(rebuiltTree); + isLoading = false; + }); + } + + Future _showCreateDialog() async { + final controller = TextEditingController(); + + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.zoteroNewCollection), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.zoteroCollectionName, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: Text(AppLocalizations.of(context)!.create), + ), + ], + ), + ); + + if (name == null || name.isEmpty) return; + + await ZoteroService.createZoteroCollection( + apiKey!, + userId!, + name, + parentCollection: selectedCollection, + ); + + await _loadCollections(); + + final created = collections.firstWhere( + (c) => c.name == name, + orElse: () => collections.last, + ); + + if (!mounted) return; + + setState(() { + selectedCollection = created; + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + // Header of the sheet + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.zoteroSelectCollection, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: Icon( + Icons.create_new_folder_outlined, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: _showCreateDialog, + ), + IconButton( + icon: Icon( + Icons.refresh, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () async { + ZoteroService.clearCollectionsCache(); + await _loadCollections(); + }, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + const Divider(), + + // The tiles of collections + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: EdgeInsets.only( + left: 8, + right: 8, + ), + children: _buildTree( + context, + tree, + null, + selectedCollection, + (collection) { + setState(() { + selectedCollection = collection; + }); + }, + ), + ), + ), + + // Buttons located at the bottom of the sheet + if (!widget.isSelectionMode) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const Divider(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(AppLocalizations.of(context)! + .zoteroSpecificCollection2), + value: alwaysSend, + onChanged: (value) async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setBool('zoteroAlwaysSend', value); + + if (value && selectedCollection != null) { + await prefs.setString( + 'zoteroDefaultCollection', + jsonEncode(selectedCollection!.toJson()), + ); + + defaultCollectionKey = selectedCollection!.key; + defaultCollectionName = selectedCollection!.name; + } + + setState(() { + alwaysSend = value; + }); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: selectedCollection == null + ? null + : () => Navigator.pop(context, selectedCollection), + child: Text( + widget.isSelectionMode + ? AppLocalizations.of(context)!.select + : AppLocalizations.of(context)!.send, + ), + ), + ), + ), + ], + ), + ); + } +} From 9a79a4754fb9b830edf2469eff0528c09eac3580 Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:26:47 -0500 Subject: [PATCH 2/2] Add bottomsheet safe area and missing await --- lib/widgets/publication_card/publication_card.dart | 4 ++-- lib/widgets/zotero_bottomsheet.dart | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/widgets/publication_card/publication_card.dart b/lib/widgets/publication_card/publication_card.dart index 40c3caa4..c6cb5c8c 100644 --- a/lib/widgets/publication_card/publication_card.dart +++ b/lib/widgets/publication_card/publication_card.dart @@ -183,7 +183,7 @@ class PublicationCardState extends State widget.onFavoriteChanged?.call(); break; case SwipeAction.sendToZotero: - _sendToZotero(); + await _sendToZotero(); break; case SwipeAction.share: _shareArticle(); @@ -528,7 +528,7 @@ class PublicationCardState extends State if (selectedCollection == null) return; - ZoteroService.sendToZotero( + await ZoteroService.sendToZotero( context, selectedCollection, widget.authors diff --git a/lib/widgets/zotero_bottomsheet.dart b/lib/widgets/zotero_bottomsheet.dart index b50d4809..668bdc18 100644 --- a/lib/widgets/zotero_bottomsheet.dart +++ b/lib/widgets/zotero_bottomsheet.dart @@ -12,6 +12,7 @@ Future selectZoteroCollection( return showModalBottomSheet( context: context, isScrollControlled: true, + useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), @@ -161,6 +162,12 @@ class _ZoteroCollectionSheetState extends State<_ZoteroCollectionSheet> { userId = await ZoteroService.loadUserId(); if (apiKey == null || userId == null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.zoteroApiKeyEmpty, + ), + duration: const Duration(seconds: 3), + )); if (mounted) Navigator.pop(context); return; }