diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index ae1fb632..957304f5 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -636,6 +636,19 @@ library: borrow_list_info: Borrowing {borrow} book(s), among which {dued} book(s) have expired search_book_window: null search_here: Search here + normal_search: Normal Search + advanced_search: Advanced Search + search: Search + match_mode: Match Mode + match_exact: Exact + match_fuzzy: Fuzzy + match_prefix: Prefix + document_type: Document Type + document_type_all: All + document_type_book: Book + only_on_shelf: Only on shelf + publish_year_begin: Publish year from + publish_year_end: Publish year to book_detail: Book details no_result: No result, change parameter or start your search library_card: @@ -803,6 +816,15 @@ setting: color_setting: Color theme simplify_timeline: Simplify schedule timeline simplify_timeline_description: Reduce space occupation while no schedule + low_electricity_warning: Low electricity card color warning + low_electricity_warning_description: + Change the homepage electricity card color when remaining electricity is below + the threshold + low_electricity_threshold: Low electricity threshold + low_electricity_threshold_description: "Current: {threshold} kWh" + low_electricity_threshold_dialog: + title: Set low electricity threshold + input_hint: Input remaining electricity account_setting: Account Settings sport_password_setting: PE system password experiment_password_setting: Physics experiment password diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 2a790aaf..e563db32 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -566,6 +566,19 @@ library: borrow_list_info: 在借 {borrow} 本,其中已过期 {dued} 本 search_book_window: null search_here: 在此搜索 + normal_search: 普通搜索 + advanced_search: 高级搜索 + search: 搜索 + match_mode: 匹配方式 + match_exact: 精确匹配 + match_fuzzy: 模糊匹配 + match_prefix: 前方一致 + document_type: 文献类型 + document_type_all: 全部 + document_type_book: 图书 + only_on_shelf: 仅看在架 + publish_year_begin: 出版年起 + publish_year_end: 出版年止 book_detail: 书籍详细信息 no_result: 没有结果,请修改搜索参数或者开始你的搜索 library_card: @@ -726,6 +739,13 @@ setting: color_setting: 颜色设置 simplify_timeline: 简化日程时间轴 simplify_timeline_description: 没有日程时 减少空间占用 + low_electricity_warning: 低电量卡片变色提醒 + low_electricity_warning_description: 电量小于阈值时 电量卡片变色提醒 + low_electricity_threshold: 低电量阈值 + low_electricity_threshold_description: 当前为 {threshold} 度 + low_electricity_threshold_dialog: + title: 设置低电量阈值 + input_hint: 请输入电量度数 account_setting: 账号设置 sport_password_setting: 体育系统密码设置 experiment_password_setting: 物理实验系统密码设置 diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 265a5d92..74b70b78 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -564,6 +564,19 @@ library: borrow_list_info: 在借 {borrow} 本,其中已過期 {dued} 本 search_book_window: null search_here: 在此搜索 + normal_search: 普通搜索 + advanced_search: 高級搜索 + search: 搜索 + match_mode: 匹配方式 + match_exact: 精確匹配 + match_fuzzy: 模糊匹配 + match_prefix: 前方一致 + document_type: 文獻類型 + document_type_all: 全部 + document_type_book: 圖書 + only_on_shelf: 僅看在架 + publish_year_begin: 出版年起 + publish_year_end: 出版年止 book_detail: 書籍詳細信息 no_result: 沒有結果,請修改搜索參數或者開始你的搜索 library_card: @@ -724,6 +737,13 @@ setting: color_setting: 顏色設置 simplify_timeline: 簡化日程時間軸 simplify_timeline_description: 沒有日程時 減少空間佔用 + low_electricity_warning: 低電量卡片變色提醒 + low_electricity_warning_description: 電量小於閾值時 電量卡片變色提醒 + low_electricity_threshold: 低電量閾值 + low_electricity_threshold_description: 目前為 {threshold} 度 + low_electricity_threshold_dialog: + title: 設置低電量閾值 + input_hint: 請輸入電量度數 account_setting: 賬號設置 sport_password_setting: 體育系統密碼設置 experiment_password_setting: 物理實驗系統密碼設置 diff --git a/lib/model/xidian_ids/library.dart b/lib/model/xidian_ids/library.dart index 511cb76b..ed1791eb 100644 --- a/lib/model/xidian_ids/library.dart +++ b/lib/model/xidian_ids/library.dart @@ -70,9 +70,60 @@ class BorrowData { factory BorrowData.fromJson(Map json) => _$BorrowDataFromJson(json); + factory BorrowData.fromOpacJson(Map json) { + final normReturnDate = _stringValue(json['normReturnDate']); + final dueDate = _parseLibraryDate(normReturnDate); + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + return BorrowData( + lendDay: dueDate.difference(todayDate).inDays, + locationId: _intValue(json['locationId']), + loanId: _intValue(json['loanId']), + renewTimes: _intValue(json['renewTimes']), + recallTimes: _intValue(json['recallTimes']), + loanDate: _stringValue(json['loanDate']), + renewDate: _nullableStringValue(json['renewDate']), + normReturnDate: normReturnDate, + returnDate: _nullableStringValue(json['returnDate']), + loanType: _stringValue(json['loanType']), + locationName: _stringValue(json['locationName']), + itemLibCode: _stringValue(json['curLibCode'] ?? json['itemLibCode']), + itemLibName: _stringValue(json['curLibName'] ?? json['itemLibName']), + loanDeskName: _stringValue(json['loanDeskName']), + title: _stringValue(json['title']), + author: _stringValue(json['author']), + publisher: _stringValue(json['publisher']), + isbn: _stringValue(json['isbn']), + isbn10: _stringValue(json['isbn10']), + isbn13: _stringValue(json['isbn13']), + publishYear: _stringValue(json['publishYear']), + titles: _nullableStringValue(json['titles']), + barcode: _stringValue(json['barcode'] ?? json['propNo']), + ); + } + Map toJson() => _$BorrowDataToJson(this); } +int _intValue(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; +} + +String _stringValue(Object? value) => value?.toString() ?? ""; + +String? _nullableStringValue(Object? value) => value?.toString(); + +DateTime _parseLibraryDate(String value) { + if (value.isEmpty) { + return DateTime.now(); + } + return DateTime.parse(value.replaceAll('/', '-').split(' ').first); +} + @JsonSerializable() class BookInfo { final String? author; @@ -92,6 +143,8 @@ class BookInfo { final List? barCodes; final List? searchCode; final List? items; + final int? availableCount; + final int? storageCount; String? imageUrl; BookInfo({ @@ -112,15 +165,48 @@ class BookInfo { this.searchCode, required this.barCodes, this.items, + this.availableCount, + this.storageCount, this.imageUrl, }); factory BookInfo.fromJson(Map json) => _$BookInfoFromJson(json); + factory BookInfo.fromOpacJson(Map json) { + final callNos = _stringList(json['callNo']); + final barCodes = _nullableStringList( + json['barCodes'] ?? json['barcode'] ?? json['propNo'], + ); + return BookInfo( + author: _nullableStringValue(json['author']), + subject: _nullableStringValue(json['subjectWord']), + isbn: _nullableStringValue(json['isbn']), + description: _nullableStringValue(json['adstract'] ?? json['ddAbstract']), + bookName: _stringValue(json['title']), + barCode: _nullableStringValue(json['barcode'] ?? json['propNo']), + docNumber: _intValue(json['recordId']), + publishYear: _nullableStringValue(json['publishYear'] ?? json['year']), + publisherHouse: _nullableStringValue(json['publisher']), + groupCode: _nullableStringValue(json['groupCode']), + callNos: callNos, + searchCode: callNos, + barCodes: barCodes, + availableCount: _nullableIntValue( + json['onShelfCountI'] ?? json['canBrrowCountI'] ?? json['onShelfNum'], + ), + storageCount: _nullableIntValue( + json['groupPhysicalCount'] ?? json['physicalCount'], + ), + ); + } + Map toJson() => _$BookInfoToJson(this); int? get canBeBorrowed { + if (availableCount != null) { + return availableCount; + } if (items == null) { return null; } @@ -131,6 +217,20 @@ class BookInfo { return toReturn; } + int? get totalStorage { + if (storageCount != null) { + return storageCount; + } + return items?.length; + } + + bool get hasBarCodes { + if (barCodes == null || barCodes!.isEmpty) { + return false; + } + return barCodes!.any((e) => e != null && e.isNotEmpty); + } + String get searchCodeStr { if (searchCode == null || searchCode!.isEmpty) { return "未提供"; @@ -142,8 +242,39 @@ class BookInfo { if (barCodes == null || barCodes!.isEmpty) { return "未提供"; } - return barCodes!.first ?? "未提供"; + for (final code in barCodes!) { + if (code != null && code.isNotEmpty) { + return code; + } + } + return "未提供"; + } +} + +int? _nullableIntValue(Object? value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +List? _stringList(Object? value) { + if (value == null) return null; + if (value is List) { + return value.map((e) => e.toString()).where((e) => e.isNotEmpty).toList(); + } + final text = value.toString(); + return text.isEmpty ? null : [text]; +} + +List? _nullableStringList(Object? value) { + if (value == null) return null; + if (value is List) { + return value.map((e) => e?.toString()).toList(); } + final text = value.toString(); + return text.isEmpty ? null : [text]; } @JsonSerializable() @@ -183,6 +314,33 @@ class BookLocation { factory BookLocation.fromJson(Map json) => _$BookLocationFromJson(json); + factory BookLocation.fromOpacJson(Map json) { + final locationName = _stringValue( + json['realLocationName'] ?? json['locationName'], + ); + final shelfName = _stringValue(json['urlName']); + final displayLocation = shelfName.isEmpty + ? locationName + : "$locationName $shelfName"; + + return BookLocation( + yearVol: _nullableStringValue(json['vol'] ?? json['yearVol']), + locationName: displayLocation.isEmpty ? null : displayLocation, + searchCode: _stringValue(json['callNo']), + campus: _nullableStringValue(json['campus']), + inDate: _nullableStringValue(json['inDate']), + barCode: _nullableStringValue(json['barcode'] ?? json['propNo']), + itemId: _intValue(json['itemId']), + circAttr: _stringValue(json['circAttr']), + locationId: _nullableStringValue(json['locationId']), + processType: _stringValue(json['processType']), + curLocationId: _stringValue(json['curLocationId']), + propNo: _nullableStringValue(json['propNo']), + borrowStatus: _nullableStringValue(json['borrowStatus']), + noBorrowMessages: _nullableStringValue(json['noBorrowMessages']), + ); + } + Map toJson() => _$BookLocationToJson(this); } diff --git a/lib/model/xidian_ids/library.g.dart b/lib/model/xidian_ids/library.g.dart index 5848bac6..c7699d59 100644 --- a/lib/model/xidian_ids/library.g.dart +++ b/lib/model/xidian_ids/library.g.dart @@ -89,6 +89,8 @@ BookInfo _$BookInfoFromJson(Map json) => BookInfo( items: (json['items'] as List?) ?.map((e) => BookLocation.fromJson(e as Map)) .toList(), + availableCount: (json['availableCount'] as num?)?.toInt(), + storageCount: (json['storageCount'] as num?)?.toInt(), imageUrl: json['imageUrl'] as String?, ); @@ -110,6 +112,8 @@ Map _$BookInfoToJson(BookInfo instance) => { 'barCodes': instance.barCodes, 'searchCode': instance.searchCode, 'items': instance.items, + 'availableCount': instance.availableCount, + 'storageCount': instance.storageCount, 'imageUrl': instance.imageUrl, }; diff --git a/lib/page/homepage/home_card_padding.dart b/lib/page/homepage/home_card_padding.dart index 93727e56..696328b4 100644 --- a/lib/page/homepage/home_card_padding.dart +++ b/lib/page/homepage/home_card_padding.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; -enum HomeCardType { plain, filled } +enum HomeCardType { plain, filled, warning } extension HomeCardPadding on Widget { Widget withHomeCardStyle( @@ -27,6 +27,9 @@ extension HomeCardPadding on Widget { iconSize: WidgetStateProperty.all(20), backgroundColor: WidgetStateProperty.resolveWith((states) { final colorScheme = Theme.of(context).colorScheme; + if (type == HomeCardType.warning) { + return colorScheme.errorContainer; + } if (type == HomeCardType.filled) { return colorScheme.surfaceContainerHigh; } @@ -34,6 +37,9 @@ extension HomeCardPadding on Widget { }), foregroundColor: WidgetStateProperty.resolveWith((states) { final colorScheme = Theme.of(context).colorScheme; + if (type == HomeCardType.warning) { + return colorScheme.onErrorContainer; + } if (type == HomeCardType.filled) { return colorScheme.onSurfaceVariant; } @@ -41,6 +47,9 @@ extension HomeCardPadding on Widget { }), side: WidgetStateProperty.resolveWith((states) { final colorScheme = Theme.of(context).colorScheme; + if (type == HomeCardType.warning) { + return BorderSide(color: colorScheme.error); + } if (type == HomeCardType.filled) { return BorderSide.none; } @@ -56,7 +65,9 @@ extension HomeCardPadding on Widget { onPressed: onPressed, child: DefaultTextStyle( style: TextStyle( - color: Theme.of(context).brightness == Brightness.dark + color: type == HomeCardType.warning + ? Theme.of(context).colorScheme.onErrorContainer + : Theme.of(context).brightness == Brightness.dark ? Theme.of(context).colorScheme.onSurface : Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/lib/page/homepage/info_widget/energy_card.dart b/lib/page/homepage/info_widget/energy_card.dart index 485a328c..076015a2 100644 --- a/lib/page/homepage/info_widget/energy_card.dart +++ b/lib/page/homepage/info_widget/energy_card.dart @@ -9,10 +9,14 @@ import 'package:intl/intl.dart'; import 'package:ming_cute_icons/ming_cute_icons.dart'; import 'package:signals/signals_flutter.dart'; import 'package:watermeter/controller/energy_controller.dart'; +import 'package:watermeter/page/homepage/home_card_padding.dart'; import 'package:watermeter/page/homepage/main_page_card.dart'; import 'package:watermeter/page/public_widget/context_extension.dart'; +import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/routing/routes.dart'; +const int _defaultLowElectricityWarningThreshold = 20; + class EnergyCard extends StatelessWidget { const EnergyCard({super.key}); @@ -23,6 +27,28 @@ class EnergyCard extends StatelessWidget { builder: (context) { final state = controller.energyInfoStateSignal.value; final displayInfo = controller.displayEnergyInfo.value; + final lowElectricityWarningEnabled = + !preference.contains( + preference.Preference.lowElectricityWarningEnabled, + ) || + preference.getBool( + preference.Preference.lowElectricityWarningEnabled, + ); + final lowElectricityWarningThreshold = + preference.contains( + preference.Preference.lowElectricityWarningThreshold, + ) + ? preference.getInt( + preference.Preference.lowElectricityWarningThreshold, + ) + : _defaultLowElectricityWarningThreshold; + final effectiveThreshold = lowElectricityWarningThreshold > 0 + ? lowElectricityWarningThreshold + : _defaultLowElectricityWarningThreshold; + final lowElectricityWarning = + displayInfo != null && + lowElectricityWarningEnabled && + displayInfo.electricityRemain < effectiveThreshold; return MainPageCard( onPressed: () async { @@ -30,6 +56,9 @@ class EnergyCard extends StatelessWidget { }, isLoad: state.isLoading && displayInfo == null, icon: MingCuteIcons.mgc_flash_line, + type: lowElectricityWarning + ? HomeCardType.warning + : HomeCardType.plain, text: FlutterI18n.translate( context, "homepage.electricity_card.title", diff --git a/lib/page/homepage/main_page_card.dart b/lib/page/homepage/main_page_card.dart index 1ea160d6..03161a7c 100644 --- a/lib/page/homepage/main_page_card.dart +++ b/lib/page/homepage/main_page_card.dart @@ -14,6 +14,7 @@ class MainPageCard extends StatelessWidget { final Widget bottomText; final Widget? rightButton; final bool? isBold; + final HomeCardType type; final void Function()? onPressed; const MainPageCard({ super.key, @@ -26,19 +27,25 @@ class MainPageCard extends StatelessWidget { this.rightButton, this.progress, this.isBold, + this.type = HomeCardType.plain, }); @override Widget build(BuildContext context) { + final textColor = type == HomeCardType.warning + ? Theme.of(context).colorScheme.onErrorContainer + : Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurfaceVariant; return ListTile( leading: Icon( icon, size: 32, - color: Theme.of(context).colorScheme.primary, + color: type == HomeCardType.warning + ? Theme.of(context).colorScheme.onErrorContainer + : Theme.of(context).colorScheme.primary, ), - textColor: Theme.of(context).brightness == Brightness.dark - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSurfaceVariant, + textColor: textColor, title: infoText, subtitle: Builder( builder: (context) { @@ -57,6 +64,6 @@ class MainPageCard extends StatelessWidget { }, ), trailing: rightButton, - ).withHomeCardStyle(context, onPressed: onPressed); + ).withHomeCardStyle(context, onPressed: onPressed, type: type); } } diff --git a/lib/page/library/book_detail_card.dart b/lib/page/library/book_detail_card.dart index 1b933938..62db52bc 100644 --- a/lib/page/library/book_detail_card.dart +++ b/lib/page/library/book_detail_card.dart @@ -2,15 +2,14 @@ // Copyright 2025 Traintime PDA authors. // SPDX-License-Identifier: MPL-2.0 -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/model/xidian_ids/library.dart'; +import 'package:watermeter/page/library/book_info_card.dart'; import 'package:watermeter/page/library/book_place_card.dart'; import 'package:watermeter/page/library/ebook_place_card.dart'; -import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/xidian_ids/library_session.dart'; class BookDetailCard extends StatefulWidget { final BookInfo toUse; @@ -22,6 +21,11 @@ class BookDetailCard extends StatefulWidget { } class _BookDetailCardState extends State { + late final Future> _locationsFuture = + widget.toUse.items != null + ? Future.value(widget.toUse.items!) + : LibrarySession().bookLocations(widget.toUse.docNumber); + @override Widget build(BuildContext context) { return ListView( @@ -30,33 +34,7 @@ class _BookDetailCardState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - CachedNetworkImage( - imageUrl: widget.toUse.imageUrl ?? "", - placeholder: (context, url) => Image.asset( - "assets/art/pda_empty_cover.jpg", - width: 120, - height: 150, - fit: BoxFit.fill, - ), - errorWidget: (context, url, error) => Image.asset( - "assets/art/pda_empty_cover.jpg", - width: 120, - height: 150, - fit: BoxFit.fill, - ), - width: 120, - height: 150, - fit: BoxFit.fitHeight, - alignment: Alignment.center, - errorListener: (e) { - if (e is DioException) { - log.info('Error with Internet error...'); - } else { - log.info('Image Exception is: ${e.runtimeType}'); - } - }, - ) - //.clipRect(clipper: BookImageClipper()) + BookCover(toUse: widget.toUse, width: 120, height: 150) .clipRRect(all: 14) .padding(all: 2) .decorated( @@ -212,30 +190,31 @@ class _BookDetailCardState extends State { ], ), ), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: FlutterI18n.translate( - context, - "library.arrangement_code", - ), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFFBFBFBF), - ), - ), - TextSpan( - text: widget.toUse.barCodesStr, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], + if (widget.toUse.hasBarCodes) + Text.rich( + TextSpan( + children: [ + TextSpan( + text: FlutterI18n.translate( + context, + "library.arrangement_code", + ), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFFBFBFBF), + ), + ), + TextSpan( + text: widget.toUse.barCodesStr, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), - ), ] .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) .flexible(), @@ -243,20 +222,30 @@ class _BookDetailCardState extends State { ), Padding( padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.toUse.items != null) - ...List.generate( - widget.toUse.items!.length, - (index) => BookPlaceCard(toUse: widget.toUse.items![index]), - ), - if (widget.toUse.eitems != null) - ...List.generate( - widget.toUse.eitems!.length, - (index) => EBookPlaceCard(toUse: widget.toUse.eitems![index]), - ), - ], + child: FutureBuilder>( + future: _locationsFuture, + builder: (context, snapshot) { + final locations = snapshot.data ?? const []; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (snapshot.connectionState == ConnectionState.waiting) + const Center( + child: CircularProgressIndicator(), + ).padding(vertical: 12), + ...List.generate( + locations.length, + (index) => BookPlaceCard(toUse: locations[index]), + ), + if (widget.toUse.eitems != null) + ...List.generate( + widget.toUse.eitems!.length, + (index) => + EBookPlaceCard(toUse: widget.toUse.eitems![index]), + ), + ], + ); + }, ), ), ], diff --git a/lib/page/library/book_info_card.dart b/lib/page/library/book_info_card.dart index 70feb156..a735ea8a 100644 --- a/lib/page/library/book_info_card.dart +++ b/lib/page/library/book_info_card.dart @@ -11,6 +11,7 @@ import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/model/xidian_ids/library.dart'; import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/xidian_ids/library_session.dart'; class BookInfoCard extends StatelessWidget { final BookInfo toUse; @@ -19,36 +20,14 @@ class BookInfoCard extends StatelessWidget { @override Widget build(BuildContext context) { return [ - CachedNetworkImage( - imageUrl: toUse.imageUrl ?? "", - placeholder: (context, url) => Image.asset( - "assets/art/pda_empty_cover.jpg", - width: 176 * 0.6, - height: 250 * 0.6, - fit: BoxFit.fill, - ), - errorWidget: (context, url, error) => Image.asset( - "assets/art/pda_empty_cover.jpg", - width: 176 * 0.6, - height: 250 * 0.6, - fit: BoxFit.fill, - ), - width: 176 * 0.6, - height: 250 * 0.6, - fit: BoxFit.fitHeight, - alignment: Alignment.center, - errorListener: (e) { - if (e is DioException) { - log.info('Error with Internet error...'); - } else { - log.info('Image Exception is: ${e.runtimeType}'); - } - }, + BookCover( + key: ValueKey(toUse.docNumber), + toUse: toUse, ).clipRRect(all: 14), - const VerticalDivider(color: Colors.transparent), + const SizedBox(width: 10), [ Text( - '${toUse.bookName}\n', + toUse.bookName, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.start, @@ -164,10 +143,10 @@ class BookInfoCard extends StatelessWidget { TextSpan( children: [ TextSpan( - text: toUse.items?.length.toString() ?? "0", + text: toUse.totalStorage?.toString() ?? "0", style: TextStyle( fontSize: 24, - color: (toUse.items?.length ?? 0) > 0 + color: (toUse.totalStorage ?? 0) > 0 ? Colors.green : Colors.yellow, fontWeight: FontWeight.w600, @@ -193,3 +172,95 @@ class BookInfoCard extends StatelessWidget { ].toRow().padding(all: 12).card(elevation: 0); } } + +class BookCover extends StatefulWidget { + final BookInfo toUse; + final double width; + final double height; + + const BookCover({ + super.key, + required this.toUse, + this.width = 176 * 0.6, + this.height = 250 * 0.6, + }); + + @override + State createState() => _BookCoverState(); +} + +class _BookCoverState extends State { + late Future _coverFuture; + + @override + void initState() { + super.initState(); + _coverFuture = _loadCover(); + } + + @override + void didUpdateWidget(covariant BookCover oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.toUse.docNumber != widget.toUse.docNumber || + oldWidget.toUse.bookName != widget.toUse.bookName || + oldWidget.toUse.isbn != widget.toUse.isbn || + oldWidget.toUse.imageUrl != widget.toUse.imageUrl) { + _coverFuture = _loadCover(); + } + } + + Future _loadCover() { + final imageUrl = widget.toUse.imageUrl; + if (imageUrl != null && imageUrl.isNotEmpty) { + return Future.value(imageUrl); + } + return LibrarySession().bookCover( + widget.toUse.bookName, + widget.toUse.isbn ?? "", + widget.toUse.docNumber, + ); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _coverFuture, + builder: (context, snapshot) { + final url = snapshot.data ?? ""; + if (url.isEmpty) { + return _emptyCover(); + } + widget.toUse.imageUrl = url; + return _networkCover(url); + }, + ); + } + + Widget _networkCover(String url) { + return CachedNetworkImage( + imageUrl: url, + placeholder: (context, url) => _emptyCover(), + errorWidget: (context, url, error) => _emptyCover(), + width: widget.width, + height: widget.height, + fit: BoxFit.fitHeight, + alignment: Alignment.center, + errorListener: (e) { + if (e is DioException) { + log.info('Error with Internet error...'); + } else { + log.info('Image Exception is: ${e.runtimeType}'); + } + }, + ); + } + + Widget _emptyCover() { + return Image.asset( + "assets/art/pda_empty_cover.jpg", + width: widget.width, + height: widget.height, + fit: BoxFit.fill, + ); + } +} diff --git a/lib/page/library/book_place_card.dart b/lib/page/library/book_place_card.dart index 493473d7..49ee5b90 100644 --- a/lib/page/library/book_place_card.dart +++ b/lib/page/library/book_place_card.dart @@ -30,6 +30,7 @@ class BookPlaceCard extends StatelessWidget { Text( toUse.locationName ?? FlutterI18n.translate(context, "library.not_provided"), + softWrap: true, style: TextStyle( fontWeight: FontWeight.w400, fontSize: 16, @@ -37,7 +38,7 @@ class BookPlaceCard extends StatelessWidget { ? Colors.green.shade900 : Colors.red.shade900, ), - ), + ).expanded(), ].toRow(), const SizedBox(height: 8), Text( @@ -56,6 +57,7 @@ class BookPlaceCard extends StatelessWidget { ? Colors.green.shade900 : Colors.red.shade900, ), + softWrap: true, ), ] .toColumn(crossAxisAlignment: CrossAxisAlignment.start) diff --git a/lib/page/library/search_book_window.dart b/lib/page/library/search_book_window.dart index 50aa0e51..5e25796e 100644 --- a/lib/page/library/search_book_window.dart +++ b/lib/page/library/search_book_window.dart @@ -7,7 +7,6 @@ import 'package:watermeter/page/public_widget/both_side_sheet.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:flutter/material.dart'; import 'package:styled_widget/styled_widget.dart'; -// import 'package:watermeter/page/public_widget/empty_list_view.dart'; import 'package:watermeter/page/public_widget/public_widget.dart'; import 'package:watermeter/repository/xidian_ids/library_session.dart' as search_book; @@ -15,61 +14,12 @@ import 'package:watermeter/model/xidian_ids/library.dart'; import 'package:watermeter/page/library/book_detail_card.dart'; import 'package:watermeter/page/library/book_info_card.dart'; -enum SearchField { keyWord, title, author, isbn, barcode, callNo } - -extension SearchFieldExtension on SearchField { - String get apiValue { - switch (this) { - case SearchField.keyWord: - return "keyWord"; - case SearchField.title: - return "title"; - case SearchField.isbn: - return "isbn"; - case SearchField.author: - return "author"; - case SearchField.barcode: - return "barcode"; - case SearchField.callNo: - return "callNo"; - } - } +const double _searchPanelMaxWidth = 760; +const double _resultCardMaxWidth = 500; - String getLabel(BuildContext context) { - switch (this) { - case SearchField.keyWord: - return FlutterI18n.translate( - context, - "library.search_field_keyword_option", - ); - case SearchField.title: - return FlutterI18n.translate( - context, - "library.search_field_title_option", - ); - case SearchField.isbn: - return FlutterI18n.translate( - context, - "library.search_field_isbn_option", - ); - case SearchField.author: - return FlutterI18n.translate( - context, - "library.search_field_author_option", - ); - case SearchField.barcode: - return FlutterI18n.translate( - context, - "library.search_field_barcode_option", - ); - case SearchField.callNo: - return FlutterI18n.translate( - context, - "library.search_field_callno_option", - ); - } - } -} +enum SearchPanelMode { normal, advanced } + +typedef _SearchOption = search_book.LibrarySearchOption; class SearchBookWindow extends StatefulWidget { const SearchBookWindow({super.key}); @@ -85,167 +35,668 @@ class _SearchBookWindowState extends State getNextPageKey: (state) => state.lastPageIsEmpty ? null : state.nextIntPageKey, - fetchPage: (pageKey) => search_book.LibrarySession().searchBook( - search, - pageKey, - searchField: selectedSearchField.apiValue, - ), + fetchPage: _fetchPage, ); - String search = ''; - SearchField selectedSearchField = SearchField.keyWord; + bool _useAdvancedSearch = false; + String _normalSearch = ""; + String _advancedSearch = ""; + String _normalSearchField = "keyWord"; + String _advancedSearchField = "keyWord"; + String _advancedMatchMode = "2"; + String _documentType = ""; + String _resourceType = ""; + String _campusId = ""; + String _locationId = ""; + String _countryCode = ""; + String _languageCode = ""; + String _publishBegin = ""; + String _publishEnd = ""; + bool _onlyOnShelf = false; + SearchPanelMode _searchMode = SearchPanelMode.normal; + bool _hasSearched = false; + bool _advancedPanelCollapsed = false; + late final Future + _filterOptionsFuture = search_book.LibrarySession().searchFilterOptions(); @override bool get wantKeepAlive => true; - late final TextEditingController _textEditingController = - TextEditingController.fromValue(TextEditingValue(text: search)); + late final TextEditingController _normalTextController = + TextEditingController.fromValue(TextEditingValue(text: _normalSearch)); + late final TextEditingController _advancedTextController = + TextEditingController.fromValue(TextEditingValue(text: _advancedSearch)); @override void dispose() { - _textEditingController.dispose(); + _normalTextController.dispose(); + _advancedTextController.dispose(); _pagingController.dispose(); super.dispose(); } + Future> _fetchPage(int pageKey) { + final session = search_book.LibrarySession(); + if (_useAdvancedSearch) { + return session.advancedSearchBook( + _advancedSearch, + pageKey, + searchField: _advancedSearchField, + matchMode: _advancedMatchMode, + docCode: _emptyToNull(_documentType), + resourceType: _emptyToNull(_resourceType), + campusId: _intFilter(_campusId), + locationId: _intFilter(_locationId), + countryCode: _emptyToNull(_countryCode), + langCode: _emptyToNull(_languageCode), + onlyOnShelf: _onlyOnShelf ? true : null, + publishBegin: _publishBegin, + publishEnd: _publishEnd, + ); + } + return session.searchBook( + _normalSearch, + pageKey, + searchField: _normalSearchField, + ); + } + + void _submitNormalSearch() { + FocusScope.of(context).unfocus(); + setState(() { + _useAdvancedSearch = false; + _normalSearch = _normalTextController.text.trim(); + _hasSearched = true; + }); + _pagingController.refresh(); + } + + String? _emptyToNull(String value) => value.isEmpty ? null : value; + + int? _intFilter(String value) => value.isEmpty ? null : int.tryParse(value); + + void _resetAdvancedSearch() { + setState(() { + _advancedSearch = ""; + _advancedTextController.clear(); + _advancedSearchField = "keyWord"; + _advancedMatchMode = "2"; + _documentType = ""; + _resourceType = ""; + _campusId = ""; + _locationId = ""; + _countryCode = ""; + _languageCode = ""; + _publishBegin = ""; + _publishEnd = ""; + _onlyOnShelf = false; + }); + } + + void _submitAdvancedSearch() { + FocusScope.of(context).unfocus(); + setState(() { + _useAdvancedSearch = true; + _advancedSearch = _advancedTextController.text.trim(); + _hasSearched = true; + }); + _pagingController.refresh(); + } + @override Widget build(BuildContext context) { super.build(context); return Scaffold( body: Column( children: [ - // Search field selector and input - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextFormField( - controller: _textEditingController, - decoration: InputDecoration( - hintText: FlutterI18n.translate( - context, - "library.search_here", - ), - prefixIcon: Icon( - Icons.search, - color: Theme.of(context).colorScheme.primary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + _buildSearchArea(context) + .padding(horizontal: 8, vertical: 4) + .constrained(maxWidth: _searchPanelMaxWidth), + if (_hasSearched) _buildResultList().expanded(), + ], + ), + ); + } + + Widget _buildResultList() { + return PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth < _resultCardMaxWidth * 2 + ? 1 + : constraints.maxWidth ~/ _resultCardMaxWidth; + return PagedMasonryGridView.count( + state: state, + fetchNextPage: fetchNextPage, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + crossAxisCount: crossAxisCount, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => + GestureDetector( + child: BookInfoCard(toUse: item), + onTap: () => _openBookDetail(item), + ) + .padding(horizontal: 12, vertical: 2) + .width(double.infinity) + .constrained(width: _resultCardMaxWidth) + .center(), + firstPageProgressIndicatorBuilder: (context) => + const Center(child: CircularProgressIndicator()), + firstPageErrorIndicatorBuilder: (context) => ReloadWidget( + function: () async => _pagingController.refresh(), + errorStatus: _pagingController.error, + ), + noItemsFoundIndicatorBuilder: (context) => Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 32, + horizontal: 16, ), - filled: false, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search, size: 96.0), + const SizedBox(height: 16), + Text( + FlutterI18n.translate(context, "library.no_result"), + textAlign: TextAlign.center, + ), + ], ), ), - onChanged: (String textFieldValue) => search = textFieldValue, - onFieldSubmitted: (value) { - _pagingController.refresh(); - }, - ).flexible(flex: 2), - const SizedBox(width: 8), - DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedSearchField, - items: SearchField.values - .map( - (SearchField field) => DropdownMenuItem( - value: field, - child: Text(field.getLabel(context)), + ), + noMoreItemsIndicatorBuilder: (context) => + [ + Icon(Icons.sentiment_very_satisfied, size: 32), + SizedBox(width: 8), + Text( + "That's all folks!", + style: Theme.of(context).textTheme.titleLarge, ), - ) - .toList(), - onChanged: (SearchField? newValue) { - if (newValue != null) { - setState(() { - selectedSearchField = newValue; - }); - _pagingController.refresh(); - } - }, - ), - ).flexible(), - ], - ).padding(vertical: 8).constrained(maxWidth: sheetMaxWidth), - PagingListener( - controller: _pagingController, - builder: (context, state, fetchNextPage) => LayoutBuilder( - builder: (context, constraints) => - PagedMasonryGridView.count( - state: state, - fetchNextPage: fetchNextPage, - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, - ), - crossAxisCount: constraints.maxWidth ~/ 360, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => - GestureDetector( - child: BookInfoCard(toUse: item), - onTap: () => BothSideSheet.show( - context: context, - title: FlutterI18n.translate( - context, - "library.book_detail", - ), - child: BookDetailCard(toUse: item), - ), - ) - .padding(horizontal: 12, vertical: 2) - .width(double.infinity) - .constrained(width: sheetMaxWidth) - .center(), - firstPageProgressIndicatorBuilder: (context) => - const Center(child: CircularProgressIndicator()), - firstPageErrorIndicatorBuilder: (context) => ReloadWidget( - function: () async => _pagingController.refresh(), - errorStatus: _pagingController.error, - ), - noItemsFoundIndicatorBuilder: (context) => Center( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 32, - horizontal: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.search, size: 96.0), - const SizedBox(height: 16), - Text( - FlutterI18n.translate( - context, - "library.no_result", - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - noMoreItemsIndicatorBuilder: (context) => - [ - Icon(Icons.sentiment_very_satisfied, size: 32), - SizedBox(width: 8), - Text( - "That's all folks!", - style: Theme.of(context).textTheme.titleLarge, - ), - ] - .toRow( - mainAxisAlignment: MainAxisAlignment.center, - ) - .padding(vertical: 12) - .center(), - ), - ), + ] + .toRow(mainAxisAlignment: MainAxisAlignment.center) + .padding(vertical: 12) + .center(), + ), + ); + }, + ), + ); + } + + Future _openBookDetail(BookInfo item) async { + FocusManager.instance.primaryFocus?.unfocus(); + await BothSideSheet.show( + context: context, + title: FlutterI18n.translate(context, "library.book_detail"), + child: BookDetailCard(toUse: item), + ); + } + + Widget _buildSearchArea(BuildContext context) { + return FutureBuilder( + future: _filterOptionsFuture, + builder: (context, snapshot) { + final options = + snapshot.data ?? search_book.LibrarySearchFilterOptions.fallback(); + return _SearchBlock( + header: _buildSearchModeSwitch(context), + collapsed: + _searchMode == SearchPanelMode.advanced && + _advancedPanelCollapsed, + child: LayoutBuilder( + builder: (context, constraints) { + if (_searchMode == SearchPanelMode.advanced) { + return _buildAdvancedSearchFields( + context, + constraints.maxWidth, + options, + ); + } + return _buildNormalSearchFields( + context, + constraints.maxWidth, + options, + ); + }, + ), + ); + }, + ); + } + + List<_SearchOption> _matchModeOptions(BuildContext context) => [ + search_book.LibrarySearchOption( + "1", + FlutterI18n.translate(context, "library.match_exact"), + "精确", + ), + search_book.LibrarySearchOption( + "2", + FlutterI18n.translate(context, "library.match_fuzzy"), + "模糊", + ), + search_book.LibrarySearchOption( + "3", + FlutterI18n.translate(context, "library.match_prefix"), + "前方", + ), + ]; + + Widget _buildSearchFieldDropdown({ + required BuildContext context, + required String value, + required List<_SearchOption> options, + required ValueChanged onChanged, + int selectedMaxLines = 2, + }) { + return _buildOptionDropdown( + context, + label: FlutterI18n.translate(context, "library.search_field_title"), + value: value, + options: options, + selectedMaxLines: selectedMaxLines, + onChanged: onChanged, + ); + } + + Widget _buildSearchModeSwitch(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + ToggleButtons( + isSelected: [ + _searchMode == SearchPanelMode.normal, + _searchMode == SearchPanelMode.advanced, + ], + borderRadius: BorderRadius.circular(10), + constraints: const BoxConstraints.tightFor(width: 76, height: 34), + selectedColor: colorScheme.onPrimary, + fillColor: colorScheme.primary, + color: colorScheme.primary, + onPressed: (index) { + setState(() { + _searchMode = index == 0 + ? SearchPanelMode.normal + : SearchPanelMode.advanced; + }); + }, + children: [ + Text(FlutterI18n.translate(context, "library.normal_search")), + Text(FlutterI18n.translate(context, "library.advanced_search")), + ], + ), + const Spacer(), + if (_searchMode == SearchPanelMode.advanced) + IconButton( + visualDensity: VisualDensity.compact, + onPressed: () => setState( + () => _advancedPanelCollapsed = !_advancedPanelCollapsed, + ), + icon: Icon( + _advancedPanelCollapsed + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_up, ), - ).expanded(), + tooltip: _advancedPanelCollapsed ? "展开条件" : "收起条件", + ), + ], + ); + } + + Widget _buildAdvancedSearchInput(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextFormField( + controller: _advancedTextController, + decoration: _inputDecoration( + context, + FlutterI18n.translate(context, "library.search_here"), + prefixIcon: Icons.manage_search, + ), + onFieldSubmitted: (_) => _submitAdvancedSearch(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _submitAdvancedSearch, + icon: const Icon(Icons.manage_search), + tooltip: FlutterI18n.translate(context, "library.search"), + ), + ], + ); + } + + Widget _buildNormalSearchFields( + BuildContext context, + double maxWidth, + search_book.LibrarySearchFilterOptions options, + ) { + return Row( + children: [ + SizedBox( + width: maxWidth < 430 ? 128 : 152, + child: _buildSearchFieldDropdown( + context: context, + value: _normalSearchField, + options: options.searchFields, + selectedMaxLines: 1, + onChanged: (value) => setState(() => _normalSearchField = value), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _normalTextController, + decoration: _inputDecoration( + context, + FlutterI18n.translate(context, "library.search_here"), + prefixIcon: Icons.search, + ), + onFieldSubmitted: (_) => _submitNormalSearch(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _submitNormalSearch, + icon: const Icon(Icons.search), + tooltip: FlutterI18n.translate(context, "library.search"), + ), + ], + ); + } + + Widget _buildAdvancedSearchFields( + BuildContext context, + double maxWidth, + search_book.LibrarySearchFilterOptions options, + ) { + return [ + _SearchFields( + maxWidth: maxWidth, + columnCount: 2, + children: [ + _buildSearchFieldDropdown( + context: context, + value: _advancedSearchField, + options: options.searchFields, + onChanged: (value) => setState(() => _advancedSearchField = value), + ), + _buildOptionDropdown( + context, + label: FlutterI18n.translate(context, "library.match_mode"), + value: _advancedMatchMode, + options: _matchModeOptions(context), + onChanged: (value) => setState(() => _advancedMatchMode = value), + ), + _buildOptionDropdown( + context, + label: "资料类型", + value: _documentType, + options: options.documentTypes, + onChanged: (value) => setState(() => _documentType = value), + ), + _buildOptionDropdown( + context, + label: "资源类型", + value: _resourceType, + options: options.resourceTypes, + onChanged: (value) => setState(() => _resourceType = value), + ), + _buildOptionDropdown( + context, + label: "校区", + value: _campusId, + options: options.campuses, + onChanged: (value) => setState(() => _campusId = value), + ), + _buildOptionDropdown( + context, + label: "馆藏地", + value: _locationId, + options: options.locations, + onChanged: (value) => setState(() => _locationId = value), + ), + _buildOptionDropdown( + context, + label: "国别", + value: _countryCode, + options: options.countries, + onChanged: (value) => setState(() => _countryCode = value), + ), + _buildOptionDropdown( + context, + label: "语种", + value: _languageCode, + options: options.languages, + onChanged: (value) => setState(() => _languageCode = value), + ), + _buildOptionDropdown( + context, + label: "出版起始年", + value: _publishBegin, + options: options.years, + onChanged: (value) => setState(() => _publishBegin = value), + ), + _buildOptionDropdown( + context, + label: "出版结束年", + value: _publishEnd, + options: options.years, + onChanged: (value) => setState(() => _publishEnd = value), + ), + CheckboxListTile( + value: _onlyOnShelf, + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text("仅看在架", overflow: TextOverflow.ellipsis), + onChanged: (value) => setState(() => _onlyOnShelf = value ?? false), + ), + OutlinedButton.icon( + onPressed: _resetAdvancedSearch, + icon: const Icon(Icons.clear), + label: const Text("清除"), + ).height(56), ], ), + const SizedBox(height: 8), + _buildAdvancedSearchInput(context), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } + + Widget _buildOptionDropdown( + BuildContext context, { + required String label, + required String value, + required List<_SearchOption> options, + int selectedMaxLines = 2, + required ValueChanged onChanged, + }) { + final selected = options.firstWhere( + (option) => option.value == value, + orElse: () => options.first, + ); + return _OptionPickerField( + label: label, + value: selected.label, + maxLines: selectedMaxLines, + onTap: () async { + final selectedValue = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => _OptionPickerSheet( + title: label, + selectedValue: value, + options: options, + ), + ); + if (selectedValue != null) { + onChanged(selectedValue); + } + }, + ); + } + + InputDecoration _inputDecoration( + BuildContext context, + String label, { + IconData? prefixIcon, + }) => InputDecoration( + labelText: label, + isDense: true, + prefixIcon: prefixIcon == null + ? null + : Icon(prefixIcon, color: Theme.of(context).colorScheme.primary), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ); +} + +class _SearchBlock extends StatelessWidget { + final Widget header; + final Widget child; + final bool collapsed; + + const _SearchBlock({ + required this.header, + required this.child, + this.collapsed = false, + }); + + @override + Widget build(BuildContext context) { + return [ + header, + if (!collapsed) ...[const SizedBox(height: 8), child], + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(horizontal: 10, vertical: 8) + .card(elevation: 0); + } +} + +class _OptionPickerField extends StatelessWidget { + final String label; + final String value; + final int maxLines; + final VoidCallback onTap; + + const _OptionPickerField({ + required this.label, + required this.value, + this.maxLines = 2, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + isDense: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + suffixIcon: const Icon(Icons.arrow_drop_down), + ), + child: Text( + value, + maxLines: maxLines, + overflow: TextOverflow.fade, + softWrap: maxLines > 1, + ), + ), + ); + } +} + +class _OptionPickerSheet extends StatelessWidget { + final String title; + final String selectedValue; + final List<_SearchOption> options; + + const _OptionPickerSheet({ + required this.title, + required this.selectedValue, + required this.options, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: 420, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Expanded( + child: ListView.builder( + itemCount: options.length, + itemBuilder: (context, index) { + final option = options[index]; + final selected = option.value == selectedValue; + return ListTile( + dense: true, + title: Text(option.label, softWrap: true), + trailing: selected ? const Icon(Icons.check) : null, + onTap: () => Navigator.of(context).pop(option.value), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _SearchFields extends StatelessWidget { + final double maxWidth; + final int? columnCount; + final List children; + + const _SearchFields({ + required this.maxWidth, + this.columnCount, + required this.children, + }); + + @override + Widget build(BuildContext context) { + final columns = + columnCount ?? + (maxWidth >= 760 + ? 3 + : maxWidth >= 520 + ? 2 + : 1); + final fieldWidth = (maxWidth - 8 * (columns - 1)) / columns; + return Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: children + .map((child) => SizedBox(width: fieldWidth, child: child)) + .toList(), ); } } diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index 747c203c..bd48aa19 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -9,6 +9,7 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:get_it/get_it.dart'; import 'package:signals/signals_flutter.dart'; @@ -62,6 +63,8 @@ class SettingWindow extends StatefulWidget { State createState() => _SettingWindowState(); } +const int _defaultLowElectricityWarningThreshold = 20; + class _SettingWindowState extends State { Widget _buildListSubtitle(String text) => Text( text, @@ -87,6 +90,81 @@ class _SettingWindowState extends State { } } + bool get _lowElectricityWarningEnabled => + !preference.contains( + preference.Preference.lowElectricityWarningEnabled, + ) || + preference.getBool(preference.Preference.lowElectricityWarningEnabled); + + int get _lowElectricityWarningThreshold { + final value = + preference.contains( + preference.Preference.lowElectricityWarningThreshold, + ) + ? preference.getInt( + preference.Preference.lowElectricityWarningThreshold, + ) + : _defaultLowElectricityWarningThreshold; + return value > 0 ? value : _defaultLowElectricityWarningThreshold; + } + + Future _showLowElectricityThresholdDialog() async { + var inputText = _lowElectricityWarningThreshold.toString(); + + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + FlutterI18n.translate( + context, + "setting.low_electricity_threshold_dialog.title", + ), + ), + content: TextFormField( + autofocus: true, + initialValue: inputText, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + maxLines: 1, + onChanged: (value) => inputText = value, + decoration: InputDecoration( + hintText: FlutterI18n.translate( + context, + "setting.low_electricity_threshold_dialog.input_hint", + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(FlutterI18n.translate(context, "cancel")), + ), + TextButton( + onPressed: () { + final parsed = int.tryParse(inputText); + Navigator.pop( + context, + parsed == null || parsed <= 0 + ? _defaultLowElectricityWarningThreshold + : parsed, + ); + }, + child: Text(FlutterI18n.translate(context, "confirm")), + ), + ], + ), + ); + + if (value == null) return; + await preference.setInt( + preference.Preference.lowElectricityWarningThreshold, + value, + ); + if (mounted) { + setState(() {}); + } + } + @override Widget build(BuildContext context) { List demoBlueModeName = [ @@ -343,6 +421,56 @@ class _SettingWindowState extends State { ), ), const Divider(), + ListTile( + title: Text( + FlutterI18n.translate( + context, + "setting.low_electricity_warning", + ), + ), + subtitle: Text( + FlutterI18n.translate( + context, + "setting.low_electricity_warning_description", + ), + ), + trailing: Switch( + value: _lowElectricityWarningEnabled, + onChanged: (bool value) async { + await preference.setBool( + preference.Preference.lowElectricityWarningEnabled, + value, + ); + if (mounted) { + setState(() {}); + } + }, + ), + ), + const Divider(), + ListTile( + enabled: _lowElectricityWarningEnabled, + title: Text( + FlutterI18n.translate( + context, + "setting.low_electricity_threshold", + ), + ), + subtitle: Text( + FlutterI18n.translate( + context, + "setting.low_electricity_threshold_description", + translationParams: { + "threshold": _lowElectricityWarningThreshold.toString(), + }, + ), + ), + trailing: const Icon(Icons.navigate_next), + onTap: _lowElectricityWarningEnabled + ? _showLowElectricityThresholdDialog + : null, + ), + const Divider(), ListTile( title: Text( FlutterI18n.translate( diff --git a/lib/repository/preference.dart b/lib/repository/preference.dart index ef61add1..3d86dad3 100644 --- a/lib/repository/preference.dart +++ b/lib/repository/preference.dart @@ -147,6 +147,14 @@ enum Preference { key: "classStyleCompletedInnerAlpha", type: "double", ), // 已完成课程底色透明度 + lowElectricityWarningEnabled( + key: "lowElectricityWarningEnabled", + type: "bool", + ), // 首页低电量卡片变色提醒 + lowElectricityWarningThreshold( + key: "lowElectricityWarningThreshold", + type: "int", + ), // 首页低电量卡片变色提醒阈值 homepageInfoOrder( key: "homepageInfoOrder", type: "String", diff --git a/lib/repository/xidian_ids/library_session.dart b/lib/repository/xidian_ids/library_session.dart index 3eee20bb..9de4926a 100644 --- a/lib/repository/xidian_ids/library_session.dart +++ b/lib/repository/xidian_ids/library_session.dart @@ -12,114 +12,496 @@ import 'package:watermeter/repository/xidian_ids/slider_captcha_client.dart'; import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/model/xidian_ids/library.dart'; import 'package:watermeter/repository/xidian_ids/ids_session.dart'; -import 'package:watermeter/repository/preference.dart' as preference; + +List _yearOptions() { + final currentYear = DateTime.now().year; + return [ + const LibrarySearchOption("", "全部"), + for (int year = currentYear; year >= 1950; year--) + LibrarySearchOption(year.toString(), year.toString()), + ]; +} + +class LibrarySearchOption { + final String value; + final String label; + final String shortLabel; + + const LibrarySearchOption(this.value, this.label, [String? shortLabel]) + : shortLabel = shortLabel ?? label; +} + +class LibrarySearchFilterOptions { + final List searchFields; + final List documentTypes; + final List resourceTypes; + final List campuses; + final List locations; + final List countries; + final List languages; + final List years; + + const LibrarySearchFilterOptions({ + required this.searchFields, + required this.documentTypes, + required this.resourceTypes, + required this.campuses, + required this.locations, + required this.countries, + required this.languages, + required this.years, + }); + + factory LibrarySearchFilterOptions.fallback() => LibrarySearchFilterOptions( + searchFields: const [ + LibrarySearchOption("keyWord", "任意词", "任意"), + LibrarySearchOption("title", "题名"), + LibrarySearchOption("author", "责任者"), + LibrarySearchOption("isbn", "标准号", "标准号"), + LibrarySearchOption("callNo", "索书号", "索书"), + LibrarySearchOption("barcode", "条码号", "条码"), + ], + documentTypes: const [ + LibrarySearchOption("", "全部"), + LibrarySearchOption("1", "图书"), + ], + resourceTypes: const [ + LibrarySearchOption("", "全部"), + LibrarySearchOption("1", "馆藏资源"), + LibrarySearchOption("2", "电子资源"), + ], + campuses: const [ + LibrarySearchOption("", "全部"), + LibrarySearchOption("1", "雁塔校区"), + LibrarySearchOption("2", "长安校区"), + ], + locations: const [LibrarySearchOption("", "全部")], + countries: const [ + LibrarySearchOption("", "全部"), + LibrarySearchOption("CN", "中国"), + LibrarySearchOption("US", "美国"), + ], + languages: const [ + LibrarySearchOption("", "全部"), + LibrarySearchOption("chi", "汉语"), + LibrarySearchOption("eng", "英语"), + ], + years: _yearOptions(), + ); +} class LibrarySession extends IDSSession { - // static String token = ""; - // static String groupCode = ""; + static const String _opacBaseUrl = "https://mfindxidian.libsp.cn"; + static const String _opacJwtHeader = "jwtOpacAuth"; + static const String _defaultGroupCode = "200755"; + static const String _opacLoginTarget = + "https://tyrzfw.chaoxing.com/auth/xidian/cas/init?" + "isLogout=0&refer=https%3A%2F%2Fmfindxidian.libsp.cn" + "%2Ffind%2Fsso%2Flogin%2Fxidian%2F1"; + static int userId = 0; static String token = ""; + static String groupCode = ""; - /* Note 1: Search book pattern, no need to implement. - POST https://zs.xianmaigu.com/xidian_book/api/search/getSearchBookType.html - body { "libraryId": 5 } - - Note 2: + /* + Note 1: Scan to borrow book and transfer borrow book will not supported, since I am not an official app, these function may lead me trouble:-P All I want to tell you, is the loanBook.html and borrow.html, that's it. And why Wechat's library app allows to scan the picture? - Note 3: + Note 2: Search the book's info does not require login. You can use it in SPM class... */ - // Options get header => Options( - // headers: { - // HttpHeaders.cookieHeader: "jwt=$token; jwtHeader=jwtOpacAuth", - // "groupCode": groupCode.isEmpty ? "undefined" : groupCode, - // "jwtOpacAuth": token, - // HttpHeaders.refererHeader: "https://findxidian.libsp.cn/", - // HttpHeaders.hostHeader: "findxidian.libsp.cn", - // }, - // )..contentType = "application/json;charset=utf-8"; + Options get _opacOptions => Options( + contentType: "application/json;charset=utf-8", + headers: { + HttpHeaders.cookieHeader: "jwt=$token; jwtHeader=$_opacJwtHeader", + HttpHeaders.refererHeader: "$_opacBaseUrl/", + HttpHeaders.hostHeader: "mfindxidian.libsp.cn", + "groupCode": groupCode.isEmpty ? _defaultGroupCode : groupCode, + "mappingPath": "", + _opacJwtHeader: token, + }, + ); + + Future searchFilterOptions() async { + final fallback = LibrarySearchFilterOptions.fallback(); + try { + final results = await Future.wait([ + _searchFieldOptions(), + _conditionOptions(), + _locationOptions(), + ]); + final conditions = + results[1] + as ({ + List documentTypes, + List countries, + List languages, + }); + return LibrarySearchFilterOptions( + searchFields: results[0] as List, + documentTypes: conditions.documentTypes, + resourceTypes: fallback.resourceTypes, + campuses: fallback.campuses, + locations: results[2] as List, + countries: conditions.countries, + languages: conditions.languages, + years: fallback.years, + ); + } catch (e, s) { + log.handle(e, s, "[LibrarySession] Fetch search filter options failed"); + return fallback; + } + } + + Future> _searchFieldOptions() async { + final response = await dioNoOfflineCheck.get( + "$_opacBaseUrl/find/groupResource/getFindOpacSearchFieldParaList", + options: _opacOptions, + ); + final data = response.data["data"]; + if (data is! List) { + return LibrarySearchFilterOptions.fallback().searchFields; + } + + final options = [ + const LibrarySearchOption("keyWord", "任意词", "任意"), + ]; + for (final item in data.whereType()) { + final label = item["searchField"]?.toString() ?? ""; + final value = _searchFieldValue(label); + if (label.isNotEmpty && value.isNotEmpty) { + options.add( + LibrarySearchOption(value, label, _searchFieldShort(label)), + ); + } + } + return options; + } + + Future< + ({ + List documentTypes, + List countries, + List languages, + }) + > + _conditionOptions() async { + final response = await dioNoOfflineCheck.get( + "$_opacBaseUrl/find/category/getConditionList", + options: _opacOptions, + ); + final data = response.data["data"]; + if (data is! Map) { + final fallback = LibrarySearchFilterOptions.fallback(); + return ( + documentTypes: fallback.documentTypes, + countries: fallback.countries, + languages: fallback.languages, + ); + } + + return ( + documentTypes: _codedOptions(data["documentTypeList"]), + countries: _codedOptions(data["countryList"]), + languages: _codedOptions(data["languageList"]), + ); + } + + Future> _locationOptions() async { + final response = await dioNoOfflineCheck.post( + "$_opacBaseUrl/find/location/list", + data: { + "locationName": "", + "campusIds": [], + "locationTypeCodes": [], + "entrust": 0, + "subscribe": 0, + "page": 1, + "rows": 2000, + }, + options: _opacOptions, + ); + final data = response.data["data"]; + final rawList = data is Map ? data["donateList"] : null; + if (rawList is! List) { + return LibrarySearchFilterOptions.fallback().locations; + } + + final options = [const LibrarySearchOption("", "全部")]; + for (final item in rawList.whereType()) { + final id = item["locationId"]?.toString() ?? ""; + final name = item["locationName"]?.toString() ?? ""; + if (id.isNotEmpty && name.isNotEmpty) { + options.add(LibrarySearchOption(id, name)); + } + } + return options; + } + + List _codedOptions(Object? value) { + final options = [const LibrarySearchOption("", "全部")]; + if (value is! List) return options; + for (final item in value.whereType()) { + final code = item["code"]?.toString() ?? ""; + final name = item["name"]?.toString() ?? ""; + if (code.isNotEmpty && name.isNotEmpty) { + options.add(LibrarySearchOption(code, name)); + } + } + return options; + } + + String _searchFieldValue(String label) => + const { + "题名": "title", + "责任者": "author", + "主题": "subject", + "标准号": "isbn", + "分类号": "kindNo", + "索书号": "callNo", + "出版社": "publisher", + "摘要": "abstract", + "条码号": "barcode", + "工具号": "toolNumber", + }[label] ?? + ""; + + String _searchFieldShort(String label) => + const { + "任意词": "任意", + "责任者": "责任者", + "标准号": "标准号", + "分类号": "分类", + "索书号": "索书", + "条码号": "条码", + "工具号": "工具", + }[label] ?? + label; Future> searchBook( String searchWord, int page, { - String searchField = "title", + String searchField = "keyWord", }) async { if (searchWord.isEmpty) return []; - var rawData = await dio - .post( - "https://shuwo.xidian.edu.cn/xidian_book/api/search/list.html", - data: { - "libraryId": 5, - "searchWord": searchWord, - "searchFiled": searchField, - "page": page, - "searchLocationStatus": 1, + final rawData = await _fetchOpacSearchResult( + "$_opacBaseUrl/find/unify/search", + _opacSearchBody( + searchWord: searchWord, + searchField: searchField, + page: page, + )..addAll({"searchItems": null, "indexSearch": 1}), + ); + return _buildBookInfoList(rawData); + } + + Future> advancedSearchBook( + String searchWord, + int page, { + String searchField = "keyWord", + String matchMode = "2", + String? docCode, + String? resourceType, + int? campusId, + int? locationId, + String? countryCode, + String? langCode, + bool? onlyOnShelf, + String? publishBegin, + String? publishEnd, + }) async { + final rawData = await _fetchOpacSearchResult( + "$_opacBaseUrl/find/unify/advancedSearch", + _opacSearchBody( + searchWord: "", + searchField: searchField, + matchMode: matchMode, + page: page, + docCode: docCode, + resourceType: resourceType, + campusId: campusId, + locationId: locationId, + countryCode: countryCode, + langCode: langCode, + onlyOnShelf: onlyOnShelf, + publishBegin: _emptyToNull(publishBegin), + publishEnd: _emptyToNull(publishEnd), + )..addAll({ + "searchItems": [ + { + "oper": null, + "searchField": searchField, + "matchMode": matchMode, + "searchFieldContent": searchWord, }, - ) - .then((value) { - if (value.data["data"] != null && - value.data["data"]["list"] != null) { - return value.data["data"]["list"]; - } else { - return []; - } - }); + ], + "searchFieldList": null, + }), + ); + return _buildBookInfoList(rawData); + } - List toReturn = []; - final pool = Pool(5); + Future bookCover(String title, String isbn, int docNumber) => + dioNoOfflineCheck + .post( + "$_opacBaseUrl/find/unify/getPItemAndOnShelfCountAndDuxiuImageUrl", + data: {"title": title, "isbn": isbn, "recordId": docNumber}, + options: _opacOptions, + ) + .then((value) { + final data = value.data["data"]; + if (data is! Map) return ""; + return data["duxiuImageUrl"]?.toString() ?? ""; + }); - await Future.wait([ - ...List.generate( - rawData.length, - (index) => BookInfo.fromJson(rawData[index]), - ).map( - (e) => pool.withResource(() async { - e.imageUrl = await bookCover(e.bookName, e.isbn ?? "", e.docNumber); - toReturn.add(e); - }), - ), - ]); + Future> bookLocations(int recordId) async { + if (recordId <= 0) return []; - return toReturn; + final response = await dioNoOfflineCheck.post( + "$_opacBaseUrl/find/physical/groupitems", + data: { + "page": 1, + "rows": 200, + "entrance": null, + "recordId": recordId.toString(), + "isUnify": true, + }, + options: _opacOptions, + ); + + final data = response.data["data"]; + final rawList = data is Map ? data["list"] : null; + if (rawList is! List) return []; + + return rawList + .whereType() + .map((e) => BookLocation.fromOpacJson(Map.from(e))) + .toList(); } - Future bookCover(String title, String isbn, int docNumber) => dio - .post( - "https://findxidian.libsp.cn/find/unify/getPItemAndOnShelfCountAndDuxiuImageUrl", - data: {"title": title, "isbn": isbn, "recordId": docNumber}, - options: Options( - headers: { - HttpHeaders.contentTypeHeader: "application/json", - HttpHeaders.refererHeader: "https://findxidian.libsp.cn/", - "groupCode": "200755", - }, - ), - ) - .then((value) => value.data["data"]["duxiuImageUrl"]?.toString() ?? ""); + Map _opacSearchBody({ + required String searchWord, + required String searchField, + String matchMode = "2", + required int page, + String? docCode, + String? resourceType, + int? campusId, + int? locationId, + String? countryCode, + String? langCode, + bool? onlyOnShelf, + String? publishBegin, + String? publishEnd, + }) => { + "docCode": [docCode], + "litCode": [], + "searchFieldContent": searchWord, + "searchField": searchField, + "matchMode": matchMode, + "resourceType": _stringArray(resourceType), + "subject": [], + "discode1": [], + "publisher": [], + "locationId": _intArray(locationId), + "collectionName": [], + "author": [], + "langCode": _stringArray(langCode), + "countryCode": _stringArray(countryCode), + "publishBegin": publishBegin, + "publishEnd": publishEnd, + "coreInclude": [], + "ddType": [], + "verifyStatus": [], + "group": [], + "sortField": "relevance", + "sortClause": "desc", + "page": page, + "rows": 10, + "onlyOnShelf": onlyOnShelf, + "campusId": _intArray(campusId), + "curLocationId": [], + "eCollectionIds": [], + "kindNo": [], + "libCode": [], + "neweCollectionIds": [], + "newCoreInclude": [], + "customSub": [], + "customSub0": [], + }; - Future renew(BorrowData toUse) => dio - .post( - "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/renewBook.html", + Future>> _fetchOpacSearchResult( + String url, + Map body, + ) => dioNoOfflineCheck.post(url, data: body, options: _opacOptions).then(( + value, + ) { + final data = value.data["data"]; + final searchResult = data is Map ? data["searchResult"] : null; + if (searchResult is! List) { + return >[]; + } + return searchResult + .whereType() + .map((e) => Map.from(e)) + .toList(); + }); + + List _buildBookInfoList(List> rawData) => + rawData.map(BookInfo.fromOpacJson).toList(); + + String? _emptyToNull(String? value) { + if (value == null || value.trim().isEmpty) return null; + return value.trim(); + } + + List _stringArray(String? value) => + value == null || value.isEmpty ? [] : [value]; + + List _intArray(int? value) => value == null ? [] : [value]; + + Future renew(BorrowData toUse) async { + try { + if (token.isEmpty) { + await initSession(); + } + + final response = await dioNoOfflineCheck.post( + "$_opacBaseUrl/find/lendbook/reNew", data: { - "libraryId": 5, - "userId": userId, - "token": token, - "cardNumber": preference.getString(preference.Preference.idsAccount), - "barNumber": toUse.barcode, + "loanIds": [toUse.loanId], }, - ) - .then((value) => value.data["msg"]?.toString() ?? "接口返回错误") - .onError((e, s) { - log.handle(e, s); - return "获取过程遇到错误"; - }); + options: _opacOptions, + ); + final body = response.data; + final data = body["data"]; + final result = data is Map ? data["result"] : null; + final success = data is Map ? data["success"] : null; + + if (success is num && success > 0) { + final newDate = result is Map && result.values.isNotEmpty + ? result.values.first.toString() + : ""; + return newDate.isEmpty ? "续借成功" : "续借成功,新还书日期:$newDate"; + } + + if (result is Map && result.values.isNotEmpty) { + return result.values.join("\n"); + } + return body["message"]?.toString().isNotEmpty == true + ? body["message"].toString() + : "续借失败"; + } catch (e, s) { + log.handle(e, s); + return "获取过程遇到错误"; + } + } Future> getBorrowList() async { log.info( @@ -127,43 +509,50 @@ class LibrarySession extends IDSSession { "Getting borrow list", ); - if (userId == 0 && token == "") { + if (token.isEmpty) { await initSession(); } - var rawData = await dio + final List> rawData = await dioNoOfflineCheck .post( - "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/getBorrowList.html", + "$_opacBaseUrl/find/loanInfo/loanList", data: { - "libraryId": 5, - "userId": userId, - "token": token, - "cardNumber": preference.getString( - preference.Preference.idsAccount, - ), - "page": 0, + "page": 1, + "rows": 999, + "searchType": 1, + "searchContent": "", + "sortType": 0, + "startDate": null, + "endDate": null, }, + options: _opacOptions, ) - .then((value) => value.data["data"]); + .then((value) { + final data = value.data["data"]; + if (data is! Map || data["searchResult"] == null) { + throw NotFetchLibraryException( + message: "Unexpected borrow list response: ${value.data}", + ); + } + final searchResult = data["searchResult"]; + if (searchResult is! List) { + throw NotFetchLibraryException( + message: "Unexpected borrow list payload: ${value.data}", + ); + } + return searchResult.cast>(); + }); List toAppend = []; final pool = Pool(5); await Future.wait([ - ...List.generate( - rawData.length, - (index) => BorrowData.fromJson(rawData[index]), - ).map( - (e) => pool.withResource(() async { - e.imageUrl = await searchBook(e.barcode, 1, searchField: "barcode") - .then( - (books) => books.isNotEmpty - ? LibrarySession().bookCover( - books.first.bookName, - books.first.isbn ?? "", - books.first.docNumber, - ) - : Future.value(""), - ); + ...rawData.map( + (rawItem) => pool.withResource(() async { + final e = BorrowData.fromOpacJson(rawItem); + final recordId = _intValue(rawItem["recordId"]); + e.imageUrl = recordId > 0 + ? await bookCover(e.title, e.isbn, recordId) + : ""; toAppend.add(e); }), ), @@ -178,214 +567,185 @@ class LibrarySession extends IDSSession { return toAppend; } + int _intValue(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; + } + @override Future initSession() async { log.info("[LibrarySession][initSession] Initalizing Library Session"); try { - String? location = await checkAndLogin( - target: - "https://hyytsgxzs.xidian.edu.cn/api/index/casLoginDo.html?" - "libraryId=5&openId=o2b1a5Fg6r8hKcL0FXopEkfmiQGc&source=xdbb", + token = ""; + groupCode = ""; + userId = 0; + + final location = await checkAndLogin( + target: _opacLoginTarget, sliderCaptcha: (String cookieStr) => SliderCaptchaClientProvider(cookie: cookieStr).solve(), ); - RegExp matchJson = RegExp(r'wx.miniProgram.postMessage(.*);'); - String result = ""; - - while (location != null) { - var response = await dio.get(location); - log.info("[LibrarySession][initSession] Received location: $location."); - location = response.headers[HttpHeaders.locationHeader]?[0]; - - if (location == null) { - result = - matchJson - .firstMatch(response.data.toString())?[0]! - .replaceFirst("wx.miniProgram.postMessage(", "") - .replaceFirst("data", "\"data\"") - .replaceFirst(");", "") ?? - ""; - } + await _resolveOpacLogin(location); + if (token.isEmpty) { + throw NotFetchLibraryException(message: "Can not find OPAC JWT."); } - log.info("[LibrarySession][initSession] result is $result"); - var toGet = jsonDecode(result); - - userId = toGet["data"]["id"]; - token = toGet["data"]["token"]; + _loadJwtInfo(token); + await _warmUpOpacSession(); } catch (e, s) { log.handle(e, s); throw NotFetchLibraryException(message: e.toString()); } } - //Future renew(int loanId) async { - // return await dio - // .post( - // "https://findxidian.libsp.cn/find/lendbook/reNew", - // data: { - // "loanIds": [loanId], - // }, - // options: header, - // ) - // .then((value) => value.data["data"]["result"]?.toString() ?? "遇到错误"); - //} - // - //Future getBorrowList() async { - // if (state.value == SessionState.fetching) { - // return; - // } - // log.info( - // "[LibrarySession][getBorrowList] " - // "Getting borrow list", - // ); - // - // try { - // state.value = SessionState.fetching; - // if (token.isEmpty) { - // await initSession(); - // } - // - // if (groupCode.isEmpty) { - // groupCode = await dio - // .post( - // "https://findxidian.libsp.cn/find/homePage/getGroupCode", - // data: {"mappingPath": ""}, - // options: header, - // ) - // .then((value) => value.data["data"]["groupCode"]); - // } - // - // var rawData = await dio - // .post( - // "https://findxidian.libsp.cn/find/loanInfo/loanList", - // data: { - // "page": 1, - // "rows": 999, - // "searchType": 1, - // "searchContent": "", - // "sortType": 0, - // "startDate": null, - // "endDate": null, - // }, - // options: header, - // ) - // .then((value) => value.data["data"]["searchResult"]); - // borrowList.clear(); - // borrowList.addAll( - // List.generate( - // rawData.length, - // (index) => BorrowData.fromJson(rawData[index]), - // ), - // ); - // state.value = SessionState.fetched; - // } catch (e) { - // error.value = e.toString(); - // state.value = SessionState.error; - // } - //} - // - //@override - //Future initSession() async { - // log.info( - // "[LibrarySession][initSession] " - // "Initalizing Library Session", - // ); - // - // token = ""; - // - // try { - // String destinationURL = - // "https://findxidian.libsp.cn/find/sso/login/xidian/0"; - // String location = await checkAndLogin( - // target: "https://tyrzfw.chaoxing.com/auth/xidian/cas/index", - // sliderCaptcha: (String cookieStr) => - // SliderCaptchaClientProvider(cookie: cookieStr).solve(), - // ); - // var response = await dio.get(location); - // - // while (response.headers[HttpHeaders.locationHeader] != null) { - // location = response.headers[HttpHeaders.locationHeader]![0]; - // log.info( - // "[LibrarySession][initSession] " - // "Received location: $location.", - // ); - // response = await dio.get(location); - // } - // - // // God damn, it use js to redirect... - // String toDeal = response.data.toString(); - // String data = RegExp( - // r'data: "(?.*)",', - // ).firstMatch(toDeal)!.namedGroup("data")!; - // int time = int.parse( - // RegExp( - // r'time: (?