add search
This commit is contained in:
@@ -40,6 +40,36 @@ class ApiClient {
|
||||
return headers;
|
||||
}
|
||||
|
||||
Future<List<SearchResultBook>> 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<dynamic> 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<Book> fetchBookInfo(String bookId) async {
|
||||
final params = {'id': bookId, 'imei_ip': '2937357107', 'teeny_mode': '0'};
|
||||
params['sign'] = _generateSignature(params, _signKey);
|
||||
|
||||
@@ -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<String, dynamic> 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/providers/search_provider.dart
Normal file
67
lib/providers/search_provider.dart
Normal file
@@ -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<SearchResultBook> searchResults;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
SearchState({
|
||||
this.searchResults = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
SearchState copyWith({
|
||||
List<SearchResultBook>? searchResults,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return SearchState(
|
||||
searchResults: searchResults ?? this.searchResults,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchNotifier extends StateNotifier<SearchState> {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
SearchNotifier(this._apiClient) : super(SearchState());
|
||||
|
||||
Future<void> 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<SearchNotifier, SearchState>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return SearchNotifier(apiClient);
|
||||
});
|
||||
@@ -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<SearchState>(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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
79
lib/ui/widgets/search_result_view.dart
Normal file
79
lib/ui/widgets/search_result_view.dart
Normal file
@@ -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<String>(
|
||||
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<String?>((ref) => null);
|
||||
final searchKeywordProvider = StateProvider<String>((ref) => '');
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user