From 23d4953d898e94cb31b76202bf92ab996a59e79d Mon Sep 17 00:00:00 2001 From: shingyu Date: Thu, 28 Aug 2025 16:58:18 +0800 Subject: [PATCH] add search --- lib/core/api_client.dart | 30 ++++++++++ lib/models/book.dart | 29 ++++++++++ lib/providers/search_provider.dart | 67 ++++++++++++++++++++++ lib/ui/screens/home_screen.dart | 78 +++++++++++++------------ lib/ui/widgets/search_result_view.dart | 79 ++++++++++++++++++++++++++ pubspec.yaml | 2 +- 6 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 lib/providers/search_provider.dart create mode 100644 lib/ui/widgets/search_result_view.dart diff --git a/lib/core/api_client.dart b/lib/core/api_client.dart index 87051e8..360f690 100644 --- a/lib/core/api_client.dart +++ b/lib/core/api_client.dart @@ -40,6 +40,36 @@ class ApiClient { return headers; } + Future> searchBooks(String keyword) async { + final params = { + 'extend': '', + 'tab': '0', + 'gender': '0', + 'refresh_state': '8', + 'page': '1', + 'wd': keyword, + 'is_short_story_user': '0' + }; + params['sign'] = _generateSignature(params, _signKey); + + final response = await _dio.get( + "$_baseUrlBc/search/v1/words", + queryParameters: params, + options: Options(headers: _getHeaders('00000000')), + ); + + if (response.statusCode == 200 && response.data['data'] != null) { + List books = response.data['data']['books'] ?? []; + return books + .where((json) => + json['id'] != null && json['id'].toString().isNotEmpty) + .map((json) => SearchResultBook.fromSearchJson(json)) + .toList(); + } else { + throw Exception('搜索失败: ${response.data['message']}'); + } + } + Future fetchBookInfo(String bookId) async { final params = {'id': bookId, 'imei_ip': '2937357107', 'teeny_mode': '0'}; params['sign'] = _generateSignature(params, _signKey); diff --git a/lib/models/book.dart b/lib/models/book.dart index 2c4b615..c628c90 100644 --- a/lib/models/book.dart +++ b/lib/models/book.dart @@ -84,4 +84,33 @@ class BookChapter { sort: _parseInt(json['chapter_sort']), ); } +} + +class SearchResultBook { + final String id; + final String title; + final String author; + final bool isOver; + + SearchResultBook({ + required this.id, + required this.title, + required this.author, + required this.isOver, + }); + + factory SearchResultBook.fromSearchJson(Map json) { + // Helper function to remove HTML tags + String _removeHtmlTags(String htmlText) { + RegExp exp = RegExp(r"<[^>]*>", multiLine: true, caseSensitive: true); + return htmlText.replaceAll(exp, ''); + } + + return SearchResultBook( + id: json['id']?.toString() ?? '', + title: _removeHtmlTags(json['title'] ?? '无书名'), + author: _removeHtmlTags(json['author'] ?? '未知作者'), + isOver: json['is_over'] == '1', + ); + } } \ No newline at end of file diff --git a/lib/providers/search_provider.dart b/lib/providers/search_provider.dart new file mode 100644 index 0000000..df9909f --- /dev/null +++ b/lib/providers/search_provider.dart @@ -0,0 +1,67 @@ +// lib/providers/search_provider.dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/book.dart'; +import '../core/api_client.dart'; + +class SearchState { + final List searchResults; + final bool isLoading; + final String? error; + + SearchState({ + this.searchResults = const [], + this.isLoading = false, + this.error, + }); + + SearchState copyWith({ + List? searchResults, + bool? isLoading, + String? error, + }) { + return SearchState( + searchResults: searchResults ?? this.searchResults, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class SearchNotifier extends StateNotifier { + final ApiClient _apiClient; + + SearchNotifier(this._apiClient) : super(SearchState()); + + Future searchBooks(String keyword) async { + if (keyword.trim().isEmpty) { + state = SearchState(searchResults: []); + return; + } + + state = state.copyWith(isLoading: true, error: null); + + try { + final results = await _apiClient.searchBooks(keyword); + state = state.copyWith( + searchResults: results, + isLoading: false, + ); + } catch (e) { + state = state.copyWith( + error: e.toString(), + isLoading: false, + ); + } + } + + void clearSearch() { + state = SearchState(); + } +} + +final apiClientProvider = Provider((ref) => ApiClient()); + +final searchProvider = StateNotifierProvider((ref) { + final apiClient = ref.watch(apiClientProvider); + return SearchNotifier(apiClient); +}); \ No newline at end of file diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 67e1096..69c2864 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -5,9 +5,11 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:flutter/gestures.dart'; import '../../providers/book_provider.dart'; import '../../providers/theme_provider.dart'; +import '../../providers/search_provider.dart'; import '../widgets/book_detail_view.dart'; import '../widgets/responsive_layout.dart'; import '../widgets/status_bar.dart'; +import '../widgets/search_result_view.dart'; import '../../globals.dart'; String? _parseBookIdInput(String input) { @@ -45,51 +47,53 @@ class HomeScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final searchController = TextEditingController(); + ref.listen(searchProvider, (previous, next) { + final isMobile = MediaQuery.of(context).size.width < 600; + if (isMobile && (previous?.isLoading ?? false) && !next.isLoading) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('搜索结果'), + content: SizedBox( + width: double.maxFinite, + child: SearchResultView( + onResultSelected: () { + Navigator.of(dialogContext).pop(); + }, + ), + ), + actions: [ + TextButton( + child: const Text('关闭'), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ], + ), + ); + } + }); + // --- 核心修改点: 重构 performSearch 方法 --- void performSearch() { final rawInput = searchController.text.trim(); if (rawInput.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请输入小说ID或链接')), + const SnackBar(content: Text('请输入小说ID、链接或关键词')), ); return; } - // 调用解析函数 final String? parsedId = _parseBookIdInput(rawInput); - // 根据解析结果执行操作 if (parsedId != null) { - // --- 逻辑分支 1 & 2: 成功解析 --- - - // 如果解析出的ID与原始输入不同(说明是从URL中提取的),则更新输入框 if (parsedId != rawInput) { searchController.text = parsedId; } - - // 使用干净的ID执行搜索 ref.read(bookProvider.notifier).fetchBook(parsedId); - + ref.read(searchProvider.notifier).clearSearch(); } else { - // --- 逻辑分支 3: 解析失败 --- - - // 弹窗报错 - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('输入无效'), - content: const Text('请输入纯数字ID或有效的七猫小说链接。'), - actions: [ - TextButton( - child: const Text('确定'), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - - // 清除输入框内容 - searchController.clear(); + ref.read(searchKeywordProvider.notifier).state = rawInput; + ref.read(searchProvider.notifier).searchBooks(rawInput); } } @@ -99,11 +103,10 @@ class HomeScreen extends ConsumerWidget { child: TextField( controller: searchController, decoration: InputDecoration( - labelText: '小说ID', - hintText: '在此输入七猫小说ID', - // 设置所有边框状态的圆角 + labelText: '搜索', + hintText: '输入小说ID、链接或关键词', border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24.0), // 从默认值增大 + borderRadius: BorderRadius.circular(24.0), ), suffixIcon: IconButton( icon: const Icon(Icons.search), @@ -212,14 +215,9 @@ class HomeScreen extends ConsumerWidget { child: Column( children: [ searchBar, - // 可以在这里添加搜索历史或推荐列表 - const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - '输入小说ID后,详细信息将显示在右侧。', - textAlign: TextAlign.center, - ), - ) + const Expanded( + child: SearchResultView(), + ), ], ), ), diff --git a/lib/ui/widgets/search_result_view.dart b/lib/ui/widgets/search_result_view.dart new file mode 100644 index 0000000..c1bef5a --- /dev/null +++ b/lib/ui/widgets/search_result_view.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:swiftcat_downloader/providers/book_provider.dart'; +import 'package:swiftcat_downloader/providers/search_provider.dart'; + +class SearchResultView extends ConsumerWidget { + final VoidCallback? onResultSelected; + + const SearchResultView({super.key, this.onResultSelected}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchState = ref.watch(searchProvider); + final selectedBookId = ref.watch(selectedBookIdProvider); + + if (searchState.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (searchState.error != null) { + return Center(child: Text('搜索出错: ${searchState.error}')); + } + + if (searchState.searchResults.isEmpty) { + final searchKeyword = ref.watch(searchKeywordProvider); + if (searchKeyword.isNotEmpty) { + return Center(child: Text('没有找到与“$searchKeyword”相关的结果。')); + } + return const Center(child: Text('没有搜索结果。')); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text('共找到 ${searchState.searchResults.length} 条结果。', style: Theme.of(context).textTheme.titleMedium), + ), + Expanded( + child: ListView.builder( + itemCount: searchState.searchResults.length, + itemBuilder: (context, index) { + final book = searchState.searchResults[index]; + final status = book.isOver ? '完结' : '连载中'; + return RadioListTile( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('《${book.title}》'), + Text( + book.author, + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + status, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + value: book.id, + groupValue: selectedBookId, + onChanged: (value) { + if (value != null) { + ref.read(selectedBookIdProvider.notifier).state = value; + ref.read(bookProvider.notifier).fetchBook(value); + onResultSelected?.call(); + } + }, + ); + }, + ), + ), + ], + ); + } +} + +final selectedBookIdProvider = StateProvider((ref) => null); +final searchKeywordProvider = StateProvider((ref) => ''); \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 4a001a3..6f88e28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+202507240 +version: 1.0.1+202508280 environment: sdk: ^3.8.1