Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
41ae738ad7
|
|||
|
23d4953d89
|
|||
|
66779421c7
|
|||
|
97c59aac8e
|
|||
|
a35f1ebc52
|
|||
|
b668d8c218
|
|||
|
768bd4b71b
|
|||
|
53171e900d
|
54
.github/workflows/build-android.yml
vendored
Normal file
54
.github/workflows/build-android.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# 由于安卓构建需要较长时间,故创建备用的独立工作流
|
||||
name: Build Android App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# 允许手动触发工作流
|
||||
|
||||
jobs:
|
||||
#-------------------------------------------------
|
||||
# Android (Unchanged, as it builds a universal app)
|
||||
#-------------------------------------------------
|
||||
build_android:
|
||||
name: Build Android
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Decode Keystore
|
||||
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
|
||||
|
||||
- name: Create key.properties file
|
||||
run: |
|
||||
echo "storeFile=$(pwd)/android/app/upload-keystore.jks" > android/key.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/key.properties
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
||||
|
||||
- name: Build Flutter App Bundle and APK
|
||||
run: |
|
||||
flutter build appbundle --release
|
||||
flutter build apk --release
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-builds
|
||||
path: |
|
||||
build/app/outputs/bundle/release/app-release.aab
|
||||
build/app/outputs/flutter-apk/app-release.apk
|
||||
49
README.md
49
README.md
@@ -1,4 +1,4 @@
|
||||
# SwiftCat Downloader Flutter
|
||||
# 灵猫小说下载器 Flutter
|
||||
|
||||
一个用于下载七猫小说的工具。
|
||||
|
||||
@@ -6,14 +6,57 @@
|
||||
|
||||
**Flutter 版本是技术测试版本,不保证稳定性。**
|
||||
|
||||
**Flutter 版本不支持Epub下载格式。**
|
||||
|
||||
## 特性
|
||||
|
||||
- [x] 支持保存为TXT格式
|
||||
- [x] 支持保存为单文件或按章节保存*
|
||||
- [x] 极快的下载速度
|
||||
- [x] 漂亮的用户界面
|
||||
- [x] 全平台支持*
|
||||
- [ ] 下载为EPUB格式
|
||||
- [ ] 通过书名搜索小说
|
||||
|
||||
*按章节保存支持平台:Android、iOS、macOS、Windows、Linux
|
||||
*全平台支持:Android、iOS、macOS、Windows、Linux、Web
|
||||
|
||||
## 特定于平台的说明
|
||||
|
||||
#### Android
|
||||
由于 Android 10 及以上版本的存储权限限制,目前 Android 版本只能保存到 Download 文件夹。
|
||||
支持的架构:arm64-v8a、armeabi-v7a、x86_64
|
||||
#### iOS
|
||||
由于签名需要向Apple缴纳开发者费用,iOS 版本提供的ipa文件需要您自行使用第三方工具(如 爱思助手)进行签名后才能安装。
|
||||
我们不提供关于签名的技术支持,请您自行查找相关资料。
|
||||
#### macOS
|
||||
macOS 版本由于需要自定义文件保存路径,故运行时未使用沙盒模式,请您仅从信任的来源下载应用。
|
||||
您可能需要执行以下操作来在新版本的 macOS 上运行应用:
|
||||
1. 打开“终端”应用
|
||||
2. 输入并回车执行 `sudo spctl --global-disable`,
|
||||
输入您的管理员密码(输入时不会显示)并回车;
|
||||
3. 打开 系统设置 > 隐私与安全性 > 常规,
|
||||
在“允许以下来源的应用程序”下选择“任何来源”,输入密码并同意。
|
||||
4. 在“终端”应用中输入并回车执行 `sudo xattr -r -d com.apple.quarantine /Applications/灵猫小说下载器.app`
|
||||
5. 运行应用。
|
||||
支持的架构:arm64 (Apple Silicon)、x86_64 (Intel)
|
||||
#### Linux
|
||||
支持的架构:arm64、x86_64
|
||||
#### Web
|
||||
Web 版本支持 Chrome、Edge、Firefox 等现代浏览器,您可能需要在浏览器设置中打开硬件加速功能以避免白屏。
|
||||
由于浏览器限制,不支持分章节保存模式,且仅能保存至浏览器下载目录。
|
||||
|
||||
## 许可
|
||||
|
||||
基于 星缘工作室软件共享许可证A 1.0 (SSLA 1.0) 发布,详情见 [LICENSE.md](https://github.com/shing-yu/swiftcat-downloader-flutter/blob/main/LICENSE.md)。
|
||||
|
||||
仅供个人使用,严禁用于任何商业目的。
|
||||
仅供个人学习研究使用,严禁用于任何商业目的,请于下载后24小时内删除小说文件。
|
||||
|
||||
书籍内容著作权归原作者所有,使用本软件下载书籍内容前请确保您遵循当地法律法规。
|
||||
|
||||
本项目作者、贡献者不对因用户使用本软件而导致的任何直接、间接、偶然、特殊或后果性损害承担责任。
|
||||
|
||||
## 技术支持
|
||||
|
||||
社区Q群:690736066
|
||||
|
||||
如果您认为本程序侵犯了您的权益,请通过 shyu@staredges.cn 联系我们。
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<application
|
||||
android:label="灵猫小说下载器"
|
||||
android:name="${applicationName}"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// lib/models/book.dart
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// --- 新增的辅助函数 ---
|
||||
// 这个函数能安全地将任何动态类型的值转换为整数。
|
||||
@@ -85,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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// lib/ui/widgets/book_detail_view.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:io' show Platform , File, Directory;
|
||||
import 'dart:io' show Platform , Directory;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart'; // 新增: 用于获取设备信息
|
||||
|
||||
import '../../core/book_downloader.dart';
|
||||
import '../../models/book.dart';
|
||||
@@ -17,23 +17,6 @@ import '../../providers/book_provider.dart';
|
||||
final bool isAndroid = !kIsWeb && Platform.isAndroid;
|
||||
final bool isIOS = !kIsWeb && Platform.isIOS;
|
||||
|
||||
/// (仅安卓) 检查文件路径是否存在,如果存在,则返回一个新的、不冲突的文件路径。
|
||||
Future<String> _getUniqueFilePath(String filePath) async {
|
||||
String newPath = filePath;
|
||||
int counter = 1;
|
||||
final context = p.Context(style: p.Style.posix);
|
||||
|
||||
while (await File(newPath).exists() || await Directory(newPath).exists()) {
|
||||
final String directory = context.dirname(filePath);
|
||||
final String extension = context.extension(filePath);
|
||||
final String filenameWithoutExt = context.basenameWithoutExtension(filePath);
|
||||
|
||||
newPath = context.join(directory, '$filenameWithoutExt($counter)$extension');
|
||||
counter++;
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
class BookDetailView extends ConsumerStatefulWidget {
|
||||
const BookDetailView({super.key});
|
||||
@@ -44,19 +27,15 @@ class BookDetailView extends ConsumerStatefulWidget {
|
||||
|
||||
class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
DownloadFormat _selectedFormat = DownloadFormat.singleTxt;
|
||||
// --- 新增: 状态变量,用于在异步方法和 build 方法之间传递数据 ---
|
||||
String? _lastDownloadedPath;
|
||||
|
||||
Future<String?> _getMobileDownloadsDirectory() async {
|
||||
Directory? directory;
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
// iOS 平台保存到应用文档目录
|
||||
directory = await getApplicationDocumentsDirectory();
|
||||
} else {
|
||||
// Android 平台,首先尝试公共的 Download 目录
|
||||
directory = Directory('/storage/emulated/0/Download');
|
||||
// 如果这个目录因为某些原因不存在,则回退到应用外部存储的根目录
|
||||
if (!await directory.exists()) {
|
||||
directory = await getExternalStorageDirectory();
|
||||
}
|
||||
@@ -75,7 +54,6 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
// --- Web 平台逻辑 ---
|
||||
if (_selectedFormat == DownloadFormat.chapterTxt) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -92,44 +70,59 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
// 对于 Web,我们不需要真实的目录。我们只用文件名作为标识。
|
||||
// 下载器将会在内存中处理数据,而不是写入文件。
|
||||
String extension = _selectedFormat == DownloadFormat.singleTxt ? 'txt' : 'epub';
|
||||
outputPath = '$fileName.$extension';
|
||||
}
|
||||
} else if (isAndroid || isIOS) {
|
||||
// --- 移动平台 (Android/iOS) 逻辑 ---
|
||||
var hasPermission = true;
|
||||
if (isAndroid) {
|
||||
// 只在 Android 上请求权限
|
||||
var status = await Permission.storage.status;
|
||||
if (status.isDenied) {
|
||||
status = await Permission.storage.request();
|
||||
// --- 修改点: 根据安卓版本请求权限 ---
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
PermissionStatus status;
|
||||
|
||||
// Android 11 (API 30) 或更高版本
|
||||
if (deviceInfo.version.sdkInt >= 30) {
|
||||
status = await Permission.manageExternalStorage.status;
|
||||
if (!status.isGranted) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
}
|
||||
} else { // Android 10 (API 29) 或更低版本
|
||||
status = await Permission.storage.status;
|
||||
if (!status.isGranted) {
|
||||
status = await Permission.storage.request();
|
||||
}
|
||||
}
|
||||
hasPermission = status.isGranted;
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
// --- 核心修改点: 使用新的路径获取方法 ---
|
||||
final String? downloadsPath = await _getMobileDownloadsDirectory();
|
||||
|
||||
if (downloadsPath != null) {
|
||||
String initialPath;
|
||||
if (_selectedFormat == DownloadFormat.chapterTxt) {
|
||||
initialPath = '$downloadsPath/$fileName';
|
||||
outputPath = '$downloadsPath/$fileName';
|
||||
} else {
|
||||
String extension = _selectedFormat == DownloadFormat.singleTxt ? 'txt' : 'epub';
|
||||
initialPath = '$downloadsPath/$fileName.$extension';
|
||||
outputPath = '$downloadsPath/$fileName.$extension';
|
||||
}
|
||||
// 同样只在 Android 上处理同名问题
|
||||
outputPath = isAndroid ? await _getUniqueFilePath(initialPath) : initialPath;
|
||||
// --- 已移除: 不再调用 _getUniqueFilePath ---
|
||||
} else {
|
||||
throw Exception("无法获取下载目录。");
|
||||
}
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('需要存储权限才能下载文件。')),
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('权限不足'),
|
||||
content: const Text('需要存储权限才能下载文件'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('确定'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -153,11 +146,9 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
}
|
||||
|
||||
if (outputPath != null) {
|
||||
// --- 修改点: 在启动下载前,保存文件路径到状态变量 ---
|
||||
setState(() {
|
||||
_lastDownloadedPath = outputPath;
|
||||
});
|
||||
// --- 修改点: 移除了这里的 ref.listen ---
|
||||
ref.read(downloadProvider.notifier).startDownload(
|
||||
book: book,
|
||||
format: _selectedFormat,
|
||||
@@ -173,31 +164,23 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// --- 核心修改点: 调整 ref.listen 的内部逻辑 ---
|
||||
ref.listen<DownloadState>(downloadProvider, (previous, next) {
|
||||
if (previous?.isDownloading == true && !next.isDownloading && next.status.contains('成功')) {
|
||||
// 检查路径是否存在
|
||||
if (_lastDownloadedPath != null) {
|
||||
|
||||
// --- 解决方案: 创建一个局部 final 变量来捕获当前路径的值 ---
|
||||
final String path = _lastDownloadedPath!;
|
||||
final String fileName = p.basename(path); // 从完整路径中提取文件名
|
||||
final String fileName = p.basename(path);
|
||||
|
||||
// 显示 SnackBar
|
||||
if (kIsWeb) {
|
||||
// --- Web 平台: 直接从 state 获取 bytes 并触发浏览器下载 ---
|
||||
if (next.data != null) {
|
||||
FileSaver.instance.saveFile(
|
||||
name: p.basenameWithoutExtension(fileName), // 文件名 (无扩展名)
|
||||
bytes: next.data!, // 文件内容
|
||||
fileExtension: p.extension(fileName).replaceFirst('.', ''), // 扩展名 (去掉点)
|
||||
mimeType: MimeType.text
|
||||
name: p.basenameWithoutExtension(fileName),
|
||||
bytes: next.data!,
|
||||
fileExtension: p.extension(fileName).replaceFirst('.', ''),
|
||||
mimeType: MimeType.text
|
||||
);
|
||||
// --- 新增: 保存文件后,调用清理方法 ---
|
||||
ref.read(downloadProvider.notifier).clearDownloadData();
|
||||
}
|
||||
} else {
|
||||
// --- 移动/桌面平台: 显示带“打开”按钮的 SnackBar ---
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('下载完成: $path'),
|
||||
@@ -210,7 +193,6 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
)
|
||||
);
|
||||
}
|
||||
// 现在可以安全地清空成员变量,为下一次下载做准备了
|
||||
_lastDownloadedPath = null;
|
||||
}
|
||||
}
|
||||
@@ -301,7 +283,6 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
);
|
||||
}
|
||||
|
||||
// _buildDownloadControls 方法保持不变
|
||||
Widget _buildDownloadControls(Book book) {
|
||||
final downloadState = ref.watch(downloadProvider);
|
||||
|
||||
@@ -385,4 +366,4 @@ class _BookDetailViewState extends ConsumerState<BookDetailView> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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) => '');
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import file_picker
|
||||
import file_saver
|
||||
import open_file_mac
|
||||
@@ -14,6 +15,7 @@ import url_launcher_macos
|
||||
import window_size
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
|
||||
34
pubspec.lock
34
pubspec.lock
@@ -121,6 +121,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -161,6 +177,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -738,6 +762,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
window_size:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -772,4 +804,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.1 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
||||
@@ -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+202507230
|
||||
version: 1.0.1+202510210
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
@@ -58,6 +58,7 @@ dependencies:
|
||||
window_size: ^0.1.0
|
||||
url_launcher: ^6.0.0
|
||||
package_info_plus: ^8.3.0
|
||||
device_info_plus: ^11.5.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user