diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index d4feac76..cff1b650 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -632,12 +632,46 @@ library: can_be_renewable: Renewable cannot_be_renewable: Not renewable renewing: Renewing + renew_success: Renewed successfully empty_borrow_list: No borrowed books found borrow_list_info: Borrowing {borrow} book(s), among which {dued} book(s) have expired search_book_window: null search_here: Search here book_detail: Book details no_result: No result, change parameter or start your search + scan_borrow: Scan to Borrow + toggle_flash: Toggle flash + scan_book_barcode: Align the holding barcode, then confirm borrowing + scan_expected_book_barcode: 'Scan the holding barcode on "{book}"' + scan_barcode_help: Keep the one-dimensional holding barcode horizontal and fill the frame as much as possible + loading_scanned_book: Loading book information + scan_book_found: Book identified + scan_borrow_available: "Book identified. Borrowable location: {location}" + scan_borrow_unavailable: "Book identified, but unavailable now: {status}" + scan_borrow_no_copy: Book identified, but no borrowable copy was found + scan_expected_book_mismatch: 'Scanned "{actual}", not the book selected in search: "{expected}"' + scan_borrow_fallback: Book identified, borrowable (exact copy location not matched) + confirm_borrow_title: Borrow this book? + confirm_borrow: Confirm borrow + confirm_borrow_real_action: Confirm borrow (will really borrow) + borrow_submitting_notice: Submitting borrow request, please wait + rescan: Scan again + scanned_barcode: "Barcode: {barcode}" + borrow_this_book: Borrow this book + scan_to_borrow_this_book: Scan to borrow this book + search_borrow_warning: Search results cannot be borrowed directly to avoid mistakes. Take the book in the library, scan its holding barcode, then the final confirm action will really submit the borrow request. + borrow_unavailable: No borrowable copy + borrowing_book: Requesting borrow + borrow_submitted: Borrow request submitted + confirm_search_borrow_message: 'Borrow "{book}" from {location}?' + error_barcode_empty: Holding barcode is empty + error_search_failed: Book search failed + error_book_not_found: No matching books found + error_no_search_code: Missing call number, cannot borrow + error_user_barcode_failed: Failed to get reader barcode + error_borrow_failed: Borrow request failed + error_renew_failed: Renew failed + error_session_expired: Library session expired library_card: title: Library status fetching: Fetching diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 80c89e5e..0dadc706 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -560,6 +560,7 @@ library: can_be_renewable: 续借 cannot_be_renewable: 不可续借 renewing: 正在续借 + renew_success: 续借成功 empty_borrow_list: "目前没有查询到在借图书 不借书就要变成上面的小呆瓜咯" @@ -568,6 +569,39 @@ library: search_here: 在此搜索 book_detail: 书籍详细信息 no_result: 没有结果,请修改搜索参数或者开始你的搜索 + scan_borrow: 扫码借书 + toggle_flash: 切换闪光灯 + scan_book_barcode: 对准馆藏条码,扫描后确认借书 + scan_expected_book_barcode: 请扫描《{book}》书上的馆藏条码 + scan_barcode_help: 请横向对准图书上的一维馆藏条码,尽量让条码充满框内 + loading_scanned_book: 正在读取图书信息 + scan_book_found: 已识别图书 + scan_borrow_available: 已识别图书,可借位置:{location} + scan_borrow_unavailable: 已识别图书,但当前不可借:{status} + scan_borrow_no_copy: 已识别图书,但没有找到可借馆藏 + scan_expected_book_mismatch: 扫到的是《{actual}》,不是你在搜索页选择的《{expected}》 + scan_borrow_fallback: 已识别图书,可借(未能匹配精确馆藏位置) + confirm_borrow_title: 确认借阅这本书? + confirm_borrow: 确认借书 + confirm_borrow_real_action: 确认借书(将真实借出) + borrow_submitting_notice: 正在提交借书申请,请稍候 + rescan: 重新扫描 + scanned_barcode: 条码:{barcode} + borrow_this_book: 借阅此书 + scan_to_borrow_this_book: 扫码借阅此书 + search_borrow_warning: 为避免误借,搜索结果不能直接借出。请到馆拿到这本书后,扫描书上的馆藏条码;最终点击确认借书会真实发起借阅。 + borrow_unavailable: 暂无可借馆藏 + borrowing_book: 正在申请借书 + borrow_submitted: 借书申请已提交 + confirm_search_borrow_message: 确认借阅《{book}》?馆藏位置:{location} + error_barcode_empty: 馆藏条码为空 + error_search_failed: 查询图书失败 + error_book_not_found: 未查找到相关书籍 + error_no_search_code: 缺少索书号,无法申请借书 + error_user_barcode_failed: 获取读者条码失败 + error_borrow_failed: 申请借书失败 + error_renew_failed: 续借失败 + error_session_expired: 图书馆登录已失效 library_card: title: 图书馆当前状况 fetching: 正在获取图书馆信息 diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 3f466097..14aae1a9 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -558,6 +558,7 @@ library: can_be_renewable: 續借 cannot_be_renewable: 不可續借 renewing: 正在續借 + renew_success: 續借成功 empty_borrow_list: '目前沒有查詢到在借圖書 不借書就要變成上面的小呆瓜咯' @@ -566,6 +567,39 @@ library: search_here: 在此搜索 book_detail: 書籍詳細信息 no_result: 沒有結果,請修改搜索參數或者開始你的搜索 + scan_borrow: 掃碼借書 + toggle_flash: 切換閃光燈 + scan_book_barcode: 對準館藏條碼,掃描後確認借書 + scan_expected_book_barcode: 請掃描《{book}》書上的館藏條碼 + scan_barcode_help: 請橫向對準圖書上的一維館藏條碼,盡量讓條碼充滿框內 + loading_scanned_book: 正在讀取圖書信息 + scan_book_found: 已識別圖書 + scan_borrow_available: 已識別圖書,可借位置:{location} + scan_borrow_unavailable: 已識別圖書,但當前不可借:{status} + scan_borrow_no_copy: 已識別圖書,但沒有找到可借館藏 + scan_expected_book_mismatch: 掃到的是《{actual}》,不是你在搜索頁選擇的《{expected}》 + scan_borrow_fallback: 已識別圖書,可借(未能匹配精確館藏位置) + confirm_borrow_title: 確認借閱這本書? + confirm_borrow: 確認借書 + confirm_borrow_real_action: 確認借書(將真實借出) + borrow_submitting_notice: 正在提交借書申請,請稍候 + rescan: 重新掃描 + scanned_barcode: 條碼:{barcode} + borrow_this_book: 借閱此書 + scan_to_borrow_this_book: 掃碼借閱此書 + search_borrow_warning: 為避免誤借,搜索結果不能直接借出。請到館拿到這本書後,掃描書上的館藏條碼;最終點擊確認借書會真實發起借閱。 + borrow_unavailable: 暫無可借館藏 + borrowing_book: 正在申請借書 + borrow_submitted: 借書申請已提交 + confirm_search_borrow_message: 確認借閱《{book}》?館藏位置:{location} + error_barcode_empty: 館藏條碼為空 + error_search_failed: 查詢圖書失敗 + error_book_not_found: 未查找到相關書籍 + error_no_search_code: 缺少索書號,無法申請借書 + error_user_barcode_failed: 獲取讀者條碼失敗 + error_borrow_failed: 申請借書失敗 + error_renew_failed: 續借失敗 + error_session_expired: 圖書館登錄已失效 library_card: title: 圖書館當前狀況 fetching: 正在獲取圖書館信息 diff --git a/lib/model/xidian_ids/library.dart b/lib/model/xidian_ids/library.dart index 511cb76b..818a0eb8 100644 --- a/lib/model/xidian_ids/library.dart +++ b/lib/model/xidian_ids/library.dart @@ -137,16 +137,8 @@ class BookInfo { } return searchCode!.first; } - - String get barCodesStr { - if (barCodes == null || barCodes!.isEmpty) { - return "未提供"; - } - return barCodes!.first ?? "未提供"; - } } -@JsonSerializable() class BookLocation { final String? yearVol; final String? locationName; diff --git a/lib/page/library/book_detail_card.dart b/lib/page/library/book_detail_card.dart index 1b933938..b4ca19e1 100644 --- a/lib/page/library/book_detail_card.dart +++ b/lib/page/library/book_detail_card.dart @@ -14,14 +14,78 @@ import 'package:watermeter/repository/logger.dart'; class BookDetailCard extends StatefulWidget { final BookInfo toUse; + final bool showBorrowAction; + final void Function(BuildContext context, BookInfo bookInfo)? onBorrowRequest; - const BookDetailCard({super.key, required this.toUse}); + const BookDetailCard({ + super.key, + required this.toUse, + this.showBorrowAction = true, + this.onBorrowRequest, + }); @override State createState() => _BookDetailCardState(); } class _BookDetailCardState extends State { + BookLocation? get _firstBorrowableLocation { + final items = widget.toUse.items; + if (items == null) return null; + for (final item in items) { + if (item.processType == "在架" && (item.barCode?.isNotEmpty ?? false)) { + return item; + } + } + return null; + } + + Widget _buildBorrowAction(BuildContext context) { + final borrowableLocation = _firstBorrowableLocation; + if (borrowableLocation == null) { + return OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.block), + label: Text( + FlutterI18n.translate(context, "library.borrow_unavailable"), + ), + ); + } + + return [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: [ + Icon( + Icons.warning_rounded, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Text( + FlutterI18n.translate(context, "library.search_borrow_warning"), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ).expanded(), + ].toRow(), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: widget.onBorrowRequest == null + ? null + : () => widget.onBorrowRequest!(context, widget.toUse), + icon: const Icon(Icons.qr_code_scanner), + label: Text( + FlutterI18n.translate(context, "library.scan_to_borrow_this_book"), + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } + @override Widget build(BuildContext context) { return ListView( @@ -212,35 +276,15 @@ 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, - ), - ), - ], - ), - ), ] .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) .flexible(), ], ), + if (widget.showBorrowAction) ...[ + const SizedBox(height: 12), + _buildBorrowAction(context), + ], Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( diff --git a/lib/page/library/borrow_confirm_dialog.dart b/lib/page/library/borrow_confirm_dialog.dart new file mode 100644 index 00000000..4a8363e7 --- /dev/null +++ b/lib/page/library/borrow_confirm_dialog.dart @@ -0,0 +1,133 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class BorrowConfirmDialog extends StatefulWidget { + final String? coverUrl; + final String bookName; + final String? locationName; + + const BorrowConfirmDialog({ + super.key, + this.coverUrl, + required this.bookName, + this.locationName, + }); + + @override + State createState() => _BorrowConfirmDialogState(); +} + +class _BorrowConfirmDialogState extends State { + int _countdown = 5; + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_countdown <= 1) { + _timer?.cancel(); + if (mounted) Navigator.of(context).pop(false); + return; + } + setState(() { + _countdown--; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: widget.coverUrl ?? "", + placeholder: (_, _) => Image.asset( + "assets/art/pda_empty_cover.jpg", + width: 100, + height: 125, + fit: BoxFit.fill, + ), + errorWidget: (_, _, _) => Image.asset( + "assets/art/pda_empty_cover.jpg", + width: 100, + height: 125, + fit: BoxFit.fill, + ), + width: 100, + height: 125, + fit: BoxFit.fitHeight, + alignment: Alignment.center, + ) + .clipRRect(all: 12) + .padding(all: 2) + .decorated( + border: Border.all(color: const Color(0xFFE8E8E8), width: 2), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + const SizedBox(height: 16), + Text( + widget.bookName, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (widget.locationName != null) ...[ + const SizedBox(height: 8), + Text( + widget.locationName!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + actionsAlignment: MainAxisAlignment.center, + actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + actions: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + FlutterI18n.translate(context, "library.confirm_borrow"), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + "${FlutterI18n.translate(context, "cancel")} ($_countdown)", + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/page/library/borrow_info_card.dart b/lib/page/library/borrow_info_card.dart index 21691848..6904b53d 100644 --- a/lib/page/library/borrow_info_card.dart +++ b/lib/page/library/borrow_info_card.dart @@ -9,6 +9,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:intl/intl.dart'; +import 'package:watermeter/controller/library_controller.dart'; import 'package:watermeter/page/public_widget/toast.dart'; import 'package:sn_progress_dialog/progress_dialog.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -181,18 +182,35 @@ class BorrowInfoCard extends StatelessWidget { ), ).padding(bottom: 10); final button = TextButton( - onPressed: () { + onPressed: () async { if (!isOverdue) { ProgressDialog pd = ProgressDialog(context: context); pd.show( msg: FlutterI18n.translate(context, "library.renewing"), ); - LibrarySession().renew(toUse).then((value) { + try { + await LibrarySession().renew(toUse); + if (!context.mounted) return; + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "library.renew_success", + ), + ); + await LibraryController.i.reloadBorrowList(); + } catch (e, s) { + log.handle(e, s, "[BorrowInfoCard][renew] Failed."); + if (!context.mounted) return; + final msg = e is LibraryOperationException && e.i18nKey != null + ? FlutterI18n.translate(context, e.i18nKey!) + : e.toString(); + showToast(context: context, msg: msg); + } finally { if (context.mounted) { pd.close(); - showToast(context: context, msg: value); } - }); + } } }, child: Text( diff --git a/lib/page/library/library_window.dart b/lib/page/library/library_window.dart index 180141ab..ea34feb5 100644 --- a/lib/page/library/library_window.dart +++ b/lib/page/library/library_window.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:watermeter/page/library/borrow_list_window.dart'; +import 'package:watermeter/page/library/scan_borrow_window.dart'; import 'package:watermeter/page/library/search_book_window.dart'; class LibraryWindow extends StatelessWidget { @@ -18,6 +19,19 @@ class LibraryWindow extends StatelessWidget { child: Scaffold( appBar: AppBar( title: Text(FlutterI18n.translate(context, "library.title")), + actions: [ + IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: FlutterI18n.translate(context, "library.scan_borrow"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ScanBorrowWindow(), + ), + ); + }, + ), + ], bottom: TabBar( tabs: [ Tab( diff --git a/lib/page/library/scan_borrow_window.dart b/lib/page/library/scan_borrow_window.dart new file mode 100644 index 00000000..8c638e0c --- /dev/null +++ b/lib/page/library/scan_borrow_window.dart @@ -0,0 +1,608 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:ming_cute_icons/ming_cute_icons.dart'; +import 'package:qr_code_dart_scan/qr_code_dart_scan.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:watermeter/controller/library_controller.dart'; +import 'package:watermeter/model/xidian_ids/library.dart'; +import 'package:watermeter/page/library/book_detail_card.dart'; +import 'package:watermeter/page/library/borrow_confirm_dialog.dart'; +import 'package:watermeter/page/public_widget/toast.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/xidian_ids/library_session.dart'; + +class ScanBorrowWindow extends StatefulWidget { + final BookInfo? expectedBook; + + const ScanBorrowWindow({super.key, this.expectedBook}); + + @override + State createState() => _ScanBorrowWindowState(); +} + +class _ScanBorrowWindowState extends State { + final QRCodeDartScanController _scannerController = + QRCodeDartScanController(); + final LibrarySession _session = LibrarySession(); + + bool _flashOn = false; + bool _handlingScan = false; + bool _loadingBook = false; + bool _submitting = false; + String? _barcode; + BookInfo? _bookInfo; + + @override + void dispose() { + _scannerController.dispose(); + super.dispose(); + } + + Future _handleCapture(Result result) async { + if (_handlingScan) return; + + final barcode = result.text.trim(); + if (barcode.isEmpty) return; + + _handlingScan = true; + await _scannerController.stopScan(); + if (!mounted) return; + + setState(() { + _barcode = barcode; + _bookInfo = null; + _loadingBook = true; + }); + + try { + final book = await _session.getScannedBorrowBook(barcode); + if (!mounted) return; + setState(() { + _bookInfo = book; + }); + showToast(context: context, msg: _borrowStatusFor(context, book).message); + } catch (e, s) { + log.handle(e, s, "[ScanBorrowWindow][_handleCapture] Failed."); + if (!mounted) return; + showToast(context: context, msg: _exceptionMsg(e)); + await _resumeScan(); + } finally { + if (mounted) { + setState(() { + _loadingBook = false; + }); + } + } + } + + String _exceptionMsg(Object e) { + if (e is LibraryOperationException && e.i18nKey != null) { + return FlutterI18n.translate(context, e.i18nKey!); + } + if (e is NotFetchLibraryException && e.i18nKey != null) { + return FlutterI18n.translate(context, e.i18nKey!); + } + return e.toString(); + } + + Future _resumeScan() async { + setState(() { + _barcode = null; + _bookInfo = null; + _handlingScan = false; + _loadingBook = false; + _submitting = false; + }); + await _scannerController.startScan(); + } + + BookLocation? _findLocationByBarcode(BookInfo bookInfo, String barcode) { + final items = bookInfo.items; + final normalizedBarcode = barcode.trim(); + if (items == null || normalizedBarcode.isEmpty) return null; + + for (final item in items) { + if (item.barCode?.trim() == normalizedBarcode) { + return item; + } + } + return null; + } + + BookLocation? _firstBorrowableLocation(BookInfo bookInfo) { + final items = bookInfo.items; + if (items == null) return null; + + for (final item in items) { + if (_isBorrowable(item)) { + return item; + } + } + return null; + } + + bool _isBorrowable(BookLocation location) => + location.processType == "在架" && (location.barCode?.isNotEmpty ?? false); + + bool _matchesExpectedBook(BookInfo bookInfo) { + final expectedBook = widget.expectedBook; + if (expectedBook == null) return true; + if (expectedBook.docNumber == bookInfo.docNumber) return true; + + final expectedIsbn = expectedBook.isbn?.trim(); + final scannedIsbn = bookInfo.isbn?.trim(); + if (expectedIsbn != null && + expectedIsbn.isNotEmpty && + scannedIsbn != null && + scannedIsbn.isNotEmpty && + expectedIsbn == scannedIsbn && + expectedBook.bookName == bookInfo.bookName) { + return true; + } + + return false; + } + + String _firstSearchCode(BookInfo bookInfo) { + final searchCodes = bookInfo.searchCode; + if (searchCodes == null) return ""; + + for (final searchCode in searchCodes) { + if (searchCode.isNotEmpty) return searchCode; + } + return ""; + } + + String _locationLabel(BuildContext context, BookLocation? location) => + location?.locationName ?? + FlutterI18n.translate(context, "library.not_provided"); + + String _unavailableReason(BookLocation location) { + final processType = location.processType.trim(); + if (processType.isNotEmpty && processType != "在架") { + return processType; + } + + final noBorrowMessages = location.noBorrowMessages?.trim(); + if (noBorrowMessages != null && noBorrowMessages.isNotEmpty) { + return noBorrowMessages; + } + + final borrowStatus = location.borrowStatus?.trim(); + if (borrowStatus != null && borrowStatus.isNotEmpty) { + return borrowStatus; + } + + return location.circAttr; + } + + _ScanBorrowStatus _borrowStatusFor(BuildContext context, BookInfo bookInfo) { + final expectedBook = widget.expectedBook; + if (expectedBook != null && !_matchesExpectedBook(bookInfo)) { + return _ScanBorrowStatus( + canBorrow: false, + location: null, + message: FlutterI18n.translate( + context, + "library.scan_expected_book_mismatch", + translationParams: { + "expected": expectedBook.bookName, + "actual": bookInfo.bookName, + }, + ), + ); + } + + final barcode = _barcode?.trim() ?? ""; + final scannedLocation = _findLocationByBarcode(bookInfo, barcode); + + if (scannedLocation != null) { + if (_isBorrowable(scannedLocation)) { + return _ScanBorrowStatus( + canBorrow: true, + location: scannedLocation, + message: FlutterI18n.translate( + context, + "library.scan_borrow_available", + translationParams: { + "location": _locationLabel(context, scannedLocation), + }, + ), + ); + } + + return _ScanBorrowStatus( + canBorrow: false, + location: scannedLocation, + message: FlutterI18n.translate( + context, + "library.scan_borrow_unavailable", + translationParams: {"status": _unavailableReason(scannedLocation)}, + ), + ); + } + + final borrowableLocation = _firstBorrowableLocation(bookInfo); + if (borrowableLocation != null) { + return _ScanBorrowStatus( + canBorrow: true, + location: null, + message: FlutterI18n.translate( + context, + "library.scan_borrow_fallback", + ), + ); + } + + return _ScanBorrowStatus( + canBorrow: false, + location: null, + message: FlutterI18n.translate(context, "library.scan_borrow_no_copy"), + ); + } + + Future _submitBorrow() async { + final barcode = _barcode; + final bookInfo = _bookInfo; + if (barcode == null || bookInfo == null || _submitting) return; + + final borrowStatus = _borrowStatusFor(context, bookInfo); + if (!borrowStatus.canBorrow) { + showToast(context: context, msg: borrowStatus.message); + return; + } + + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => BorrowConfirmDialog( + coverUrl: bookInfo.imageUrl, + bookName: bookInfo.bookName, + locationName: borrowStatus.location?.locationName, + ), + ); + + if (confirmed != true || !mounted) return; + + setState(() { + _submitting = true; + }); + showToast( + context: context, + msg: FlutterI18n.translate(context, "library.borrow_submitting_notice"), + ); + + try { + await _session.borrowBook( + barcode: barcode, + bookInfo: bookInfo, + searchCode: + borrowStatus.location?.searchCode ?? _firstSearchCode(bookInfo), + ); + if (!mounted) return; + showToast( + context: context, + msg: FlutterI18n.translate(context, "library.borrow_submitted"), + ); + await LibraryController.i.reloadBorrowList(); + if (!mounted) return; + Navigator.of(context).pop(); + } catch (e, s) { + log.handle(e, s, "[ScanBorrowWindow][_submitBorrow] Failed."); + if (!mounted) return; + showToast(context: context, msg: _exceptionMsg(e)); + setState(() { + _submitting = false; + }); + } + } + + Future _toggleFlash() async { + await _scannerController.toggleFlash(); + if (!mounted) return; + setState(() { + _flashOn = _scannerController.isFlashOn; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(FlutterI18n.translate(context, "library.scan_borrow")), + actions: [ + IconButton( + icon: Icon(_flashOn ? Icons.flash_on : Icons.flash_off), + onPressed: _toggleFlash, + tooltip: FlutterI18n.translate(context, "library.toggle_flash"), + ), + ], + ), + body: Stack( + children: [ + Positioned.fill( + child: QRCodeDartScanView( + controller: _scannerController, + resolutionPreset: QRCodeDartScanResolutionPreset.veryHigh, + imageDecodeOrientation: ImageDecodeOrientation.portrait, + intervalScan: const Duration(milliseconds: 400), + onCapture: _handleCapture, + child: const ColoredBox(color: Colors.black), + ), + ), + Positioned.fill(child: IgnorePointer(child: _ScanOverlay())), + Positioned( + left: 16, + right: 16, + bottom: 24, + child: SafeArea(child: _buildBottomPanel(context)), + ), + ], + ), + ); + } + + Widget _buildBottomPanel(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (_loadingBook) { + return Card( + child: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(FlutterI18n.translate(context, "library.loading_scanned_book")), + ].toRow().padding(all: 16), + ); + } + + final bookInfo = _bookInfo; + if (bookInfo == null) { + return Card( + color: colorScheme.surface.withValues(alpha: 0.94), + child: [ + Icon(MingCuteIcons.mgc_barcode_line, color: colorScheme.primary), + const SizedBox(width: 12), + [ + Text( + widget.expectedBook == null + ? FlutterI18n.translate(context, "library.scan_book_barcode") + : FlutterI18n.translate( + context, + "library.scan_expected_book_barcode", + translationParams: { + "book": widget.expectedBook!.bookName, + }, + ), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + FlutterI18n.translate(context, "library.scan_barcode_help"), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch).expanded(), + ].toRow().padding(all: 16), + ); + } + + final borrowStatus = _borrowStatusFor(context, bookInfo); + return Card( + color: colorScheme.surface.withValues(alpha: 0.97), + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.64, + child: + [ + Text( + FlutterI18n.translate(context, "library.scan_book_found"), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + _buildBorrowStatusBanner(context, borrowStatus), + const SizedBox(height: 12), + BookDetailCard( + toUse: bookInfo, + showBorrowAction: false, + ).expanded(), + const SizedBox(height: 12), + [ + OutlinedButton( + onPressed: _submitting ? null : _resumeScan, + child: Text( + FlutterI18n.translate(context, "library.rescan"), + ), + ).expanded(), + const SizedBox(width: 12), + FilledButton( + onPressed: _submitting || !borrowStatus.canBorrow + ? null + : _submitBorrow, + child: _submitting + ? [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text( + FlutterI18n.translate( + context, + "library.borrowing_book", + ), + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.center) + : Text( + FlutterI18n.translate( + context, + "library.confirm_borrow", + ), + ), + ).expanded(), + ].toRow(), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(all: 16), + ), + ); + } + + Widget _buildBorrowStatusBanner( + BuildContext context, + _ScanBorrowStatus borrowStatus, + ) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = borrowStatus.canBorrow + ? Colors.green.shade900 + : colorScheme.onErrorContainer; + final background = borrowStatus.canBorrow + ? Colors.green.shade100 + : colorScheme.errorContainer; + final barcode = _barcode ?? ""; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + child: [ + Icon( + borrowStatus.canBorrow ? Icons.check_circle : Icons.info, + color: foreground, + ), + const SizedBox(width: 10), + [ + Text( + borrowStatus.message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + if (barcode.isNotEmpty) + Text( + FlutterI18n.translate( + context, + "library.scanned_barcode", + translationParams: {"barcode": barcode}, + ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: foreground), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch).expanded(), + ].toRow(), + ); + } +} + +class _ScanBorrowStatus { + final bool canBorrow; + final BookLocation? location; + final String message; + + const _ScanBorrowStatus({ + required this.canBorrow, + required this.location, + required this.message, + }); +} + +class _ScanOverlay extends StatefulWidget { + @override + State<_ScanOverlay> createState() => _ScanOverlayState(); +} + +class _ScanOverlayState extends State<_ScanOverlay> + with SingleTickerProviderStateMixin { + late final AnimationController _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(reverse: true); + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.2), + ), + ), + ), + Center( + child: SizedBox( + width: (MediaQuery.sizeOf(context).width - 48) + .clamp(280.0, 420.0) + .toDouble(), + height: 170, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) => Align( + alignment: Alignment( + 0, + -0.92 + _animationController.value * 1.84, + ), + child: child, + ), + child: Container( + height: 4, + margin: const EdgeInsets.symmetric(horizontal: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: LinearGradient( + colors: [ + colorScheme.primary.withValues(alpha: 0.0), + colorScheme.primary.withValues(alpha: 0.95), + colorScheme.primary.withValues(alpha: 0.0), + ], + ), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.55), + blurRadius: 14, + spreadRadius: 2, + ), + ], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: colorScheme.primary, width: 3), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/page/library/search_book_window.dart b/lib/page/library/search_book_window.dart index 50aa0e51..6ce92e43 100644 --- a/lib/page/library/search_book_window.dart +++ b/lib/page/library/search_book_window.dart @@ -14,6 +14,7 @@ import 'package:watermeter/repository/xidian_ids/library_session.dart' 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'; +import 'package:watermeter/page/library/scan_borrow_window.dart'; enum SearchField { keyWord, title, author, isbn, barcode, callNo } @@ -191,7 +192,25 @@ class _SearchBookWindowState extends State context, "library.book_detail", ), - child: BookDetailCard(toUse: item), + child: BookDetailCard( + toUse: item, + onBorrowRequest: (sheetContext, bookInfo) { + final navigator = Navigator.of(context); + Navigator.of(sheetContext).pop(); + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (!context.mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => + ScanBorrowWindow( + expectedBook: bookInfo, + ), + ), + ); + }); + }, + ), ), ) .padding(horizontal: 12, vertical: 2) diff --git a/lib/repository/xidian_ids/library_session.dart b/lib/repository/xidian_ids/library_session.dart index 3eee20bb..51350268 100644 --- a/lib/repository/xidian_ids/library_session.dart +++ b/lib/repository/xidian_ids/library_session.dart @@ -72,20 +72,21 @@ class LibrarySession extends IDSSession { } }); - List toReturn = []; + final toReturn = List.generate( + rawData.length, + (index) => BookInfo.fromJson(rawData[index]), + ); final pool = Pool(5); - 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); - }), - ), - ]); + await Future.wait( + List.generate(toReturn.length, (i) => pool.withResource(() async { + toReturn[i].imageUrl = await bookCover( + toReturn[i].bookName, + toReturn[i].isbn ?? "", + toReturn[i].docNumber, + ); + })), + ); return toReturn; } @@ -104,22 +105,184 @@ class LibrarySession extends IDSSession { ) .then((value) => value.data["data"]["duxiuImageUrl"]?.toString() ?? ""); - Future renew(BorrowData toUse) => dio - .post( - "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/renewBook.html", - data: { - "libraryId": 5, - "userId": userId, - "token": token, - "cardNumber": preference.getString(preference.Preference.idsAccount), - "barNumber": toUse.barcode, - }, - ) - .then((value) => value.data["msg"]?.toString() ?? "接口返回错误") - .onError((e, s) { - log.handle(e, s); - return "获取过程遇到错误"; - }); + Future renew(BorrowData toUse) async { + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/renewBook.html", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "cardNumber": preference.getString(preference.Preference.idsAccount), + "barNumber": toUse.barcode, + "bookName": toUse.title, + "isbn": toUse.isbn, + "author": toUse.author, + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"]?.toString() == "1") { + return "续借成功"; + } + + throw LibraryOperationException( + message: data is Map ? data["msg"]?.toString() ?? "续借失败" : "续借失败", + i18nKey: "library.error_renew_failed", + ); + } + + Future getScannedBorrowBook(String barcode) async { + if (barcode.isEmpty) { + throw LibraryOperationException( + message: "馆藏条码为空", + i18nKey: "library.error_barcode_empty", + ); + } + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/search/list.html", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "cardNumber": preference.getString(preference.Preference.idsAccount), + "searchWord": barcode, + "searchFiled": "barcode", + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"] != null && data["code"] != 1) { + throw LibraryOperationException( + message: data["msg"]?.toString() ?? "查询图书失败", + i18nKey: "library.error_search_failed", + ); + } + + final rawList = data["data"]?["list"]; + if (rawList is! List || rawList.isEmpty) { + throw LibraryOperationException( + message: "未查找到相关书籍", + i18nKey: "library.error_book_not_found", + ); + } + + final book = BookInfo.fromJson(rawList.first as Map); + book.imageUrl = await bookCover( + book.bookName, + book.isbn ?? "", + book.docNumber, + ); + return book; + } + + Future borrowScannedBook({ + required String barcode, + required BookInfo bookInfo, + }) => borrowBook(barcode: barcode, bookInfo: bookInfo); + + Future borrowBook({ + required String barcode, + required BookInfo bookInfo, + String? searchCode, + }) async { + if (barcode.isEmpty) { + throw LibraryOperationException( + message: "馆藏条码为空", + i18nKey: "library.error_barcode_empty", + ); + } + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final userBarcode = await _getUserBarcode(); + final searchCodeToUse = searchCode ?? bookInfo.searchCode?.firstOrNull; + if (searchCodeToUse == null || searchCodeToUse.isEmpty) { + throw LibraryOperationException( + message: "缺少索书号,无法申请借书", + i18nKey: "library.error_no_search_code", + ); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/borrow.html", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "barNumber": barcode, + "cardNumber": userBarcode, + "bookName": bookInfo.bookName, + "author": bookInfo.author ?? "", + "searchCode": searchCodeToUse, + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"] == 1) { + final msg = data["msg"]?.toString(); + if (msg != null && msg.isNotEmpty) return msg; + final dataStr = data["data"]?.toString(); + if (dataStr != null && dataStr.isNotEmpty) return dataStr; + return "借书申请已提交"; + } + throw LibraryOperationException( + message: data is Map ? data["msg"]?.toString() ?? "申请借书失败" : "申请借书失败", + i18nKey: "library.error_borrow_failed", + ); + } + + Future _getUserBarcode() async { + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/getUserInfo", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "cardNumber": preference.getString(preference.Preference.idsAccount), + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"] == 1) { + final userBarcode = data["data"]?["userBarcode"]?.toString(); + if (userBarcode != null && userBarcode.isNotEmpty) { + return userBarcode; + } + } + + throw LibraryOperationException( + message: data is Map ? data["msg"]?.toString() ?? "获取读者条码失败" : "获取读者条码失败", + i18nKey: "library.error_user_barcode_failed", + ); + } + + void _throwIfLibrarySessionExpired(dynamic data) { + if (data is Map && data["code"] == -1) { + userId = 0; + token = ""; + throw NotFetchLibraryException( + message: data["msg"]?.toString() ?? "图书馆登录已失效", + i18nKey: "library.error_session_expired", + ); + } + } Future> getBorrowList() async { log.info( @@ -127,7 +290,7 @@ class LibrarySession extends IDSSession { "Getting borrow list", ); - if (userId == 0 && token == "") { + if (userId == 0 || token.isEmpty) { await initSession(); } @@ -144,7 +307,10 @@ class LibrarySession extends IDSSession { "page": 0, }, ) - .then((value) => value.data["data"]); + .then((value) { + _throwIfLibrarySessionExpired(value.data); + return value.data["data"]; + }); List toAppend = []; final pool = Pool(5); @@ -157,7 +323,7 @@ class LibrarySession extends IDSSession { e.imageUrl = await searchBook(e.barcode, 1, searchField: "barcode") .then( (books) => books.isNotEmpty - ? LibrarySession().bookCover( + ? bookCover( books.first.bookName, books.first.isbn ?? "", books.first.docNumber, @@ -170,9 +336,11 @@ class LibrarySession extends IDSSession { ]); toAppend.sort( - (a, b) => - a.normReturnDateTime.millisecondsSinceEpoch - - b.normReturnDateTime.millisecondsSinceEpoch, + (a, b) { + final returnCmp = a.normReturnDateTime.compareTo(b.normReturnDateTime); + if (returnCmp != 0) return returnCmp; + return a.loanDateTime.compareTo(b.loanDateTime); + }, ); return toAppend; @@ -390,5 +558,18 @@ class LibrarySession extends IDSSession { class NotFetchLibraryException implements Exception { final String message; - NotFetchLibraryException({this.message = "Error detected."}); + final String? i18nKey; + NotFetchLibraryException({this.message = "Error detected.", this.i18nKey}); + + @override + String toString() => message; +} + +class LibraryOperationException implements Exception { + final String message; + final String? i18nKey; + LibraryOperationException({required this.message, this.i18nKey}); + + @override + String toString() => message; }