chore: initialize Guangya Flutter project

This commit is contained in:
ngfchl
2026-07-18 10:23:34 +08:00
commit 6bcc3fdf18
143 changed files with 13939 additions and 0 deletions
+891
View File
@@ -0,0 +1,891 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../core/http/dio_client.dart';
import '../core/http/http.dart';
import '../core/http/http_error.dart';
import '../core/config/app_config.dart';
/// API client for Guangya Cloud Drive (光鸭云盘).
/// 基于 Dio 封装,参考 harvest_flutter 的 HTTP 架构。
class GuangyaAPI {
String accessToken;
String? refreshTokenValue;
DateTime? tokenExpiresAt;
final String deviceID;
GuangyaAPI({
this.accessToken = '',
String? refreshToken,
String? deviceID,
}) : deviceID = deviceID ?? _generateDeviceID();
static String _generateDeviceID() {
return 'flutter-${DateTime.now().millisecondsSinceEpoch}';
}
/// Update tokens from a JSON response.
AuthTokens? updateTokens(Map<String, dynamic> value) {
final access = _findStringDeep(value, ['access_token', 'accessToken']);
if (access == null) return null;
accessToken = access;
refreshTokenValue =
_findStringDeep(value, ['refresh_token', 'refreshToken']) ?? refreshTokenValue;
final expires = _findIntDeep(value, ['expires_in', 'expiresIn']);
if (expires != null) {
tokenExpiresAt = DateTime.now().add(Duration(seconds: expires));
}
return AuthTokens(
accessToken: access,
refreshToken: refreshTokenValue,
expiresIn: expires?.toDouble(),
);
}
void clearTokens() {
accessToken = '';
refreshTokenValue = null;
tokenExpiresAt = null;
}
// ── Authentication ──────────────────────────────────────────────────
Future<Map<String, dynamic>> loginSMSInit(String phoneNumber,
{String? captchaToken}) async {
final body = <String, dynamic>{
'client_id': AppConfig.clientID,
'action': 'POST:/v1/auth/verification',
'device_id': deviceID,
'meta': {'phone_number': phoneNumber},
};
if (captchaToken != null) body['captcha_token'] = captchaToken;
return Http.accountRequest('/v1/shield/captcha/init', body: body);
}
Future<Map<String, dynamic>> loginSMSSend(String phoneNumber,
{String captchaToken = '', String target = 'ANY'}) async {
return Http.accountRequest('/v1/auth/verification',
body: {
'phone_number': phoneNumber,
'target': target,
'client_id': AppConfig.clientID,
},
extraHeaders: {'x-captcha-token': captchaToken});
}
Future<Map<String, dynamic>> loginSMSVerify(
String verificationID, String verificationCode) async {
return Http.accountRequest('/v1/auth/verification/verify', body: {
'verification_id': verificationID,
'verification_code': verificationCode,
'client_id': AppConfig.clientID,
});
}
Future<Map<String, dynamic>> loginSMSSignIn({
required String code,
required String verificationToken,
required String username,
required String captchaToken,
}) async {
final result = await Http.accountRequest('/v1/auth/signin',
body: {
'verification_code': code,
'verification_token': verificationToken,
'username': username,
'client_id': AppConfig.clientID,
},
extraHeaders: {'x-captcha-token': captchaToken});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> loginQRInit() async {
return Http.accountRequest('/v1/auth/device/code', body: {
'scope': 'user',
'client_id': AppConfig.clientID,
});
}
Future<Map<String, dynamic>> loginQRPoll(String token) async {
final result = await Http.accountRequest('/v1/auth/token', body: {
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': token,
'client_id': AppConfig.clientID,
});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> refreshAccessToken([String? token]) async {
final t = token ?? refreshTokenValue;
if (t == null) throw Exception('没有可用的刷新令牌');
final result = await Http.accountRequest('/v1/auth/token',
body: {
'client_id': AppConfig.clientID,
'grant_type': 'refresh_token',
'refresh_token': t,
},
extraHeaders: {'x-action': '401'});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> userInfo() async {
return Http.accountRequest('/v1/user/me', body: null, authenticated: true);
}
// ── Cloud downloads ────────────────────────────────────────────────
Future<Map<String, dynamic>> cloudTaskList({
int page = 0,
int pageSize = 50,
List<int> status = const [0, 1, 3, 4],
}) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/list_task', body: {
'page': page,
'pageSize': pageSize,
'status': status,
});
}
Future<Map<String, dynamic>> cloudResolveURL(String url) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_res',
body: {'url': url});
}
Future<Map<String, dynamic>> cloudResolveTorrent(File torrentFile) async {
final bytes = await torrentFile.readAsBytes();
final formData = FormData.fromMap({
'torrent': MultipartFile.fromBytes(bytes, filename: 'file.torrent'),
});
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_torrent',
body: formData);
}
Future<Map<String, dynamic>> cloudCreateTask(String url,
{String? parentID}) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/create_task', body: {
'url': url,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> taskStatus(String taskID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_task_status',
body: {'taskId': taskID});
}
// ── File system ────────────────────────────────────────────────────
Future<Map<String, dynamic>> fsFiles({
String? parentID,
int page = 0,
int pageSize = 50,
int orderBy = 0,
int sortType = 0,
List<int>? fileTypes,
int? resType,
int? dirType,
bool needPlayRecord = false,
}) async {
final body = <String, dynamic>{
'parentId': parentID ?? '',
'page': page,
'pageSize': pageSize,
'orderBy': orderBy,
'sortType': sortType,
};
if (fileTypes != null) body['fileTypes'] = fileTypes;
if (resType != null) body['resType'] = resType;
if (dirType != null) body['dirType'] = dirType;
if (needPlayRecord) body['needPlayRecord'] = true;
return Http.apiRequest('/userres/v1/file/get_file_list', body: body);
}
Future<Map<String, dynamic>> fsCreateDir(String name,
{String? parentID, bool failIfNameExist = false}) async {
final body = <String, dynamic>{
'dirName': name,
'parentId': parentID ?? '',
};
if (failIfNameExist) body['failIfNameExist'] = true;
return Http.apiRequest('/nd.bizuserres.s/v1/file/create_dir',
body: body,
allowedCodes: failIfNameExist ? [159] : []);
}
Future<Map<String, dynamic>> fsCopy(List<String> fileIDs,
{String? parentID}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/copy_file', body: {
'fileIds': fileIDs,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> fsMove(List<String> fileIDs,
{String? parentID}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/move_file', body: {
'fileIds': fileIDs,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> fsDelete(List<String> fileIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_file',
body: {'fileIds': fileIDs});
}
Future<Map<String, dynamic>> fsRecycle(List<String> fileIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/recycle_file',
body: {'fileIds': fileIDs});
}
Future<Map<String, dynamic>> fsClearRecycleBin() async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/clear_recycle_bin');
}
Future<Map<String, dynamic>> fsRename(String fileID, String newName) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/rename', body: {
'fileId': fileID,
'newName': newName,
});
}
Future<Map<String, dynamic>> fsDetail(String fileID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_file_detail',
body: {'fileId': fileID});
}
Future<Map<String, dynamic>> fsImageList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1);
}
Future<Map<String, dynamic>> fsVideoList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1);
}
Future<Map<String, dynamic>> fsAudioList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true);
}
Future<Map<String, dynamic>> fsDocumentList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1);
}
Future<Map<String, dynamic>> fsRecycleFiles() async {
return fsFiles(orderBy: 10, dirType: 4);
}
Future<Map<String, dynamic>> downloadURL(String fileID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_download_url',
body: {'fileId': fileID});
}
Future<Map<String, dynamic>> vodDownloadURL(String fileID, String gcid) async {
return Http.apiRequest('/userres/v1/file/get_vod_download_url',
body: {'fileId': fileID, 'gcid': gcid});
}
Future<Map<String, dynamic>> recentViewed({
int pageSize = 100,
String cursor = '',
}) async {
return Http.apiRequest('/userres/v1/get_user_action',
body: {'cursor': cursor, 'pageSize': pageSize});
}
Future<Map<String, dynamic>> recentRestored({int pageSize = 100}) async {
return Http.apiRequest('/userres/v1/get_restore_list', body: {
'pageSize': pageSize,
'orderBy': 2,
'sortType': 1,
});
}
// ── Shares ─────────────────────────────────────────────────────────
Future<Map<String, dynamic>> shareCreate(
List<String> fileIDs, {
required String title,
int validateDuration = 0,
int shareType = 1,
String code = '',
bool autoFillCode = true,
String trafficLimit = '0',
int maxRestoreCount = 0,
int downloadType = 1,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/share_file', body: {
'fileIds': fileIDs,
'title': title,
'validateDuration': validateDuration,
'shareType': shareType,
'code': code,
'autoFillCode': autoFillCode,
'trafficLimit': trafficLimit,
'maxRestoreCount': maxRestoreCount,
'downloadType': downloadType,
});
}
Future<Map<String, dynamic>> shareUserList({
int page = 0,
int pageSize = 50,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_list', body: {
'page': page,
'pageSize': pageSize,
'orderType': 1,
'sortType': 1,
});
}
Future<Map<String, dynamic>> shareDelete(List<String> ids) async {
return Http.apiRequest('/nd.bizuserres.s/v1/delete_share',
body: {'ids': ids});
}
Future<Map<String, dynamic>> shareUpdate(
String shareID, {
required String title,
int validateDuration = 0,
int shareType = 1,
String code = '',
bool autoFillCode = true,
String trafficLimit = '0',
int maxRestoreCount = 0,
int downloadType = 1,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/update_share', body: {
'id': shareID,
'title': title,
'validateDuration': validateDuration,
'shareType': shareType,
'code': code,
'autoFillCode': autoFillCode,
'trafficLimit': trafficLimit,
'maxRestoreCount': maxRestoreCount,
'downloadType': downloadType,
});
}
Future<Map<String, dynamic>> shareRestore(
String accessToken,
List<String> fileIDs, {
String parentID = '',
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/restore_share', body: {
'accessToken': accessToken,
'fileIds': fileIDs,
'parentId': parentID,
});
}
Future<Map<String, dynamic>> shareDownloadURL(
String fileID, String accessToken) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_download_url',
body: {'fileId': fileID, 'accessToken': accessToken});
}
Future<Map<String, dynamic>> shareFilesSize(
String accessToken,
List<String> fileIDs, {
bool download = true,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_files_size',
body: {
'accessToken': accessToken,
'fileIds': fileIDs,
'download': download,
});
}
Future<Map<String, dynamic>> shareFilesList(
String accessToken, {
String parentID = '',
int page = 1,
int pageSize = 50,
int orderBy = 0,
int sortType = 0,
}) async {
return Http.publicRequest(
'/nd.bizuserres.s/v1/get_share_page_files_list',
body: {
'accessToken': accessToken,
'parentId': parentID,
'page': page,
'pageSize': pageSize,
'orderBy': orderBy,
'sortType': sortType,
});
}
Future<Map<String, dynamic>> shareSummary(String shareID) async {
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_summary',
body: {'shareId': shareID});
}
Future<Map<String, dynamic>> shareAccessToken(
String shareID, String code) async {
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_access_token',
body: {'shareId': shareID, 'code': code});
}
// ── Upload ─────────────────────────────────────────────────────────
Future<Map<String, dynamic>> uploadToken({
required String name,
required int fileSize,
String? parentID,
String? md5,
}) async {
final res = <String, dynamic>{'fileSize': fileSize};
if (md5 != null) res['md5'] = _base64MD5(md5);
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 2,
'name': name,
'res': res,
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> flashTransferToken({
required String name,
required int fileSize,
String? parentID,
required String md5,
}) async {
final normalizedMD5 = md5.trim().toLowerCase();
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 1,
'name': name,
'res': {'md5': normalizedMD5, 'fileSize': fileSize},
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> flashTransferGCIDToken({
required String name,
required int fileSize,
String? parentID,
required String gcid,
}) async {
final normalizedGCID = gcid.trim().toUpperCase();
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 1,
'name': name,
'res': {'gcid': normalizedGCID, 'fileSize': fileSize},
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> deleteUploadTask(List<String> taskIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_upload_task',
body: {'taskIds': taskIDs});
}
Future<Map<String, dynamic>> checkCanFlashUpload(
String taskID, String gcid) async {
return Http.apiRequest('/nd.bizuserres.s/v1/check_can_flash_upload',
body: {'taskId': taskID, 'gcid': gcid});
}
Future<Map<String, dynamic>> uploadInfo(String taskID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_info_by_task_id',
body: {'taskId': taskID},
allowedCodes: const [145, 146, 155, 163]);
}
Future<Map<String, dynamic>> fileUpload(
File file, {
String? parentID,
String contentType = 'application/octet-stream',
int chunkSize = 5 * 1024 * 1024,
}) async {
final bytes = await file.readAsBytes();
final name = file.uri.pathSegments.isEmpty
? file.path.split(Platform.pathSeparator).last
: Uri.decodeComponent(file.uri.pathSegments.last);
final size = bytes.length;
Map<String, dynamic> token;
if (size < 1024 * 1024) {
final md5Base64 = base64Encode(md5.convert(bytes).bytes);
token = await uploadToken(
name: name,
fileSize: size,
parentID: parentID,
md5: md5Base64,
);
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
if (taskID == null) return token;
return uploadInfo(taskID);
}
token = await uploadToken(name: name, fileSize: size, parentID: parentID);
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
if (taskID == null) throw Exception('响应缺少字段:taskId');
final gcid = _calculateGCID(bytes);
final canFlash = await checkCanFlashUpload(taskID, gcid);
if (_findBoolDeep(canFlash, ['canFlashUpload', 'can_flash_upload']) == true) {
return uploadInfo(taskID);
}
final tokenData = _findMapDeep(token, ['data']) ?? token;
await _cdnUpload(
bytes,
tokenData: tokenData,
contentType: contentType,
chunkSize: chunkSize,
);
return uploadInfo(taskID);
}
Future<String> _cdnUpload(
List<int> bytes, {
required Map<String, dynamic> tokenData,
required String contentType,
required int chunkSize,
}) async {
final creds = _findMapDeep(tokenData, ['creds']) ?? const <String, dynamic>{};
final accessKeyID = _findStringDeep(creds, ['accessKeyID', 'accessKeyId']);
final secret = _findStringDeep(creds, ['secretAccessKey']);
final sessionToken = _findStringDeep(creds, ['sessionToken']);
final endpoint = _findStringDeep(tokenData, ['fullEndPoint', 'fullEndpoint']);
final bucket = _findStringDeep(tokenData, ['bucketName']);
final objectPath = _findStringDeep(tokenData, ['objectPath']);
if (accessKeyID == null ||
secret == null ||
sessionToken == null ||
endpoint == null ||
bucket == null ||
objectPath == null) {
throw Exception('响应缺少字段:OSS token data');
}
final objectURL = '$endpoint/$objectPath';
final init = await _ossRequest(
method: 'POST',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
subResources: {'uploads': ''},
);
final uploadID = _xmlValue(init.body, 'UploadId');
if (uploadID == null) throw Exception('响应缺少字段:UploadId');
final parts = <MapEntry<int, String>>[];
var offset = 0;
var number = 1;
while (offset < bytes.length) {
final end = min(offset + chunkSize, bytes.length);
final chunk = bytes.sublist(offset, end);
final contentMD5 = base64Encode(md5.convert(chunk).bytes);
final response = await _ossRequest(
method: 'PUT',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
content: chunk,
contentType: 'application/octet-stream',
contentMD5: contentMD5,
subResources: {'partNumber': '$number', 'uploadId': uploadID},
);
parts.add(MapEntry(number, response.headers['etag']?.replaceAll('"', '') ?? ''));
offset = end;
number += 1;
}
final xml =
'<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${parts.map((part) => '<Part><PartNumber>${part.key}</PartNumber><ETag>"${part.value}"</ETag></Part>').join()}</CompleteMultipartUpload>';
final xmlBytes = utf8.encode(xml);
final result = await _ossRequest(
method: 'POST',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
content: xmlBytes,
contentType: 'application/xml',
contentMD5: base64Encode(md5.convert(xmlBytes).bytes),
subResources: {'uploadId': uploadID},
);
return _xmlValue(result.body, 'ETag') ?? '';
}
Future<({String body, Map<String, String> headers})> _ossRequest({
required String method,
required String url,
required String bucket,
required String objectKey,
required String accessKeyID,
required String secret,
required String sessionToken,
List<int> content = const [],
String contentType = '',
String contentMD5 = '',
Map<String, String> subResources = const {},
}) async {
final date = HttpDate.format(DateTime.now().toUtc());
final canonicalResource = '/$bucket/$objectKey${_subResourceQuery(subResources)}';
final canonical = [
method.toUpperCase(),
contentMD5,
contentType,
date,
'x-oss-date:$date',
'x-oss-security-token:${sessionToken.trim()}',
canonicalResource,
].join('\n');
final signature = base64Encode(
Hmac(sha1, utf8.encode(secret)).convert(utf8.encode(canonical)).bytes,
);
final uri = Uri.parse('$url${_subResourceQuery(subResources)}');
final response = await Dio().requestUri<List<int>>(
uri,
data: content.isEmpty ? null : Stream.fromIterable([content]),
options: Options(
method: method,
responseType: ResponseType.bytes,
headers: {
'Authorization': 'OSS $accessKeyID:$signature',
'x-oss-date': date,
'x-oss-security-token': sessionToken,
if (contentType.isNotEmpty) 'Content-Type': contentType,
if (contentMD5.isNotEmpty) 'Content-MD5': contentMD5,
},
),
);
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw Exception('OSS 请求失败 ($status)');
}
return (
body: utf8.decode(response.data ?? const [], allowMalformed: true),
headers: response.headers.map.map(
(key, value) => MapEntry(key.toLowerCase(), value.join(',')),
),
);
}
// ── TMDB ───────────────────────────────────────────────────────────
Future<Map<String, dynamic>> tmdbSearch(
String query, {
required String apiKey,
String mediaKind = 'auto',
String proxyHost = '',
String proxyPort = '',
int? year,
}) async {
final endpoint = mediaKind == 'movie'
? 'movie'
: (mediaKind == 'tv' ? 'tv' : 'multi');
final params = {
'api_key': apiKey,
'query': query,
'language': 'zh-CN',
'region': 'CN',
'include_adult': 'false',
};
if (year != null) {
if (mediaKind == 'movie') {
params['primary_release_year'] = year.toString();
} else if (mediaKind == 'tv') {
params['first_air_date_year'] = year.toString();
}
}
final uri = Uri.https('api.themoviedb.org', '/3/search/$endpoint', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
Future<Map<String, dynamic>> tmdbDetails(
int id, {
required String mediaKind,
required String apiKey,
String proxyHost = '',
String proxyPort = '',
}) async {
final endpoint = mediaKind == 'tv' ? 'tv' : 'movie';
final params = {
'api_key': apiKey,
'language': 'zh-CN',
'append_to_response': 'credits,images,external_ids,translations',
'include_image_language': 'zh-CN,zh,null,en',
};
final uri = Uri.https('api.themoviedb.org', '/3/$endpoint/$id', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
Future<Map<String, dynamic>> tmdbEpisodeDetails(
int seriesID, {
required int season,
required int episode,
required String apiKey,
String proxyHost = '',
String proxyPort = '',
}) async {
final params = {
'api_key': apiKey,
'language': 'zh-CN',
'append_to_response': 'credits,images,translations',
'include_image_language': 'zh-CN,zh,null,en',
};
final uri = Uri.https('api.themoviedb.org',
'/3/tv/$seriesID/season/$season/episode/$episode', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
// ── HTTP helpers ───────────────────────────────────────────────────
Future<Map<String, dynamic>> _tmdbRequest(
Uri url, {
String proxyHost = '',
String proxyPort = '',
}) async {
final dio = DioClient.dio;
final response = await dio.getUri(url, options: Options(
headers: {'Accept': 'application/json'},
));
if (response.statusCode! < 200 || response.statusCode! >= 300) {
throw Exception('TMDB 请求失败');
}
return response.data as Map<String, dynamic>;
}
// ── Helpers ───────────────────────────────────────────────────────
static String? _findStringDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found =
_findStringDeep(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static int? _findIntDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
return null;
}
static bool? _findBoolDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final value = json[key];
if (value is bool) return value;
if (value != null) {
final text = value.toString().toLowerCase();
if (text == 'true' || text == '1') return true;
if (text == 'false' || text == '0') return false;
}
}
for (final entry in json.entries) {
if (entry.value is Map) {
final found = _findBoolDeep(
Map<String, dynamic>.from(entry.value as Map),
keys,
);
if (found != null) return found;
}
}
return null;
}
static Map<String, dynamic>? _findMapDeep(
Map<String, dynamic>? json,
List<String> keys,
) {
if (json == null) return null;
for (final key in keys) {
final value = json[key];
if (value is Map) return Map<String, dynamic>.from(value);
}
for (final entry in json.entries) {
if (entry.value is Map) {
final found = _findMapDeep(
Map<String, dynamic>.from(entry.value as Map),
keys,
);
if (found != null) return found;
}
}
return null;
}
static String _subResourceQuery(Map<String, String> values) {
if (values.isEmpty) return '';
final keys = values.keys.toList()..sort();
return '?${keys.map((key) {
final value = values[key] ?? '';
return value.isEmpty ? key : '$key=$value';
}).join('&')}';
}
static String? _xmlValue(String xml, String tag) {
final match = RegExp('<$tag>(.*?)</$tag>').firstMatch(xml);
return match?.group(1);
}
static String _base64MD5(String value) {
final normalized = value.trim();
if (!RegExp(r'^[A-Fa-f0-9]{32}$').hasMatch(normalized)) {
return normalized;
}
final bytes = <int>[];
for (var i = 0; i < normalized.length; i += 2) {
bytes.add(int.parse(normalized.substring(i, i + 2), radix: 16));
}
return base64Encode(bytes);
}
static String _calculateGCID(List<int> bytes) {
final length = bytes.length;
final chunkSize = length <= 0x8000000
? 262144
: length <= 0x10000000
? 524288
: length <= 0x20000000
? 1048576
: 2097152;
final hashes = <int>[];
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = min(offset + chunkSize, bytes.length);
hashes.addAll(sha1.convert(bytes.sublist(offset, end)).bytes);
}
return sha1
.convert(hashes)
.bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join();
}
void dispose() {
// DioClient 的 Dio 实例是静态的,不需要手动关闭
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/theme_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/file_provider.dart';
import '../providers/media_library_provider.dart';
import '../pages/login_page.dart';
import '../pages/workspace_page.dart';
import 'app_theme.dart';
class GuangyaApp extends ConsumerWidget {
const GuangyaApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeState = ref.watch(themeProvider);
final auth = ref.watch(authProvider);
// Auto-load files when signed in
if (auth.isSignedIn) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final fp = ref.read(fileProvider.notifier);
fp.api = ref.read(authProvider.notifier).api;
ref.read(mediaLibraryProvider.notifier).api = ref
.read(authProvider.notifier)
.api;
final fileState = ref.read(fileProvider);
if (fileState.files.isEmpty && !fileState.isLoading) {
fp.loadFiles();
}
});
}
return ShadApp(
title: '光鸭云盘',
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeState.themeMode,
home: auth.isLoading
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: auth.isSignedIn
? const WorkspacePage()
: const LoginPage(),
);
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
// Light theme
final lightTheme = ShadThemeData(
colorScheme: const ShadOrangeColorScheme.light(
background: Color(0xFFFFFFFF),
foreground: Color(0xFF0F172A),
card: Color(0xFFFFFFFF),
cardForeground: Color(0xFF0F172A),
popover: Color(0xFFFFFFFF),
popoverForeground: Color(0xFF0F172A),
primary: Color(0xFFF97316),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFFF1F5F9),
secondaryForeground: Color(0xFF0F172A),
muted: Color(0xFFF1F5F9),
mutedForeground: Color(0xFF64748B),
accent: Color(0xFFF1F5F9),
accentForeground: Color(0xFF0F172A),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0xFFE2E8F0),
input: Color(0xFFE2E8F0),
ring: Color(0xFFF97316),
selection: Color(0xFFFB923C),
),
);
// Dark theme
final darkTheme = ShadThemeData(
colorScheme: const ShadOrangeColorScheme.dark(
background: Color(0xFF0F172A),
foreground: Color(0xFFF8FAFC),
card: Color(0xFF1E293B),
cardForeground: Color(0xFFF8FAFC),
popover: Color(0xFF1E293B),
popoverForeground: Color(0xFFF8FAFC),
primary: Color(0xFFF97316),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFF1E293B),
secondaryForeground: Color(0xFFF8FAFC),
muted: Color(0xFF1E293B),
mutedForeground: Color(0xFF94A3B8),
accent: Color(0xFF1E293B),
accentForeground: Color(0xFFF8FAFC),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0xFF334155),
input: Color(0xFF334155),
ring: Color(0xFFF97316),
selection: Color(0xFFFB923C),
),
);
class GlassCard extends StatelessWidget {
final Widget child;
final double borderRadius;
final EdgeInsets? padding;
const GlassCard({
super.key,
required this.child,
this.borderRadius = 24,
this.padding,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: child,
),
),
);
}
}
class OS26Surface extends StatelessWidget {
final Widget child;
const OS26Surface({super.key, required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFE9F6F5), Color(0xFFF6FAF6), Color(0xFFF9E6D4)],
),
),
child: child,
);
}
}
class OS26Glass extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final double radius;
final double opacity;
final Border? border;
const OS26Glass({
super.key,
required this.child,
this.padding,
this.radius = 18,
this.opacity = 0.48,
this.border,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(radius),
border:
border ??
Border.all(
color: Colors.white.withValues(alpha: 0.58),
width: 1,
),
),
child: child,
),
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import '../storage/storage_manager.dart';
class AppConfig {
/// 光鸭 API 基础地址
static const String _defaultApiBase = 'https://api.guangyapan.com';
/// 光鸭账号基础地址
static const String _defaultAccountBase = 'https://account.guangyapan.com';
/// 客户端 ID
static const String clientID = 'aMe-8VSlkrbQXpUR';
/// 获取 API baseUrl(动态)
static String get apiBase {
return StorageManager.get<String>(StorageKeys.apiBase) ?? _defaultApiBase;
}
/// 设置 API baseUrl
static Future<void> setApiBase(String url) async {
await StorageManager.set(StorageKeys.apiBase, normalizeUrl(url));
}
/// 获取账号 baseUrl
static String get accountBase {
return StorageManager.get<String>(StorageKeys.accountBase) ?? _defaultAccountBase;
}
/// 设置账号 baseUrl
static Future<void> setAccountBase(String url) async {
await StorageManager.set(StorageKeys.accountBase, normalizeUrl(url));
}
static String normalizeUrl(String url) {
var value = url.trim();
while (value.endsWith('/')) {
value = value.substring(0, value.length - 1);
}
return value;
}
/// 清除所有配置
static Future<void> clear() async {
await StorageManager.delete(StorageKeys.apiBase);
await StorageManager.delete(StorageKeys.accountBase);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import '../config/app_config.dart';
import 'interceptors/auth_interceptor.dart';
import 'interceptors/response_interceptor.dart';
class DioClient {
static late Dio dio;
static late Dio accountDio;
static AuthInterceptor? _authInterceptor;
/// 初始化 DIO 客户端
static void init({OnLogout? onLogout}) {
_authInterceptor = AuthInterceptor(onLogout: onLogout);
dio = _createDio(
baseUrl: AppConfig.apiBase,
interceptors: [
_authInterceptor!,
ResponseInterceptor(),
],
);
accountDio = _createDio(
baseUrl: AppConfig.accountBase,
interceptors: [
ResponseInterceptor(),
],
);
}
/// 更新 API baseUrl 并同步到 DioClient
static void updateBaseUrl(String apiBase, {String? accountBase}) {
dio.options.baseUrl = apiBase;
if (accountBase != null) {
accountDio.options.baseUrl = accountBase;
}
}
/// 重新设置 onLogout 回调(例如切换 provider 时)
static void setOnLogout(OnLogout? onLogout) {
if (_authInterceptor != null) {
_authInterceptor = AuthInterceptor(onLogout: onLogout);
dio.interceptors.removeWhere((i) => i is AuthInterceptor);
dio.interceptors.insert(0, _authInterceptor!);
}
}
static Dio _createDio({
required String baseUrl,
required List<Interceptor> interceptors,
}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'https://www.guangyapan.com',
'Referer': 'https://www.guangyapan.com/',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
),
);
// Windows 下优化连接池
if (Platform.isWindows) {
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.maxConnectionsPerHost = 10;
client.idleTimeout = const Duration(seconds: 60);
return client;
};
}
dio.interceptors.addAll(interceptors);
return dio;
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import '../storage/storage_manager.dart';
import 'dio_client.dart';
import 'http_error.dart';
import 'interceptors/response_interceptor.dart';
/// 统一 HTTP 封装 — 光鸭云盘
class Http {
/// 通用底层请求方法
static Future<T> request<T>(
String path, {
String method = 'POST',
Map<String, dynamic>? queryParameters,
dynamic data,
Map<String, dynamic>? headers,
CancelToken? cancelToken,
Options? options,
bool useAccountDio = false,
bool allowAnyCode = false,
}) async {
final client = useAccountDio ? DioClient.accountDio : DioClient.dio;
final mergedOptions = (options ?? Options()).copyWith(
method: method,
headers: headers,
);
final stopwatch = Stopwatch()..start();
final endpoint = useAccountDio ? 'account' : 'api';
log('[HTTP:$endpoint] -> $method $path');
try {
final res = await client.request(
path,
data: data,
queryParameters: queryParameters,
cancelToken: cancelToken,
options: mergedOptions,
);
stopwatch.stop();
log(
'[HTTP:$endpoint] <- $method $path status=${res.statusCode} '
'elapsed=${stopwatch.elapsedMilliseconds}ms',
);
return res.data as T;
} on DioException catch (e) {
stopwatch.stop();
final status = e.response?.statusCode;
log(
'[HTTP:$endpoint] !! $method $path status=${status ?? '-'} '
'type=${e.type.name} elapsed=${stopwatch.elapsedMilliseconds}ms',
);
// 将业务错误信息包装为 ApiException
final errorMsg = e.error is String ? (e.error as String) : '';
if (errorMsg.isNotEmpty) {
throw ApiException(status: status, message: errorMsg);
}
rethrow;
} catch (e) {
stopwatch.stop();
log(
'[HTTP:$endpoint] !! $method $path elapsed=${stopwatch.elapsedMilliseconds}ms',
);
rethrow;
}
}
/// 光鸭 API 请求(带 tokencode==200 为成功)
static Future<Map<String, dynamic>> apiRequest(
String path, {
String method = 'POST',
dynamic body,
Map<String, dynamic>? headers,
CancelToken? cancelToken,
List<int> allowedCodes = const [],
}) async {
// 过期自动刷新
_checkTokenExpiry();
final mergedHeaders = <String, dynamic>{
'traceparent': _generateTraceparent(),
...?headers,
};
final data = await request<Map<String, dynamic>>(
path,
method: method,
data: body,
headers: mergedHeaders,
cancelToken: cancelToken,
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
);
final code = _businessCode(data['code']);
if (code == null ||
code == 0 ||
code == 200 ||
allowedCodes.contains(code)) {
return data;
}
throw ApiException(
status: code,
message: extractHttpMessage(data) ?? '请求失败 ($code)',
);
}
/// 光鸭账号请求(account API 响应格式无 code 字段,跳过业务 code 检查)
static Future<Map<String, dynamic>> accountRequest(
String path, {
String method = 'POST',
Map<String, dynamic>? body,
Map<String, dynamic>? extraHeaders,
bool authenticated = false,
}) async {
final headers = <String, dynamic>{
'did': _getDeviceID(),
'dt': '4',
...?extraHeaders,
};
if (authenticated) {
final token = StorageManager.get<String>(StorageKeys.accessToken);
if (token != null && token.isNotEmpty) {
headers['authorization'] = 'Bearer $token';
}
}
final data = await request<Map<String, dynamic>>(
path,
method: method,
data: body,
headers: headers,
useAccountDio: true,
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
);
return data;
}
/// 公开请求(无 token,用于分享预览等)
static Future<Map<String, dynamic>> publicRequest(
String path, {
Map<String, dynamic>? body,
}) async {
return request<Map<String, dynamic>>(
path,
method: 'POST',
data: body,
headers: {'traceparent': _generateTraceparent()},
);
}
// ── Token 过期检测 ─────────────────────────────────────────────
static void _checkTokenExpiry() {
final expiresAt = StorageManager.get<int>(StorageKeys.tokenExpiresAt);
if (expiresAt == null) return;
if (DateTime.now().millisecondsSinceEpoch > expiresAt) {
log('[HTTP] token expired, will refresh on next 401');
}
}
static String _getDeviceID() {
return StorageManager.get<String>(StorageKeys.deviceID) ??
'flutter-${DateTime.now().millisecondsSinceEpoch}';
}
// ── Helpers ─────────────────────────────────────────────────────
static String _generateTraceparent() {
final now = DateTime.now().microsecondsSinceEpoch;
return '00-${now.toRadixString(16).padLeft(32, '0')}-${now.toRadixString(16).padLeft(16, '0')}-01';
}
static int? _businessCode(dynamic value) {
if (value == null) return null;
if (value is int) return value;
return int.tryParse(value.toString());
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:dio/dio.dart';
const String noToastHeader = 'NoToast';
const String suppressErrorToastExtra = 'suppressErrorToast';
bool suppressErrorToast(RequestOptions options) {
return options.extra[suppressErrorToastExtra] == true;
}
void applyNoToastHeader(RequestOptions options) {
String? matchedKey;
Object? matchedValue;
for (final entry in options.headers.entries) {
if (entry.key.toLowerCase() == noToastHeader.toLowerCase()) {
matchedKey = entry.key;
matchedValue = entry.value;
break;
}
}
if (matchedKey == null) return;
options.headers.remove(matchedKey);
if (_isNoToastValue(matchedValue)) {
options.extra[suppressErrorToastExtra] = true;
}
}
bool _isNoToastValue(Object? value) {
if (value == null) return true;
final text = value.toString().trim().toLowerCase();
return text.isEmpty || text == '1' || text == 'true' || text == 'yes';
}
/// Extracts a human-readable message from various response shapes
String? extractHttpMessage(dynamic value) {
if (value == null) return null;
if (value is String) return value.trim().isEmpty ? null : value.trim();
if (value is Map) {
for (final key in const ['message', 'msg', 'info', 'detail', 'error']) {
final message = extractHttpMessage(value[key]);
if (message != null) return message;
}
return extractHttpMessage(value['data']);
}
if (value is Iterable) {
final messages = value
.map(extractHttpMessage)
.whereType<String>()
.where((message) => message.trim().isNotEmpty)
.toList();
return messages.isEmpty ? null : messages.join('\n');
}
return null;
}
/// Build a user-friendly toast message from request context + detail
String requestToastMessage(RequestOptions options, String detail) {
final endpoint = _describePath(options.path);
final trimmed = detail.trim();
if (trimmed.isEmpty) return endpoint;
return '$endpoint$trimmed';
}
String _describePath(String path) {
if (path.contains('/auth')) return '认证接口';
if (path.contains('/token')) return '令牌接口';
if (path.contains('/file')) return '文件接口';
if (path.contains('/share')) return '分享接口';
if (path.contains('/res')) return '资源接口';
if (path.contains('/task') || path.contains('/cloud')) return '云下载接口';
return '接口请求';
}
/// Custom exception for API errors
class ApiException implements Exception {
final int? status;
final String message;
ApiException({this.status, required this.message});
@override
String toString() => message;
}
class AuthTokens {
final String accessToken;
final String? refreshToken;
final double? expiresIn;
AuthTokens({
required this.accessToken,
this.refreshToken,
this.expiresIn,
});
}
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:developer';
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import '../../storage/storage_manager.dart';
import '../http_error.dart';
import '../dio_client.dart';
/// 光鸭 OAuth token 刷新回调
typedef OnLogout = Future<void> Function();
class AuthInterceptor extends Interceptor {
final OnLogout? onLogout;
bool _isRefreshing = false;
bool _logoutScheduled = false;
final List<Completer<void>> _waitQueue = [];
AuthInterceptor({this.onLogout});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
applyNoToastHeader(options);
// 免认证路径:登录、刷新等
if (_isAuthExemptPath(options.path)) {
return handler.next(options);
}
final token = StorageManager.get<String>(StorageKeys.accessToken);
if (token != null && token.isNotEmpty) {
_logoutScheduled = false;
options.headers['authorization'] = 'Bearer $token';
return handler.next(options);
}
// 无 token → 静默取消
return handler.reject(_silentCancel(options));
}
bool _isAuthExemptPath(String path) {
return path.contains('/auth/signin') ||
path.contains('/auth/verification') ||
path.contains('/auth/device/code') ||
path.contains('/auth/token');
}
bool _isSilentCancel(DioException err) {
return err.type == DioExceptionType.cancel &&
err.error?.toString() == 'silent_auth_cancel';
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isSilentCancel(err)) {
return handler.next(err);
}
final status = err.response?.statusCode;
final alreadyLoggedOut = status == 401 && _isAlreadyLoggedOut;
if (!alreadyLoggedOut) {
log('[Auth] error path=${err.requestOptions.path} status=$status');
}
// 免认证路径的错误直接放行
if (_isAuthExemptPath(err.requestOptions.path)) {
return handler.next(err);
}
// 网络错误
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
return handler.next(err);
}
// 非 401 → 直接传递
if (status != 401) {
return handler.next(err);
}
// 以下全部是 401 处理
if (alreadyLoggedOut) {
return handler.reject(_silentCancel(err.requestOptions));
}
// 刷新接口本身 401 → refresh token 也失效了
if (err.requestOptions.path.contains('/auth/token') &&
err.requestOptions.method == 'POST') {
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
}
// 获取 refreshToken
final refreshToken = StorageManager.get<String>(StorageKeys.refreshToken);
// 没有 refreshToken → 静默登出
if (refreshToken == null || refreshToken.isEmpty) {
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
}
// 已经在刷新中 → 排队等待
if (_isRefreshing) {
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
final newToken = StorageManager.get<String>(StorageKeys.accessToken);
if (newToken == null || newToken.isEmpty) {
return handler.reject(_silentCancel(err.requestOptions));
}
final request = err.requestOptions;
request.headers['authorization'] = 'Bearer $newToken';
try {
final response = await DioClient.dio.fetch(request);
return handler.resolve(response);
} catch (e) {
return handler.reject(_silentCancel(err.requestOptions));
}
}
// 开始刷新
_isRefreshing = true;
log('[Auth] refreshing access token');
try {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.accountBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
),
);
final res = await dio.post(
'/v1/auth/token',
data: {
'client_id': AppConfig.clientID,
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
},
options: Options(
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'https://www.guangyapan.com',
'Referer': 'https://www.guangyapan.com/',
'did': StorageManager.get<String>(StorageKeys.deviceID) ?? '',
'dt': '4',
'x-action': '401',
},
),
);
log('[Auth] access token refreshed');
final data = res.data;
final newAccess = _findStringDeep(data, ['access_token', 'accessToken']);
final newRefresh = _findStringDeep(data, [
'refresh_token',
'refreshToken',
]);
if (newAccess == null) {
throw Exception('刷新令牌返回数据无效');
}
// 保存新 token
await StorageManager.set(StorageKeys.accessToken, newAccess);
if (newRefresh != null) {
await StorageManager.set(StorageKeys.refreshToken, newRefresh);
}
// 保存过期时间
final expiresIn = data is Map ? data['expires_in'] : null;
if (expiresIn != null) {
final expiresAt =
DateTime.now().millisecondsSinceEpoch +
(expiresIn is int
? expiresIn
: int.tryParse(expiresIn.toString()) ?? 3600) *
1000;
await StorageManager.set(StorageKeys.tokenExpiresAt, expiresAt);
}
// 唤醒排队的请求
for (var c in _waitQueue) {
c.complete();
}
_waitQueue.clear();
// 用新 token 重试当前请求
final request = err.requestOptions;
request.headers['authorization'] = 'Bearer $newAccess';
final response = await DioClient.dio.fetch(request);
return handler.resolve(response);
} catch (e) {
log('[Auth] token refresh failed: $e');
for (var c in _waitQueue) {
c.complete();
}
_waitQueue.clear();
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
} finally {
_isRefreshing = false;
}
}
DioException _silentCancel(RequestOptions options) {
return DioException(
requestOptions: options,
type: DioExceptionType.cancel,
error: 'silent_auth_cancel',
);
}
Future<void> _logout() async {
if (_logoutScheduled) return;
_logoutScheduled = true;
log('[Auth] logout scheduled');
await StorageManager.delete(StorageKeys.accessToken);
await StorageManager.delete(StorageKeys.refreshToken);
await StorageManager.delete(StorageKeys.tokenExpiresAt);
final logout = onLogout;
if (logout != null) {
await logout();
}
_logoutScheduled = false;
}
bool get _isAlreadyLoggedOut {
return StorageManager.get<String>(StorageKeys.accessToken) == null &&
StorageManager.get<String>(StorageKeys.refreshToken) == null;
}
static String? _findStringDeep(dynamic value, List<String> keys) {
if (value is Map) {
for (final key in keys) {
final v = value[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in value.entries) {
if (entry.value is Map) {
final found = _findStringDeep(entry.value, keys);
if (found != null) return found;
}
}
}
return null;
}
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import '../http_error.dart';
class ResponseInterceptor extends Interceptor {
/// Extra key: true = account API (skip business code check)
static const String skipCodeCheckKey = 'skipBusinessCodeCheck';
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
// 流式响应直接放行
if (data is ResponseBody) {
return handler.next(response);
}
// 尝试解析 JSON
if (data is String) {
try {
response.data = jsonDecode(data);
} catch (_) {
// non-JSON response, pass through
}
}
final body = response.data;
if (body is! Map) {
return handler.next(response);
}
// Account API 跳过业务 code 检查(响应格式是 {data: {...}},没有 code 字段)
final skipCodeCheck =
response.requestOptions.extra[skipCodeCheckKey] == true ||
response.requestOptions.headers['x-skip-code-check'] == true;
if (skipCodeCheck) {
return handler.next(response);
}
// 业务 API:检查 code 字段
final code = _businessCode(body['code']);
final message = extractHttpMessage(body);
// code == 0/200 视为成功。不同网关返回的业务成功码不完全一致。
if (code == null || code == 0 || code == 200) {
if (message != null && message.isNotEmpty) {
response.extra['success_message'] = message;
}
return handler.next(response);
}
// 其他 code 视为业务错误
final errorMsg = message ?? _defaultErrorMessage(code);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: errorMsg,
),
);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.type == DioExceptionType.cancel) {
return handler.next(err);
}
// 网络错误
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
if (!suppressErrorToast(err.requestOptions)) {
log(
'[HTTP] ${requestToastMessage(err.requestOptions, '请求超时或连接失败,请检查网络')}',
);
}
return handler.next(err);
}
// 其他错误
if (!suppressErrorToast(err.requestOptions)) {
final msg = err.response?.data is Map
? extractHttpMessage(err.response?.data) ?? '请求失败'
: '请求失败';
log('[HTTP] ${requestToastMessage(err.requestOptions, msg)}');
}
return handler.next(err);
}
static String? _defaultErrorMessage(dynamic code) {
if (code == 400) return '请求参数错误';
if (code == 401) return '未授权,请重新登录';
if (code == 403) return '没有权限执行此操作';
if (code == 404) return '接口不存在';
if (code == 500) return '服务器内部错误';
return '请求失败 ($code)';
}
static int? _businessCode(dynamic value) {
if (value == null) return null;
if (value is int) return value;
return int.tryParse(value.toString());
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:hive_flutter/hive_flutter.dart';
class StorageKeys {
static const String apiBase = 'guangya.apiBase';
static const String accountBase = 'guangya.accountBase';
static const String accessToken = 'guangya.accessToken';
static const String refreshToken = 'guangya.refreshToken';
static const String deviceID = 'guangya.deviceID';
static const String tokenExpiresAt = 'guangya.tokenExpiresAt';
static const String baseUrl = 'guangya.baseUrl';
static const String themeMode = 'guangya.themeMode';
static const String tmdbApiKey = 'guangya.tmdbApiKey';
static const String tmdbProxyHost = 'guangya.tmdbProxyHost';
static const String tmdbProxyPort = 'guangya.tmdbProxyPort';
static const String mediaLibraries = 'guangya.mediaLibraries';
static const String mediaLibraryItems = 'guangya.mediaLibraryItems';
}
class StorageManager {
static const String _boxName = 'guangya';
static Box? _box;
static Future<void> init() async {
await Hive.initFlutter();
_box = await Hive.openBox(_boxName);
}
static Box get _instance {
if (_box == null) throw StateError('StorageManager not initialized');
return _box!;
}
static T? get<T>(String key) => _box?.get(key) as T?;
static Future<void> set(String key, dynamic value) async {
if (value == null) {
await _instance.delete(key);
} else {
await _instance.put(key, value);
}
}
static Future<void> delete(String key) async {
await _instance.delete(key);
}
static Future<void> clear() async {
await _instance.clear();
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import 'core/storage/storage_manager.dart';
import 'core/http/dio_client.dart';
import 'app/app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Init Hive for persistent storage
await StorageManager.init();
// Init Dio HTTP client
DioClient.init();
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
await windowManager.ensureInitialized();
const options = WindowOptions(
size: Size(1280, 820),
minimumSize: Size(980, 640),
center: true,
backgroundColor: Colors.transparent,
titleBarStyle: TitleBarStyle.hidden,
);
windowManager.waitUntilReadyToShow(options, () async {
await windowManager.show();
await windowManager.focus();
});
}
// Trigger network permission
_triggerNetworkPermission();
runApp(const ProviderScope(child: GuangyaApp()));
}
/// Startup network permission trigger
void _triggerNetworkPermission() {
Future.delayed(const Duration(seconds: 2), () async {
try {
final url = Uri.parse('https://www.baidu.com');
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(url);
await request.close();
client.close();
} catch (_) {
// Silently ignore - permission dialog may have been shown
}
});
}
+260
View File
@@ -0,0 +1,260 @@
import 'package:intl/intl.dart';
/// Cloud file model representing a file or folder in the cloud drive.
class CloudFile {
static const supportedVideoExtensions = {
'mp4',
'm4v',
'mkv',
'mov',
'avi',
'ts',
'm2ts',
'mts',
'webm',
'flv',
'f4v',
'wmv',
'asf',
'mpg',
'mpeg',
'vob',
'iso',
'rm',
'rmvb',
'3gp',
'ogv',
};
final String id;
final String name;
final bool isDirectory;
final int? size;
final String? gcid;
final int? subDirectoryCount;
final int? subFileCount;
final String modifiedAt;
final String cloudPath;
final int fileType;
const CloudFile({
required this.id,
required this.name,
required this.isDirectory,
this.size,
this.gcid,
this.subDirectoryCount,
this.subFileCount,
this.modifiedAt = '',
this.cloudPath = '',
this.fileType = 0,
});
bool get isVideo {
if (isDirectory) return false;
if (fileType == 2) return true;
final ext = name.split('.').last.toLowerCase();
return supportedVideoExtensions.contains(ext);
}
bool get isImage => fileType == 1;
bool get isAudio => fileType == 3;
bool get isDocument => fileType == 4;
String get icon {
if (isDirectory) return 'folder';
if (isVideo) return 'movie';
switch (fileType) {
case 1:
return 'image';
case 2:
return 'movie';
case 3:
return 'music_note';
case 4:
return 'description';
case 5:
case 9:
return 'archive';
default:
return 'insert_drive_file';
}
}
String get typeName {
if (isDirectory) return '文件夹';
if (isVideo) return '视频';
const names = {1: '图片', 2: '视频', 3: '音频', 4: '文档', 5: '压缩包', 9: 'BT种子'};
return names[fileType] ?? '文件';
}
String get formattedSize {
if (size == null) return '--';
return _formatBytes(size!);
}
static String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
factory CloudFile.fromJson(Map<String, dynamic> json) {
final name =
json['name'] ??
json['fileName'] ??
json['file_name'] ??
json['resName'] ??
json['res_name'] ??
json['dirName'] ??
json['dir_name'] ??
'';
final id = _extractId(json);
if (id.isEmpty) throw FormatException('Missing file ID');
final resourceType = _extractInt(json, ['resType']);
final type = _extractInt(json, ['fileType', 'type']) ?? 0;
bool isDir;
final explicitDir = json['isDir'] ?? json['dir'] ?? json['directoryType'];
if (explicitDir != null) {
isDir = _truthyDirectoryFlag(explicitDir);
} else if (resourceType != null) {
isDir = resourceType == 2;
} else {
isDir =
type == 0 && (json['dirName'] != null || json['children'] != null);
}
int? fileSize = _extractIntDeep(json, [
'size',
'fileSize',
'resSize',
'totalSize',
'dirSize',
'folderSize',
]);
if (isDir && fileSize == null) fileSize = 0;
final epoch = _extractIntDeep(json, ['utime', 'ctime']);
return CloudFile(
id: id,
name: name.toString(),
isDirectory: isDir,
size: fileSize,
gcid: _extractStringDeep(json, ['gcid', 'gcId', 'gcidValue', 'hash']),
subDirectoryCount: _extractIntDeep(json, ['subDirCount']),
subFileCount: _extractIntDeep(json, ['subFileCount']),
modifiedAt:
json['updateTime']?.toString() ??
json['updatedAt']?.toString() ??
json['modifyTime']?.toString() ??
json['createTime']?.toString() ??
(epoch == null ? null : _formatEpoch(epoch)) ??
'',
cloudPath:
_extractString(json, ['location', 'path', 'fullPath']) ??
name.toString(),
fileType: type,
);
}
static String _extractId(Map<String, dynamic> json) {
for (final key in ['fileId', 'file_id', 'resId', 'res_id', 'fid', 'id']) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
return '';
}
static String? _extractString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null) return v.toString();
}
return null;
}
static int? _extractInt(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
final value = _toInt(v);
if (value != null) return value;
}
return null;
}
static String? _extractStringDeep(
Map<String, dynamic> json,
List<String> keys,
) {
final direct = _extractString(json, keys);
if (direct != null && direct.isNotEmpty) return direct;
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _extractStringDeep(value, keys);
if (found != null && found.isNotEmpty) return found;
} else if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final found = _extractStringDeep(item, keys);
if (found != null && found.isNotEmpty) return found;
}
}
}
}
return null;
}
static int? _extractIntDeep(Map<String, dynamic> json, List<String> keys) {
final direct = _extractInt(json, keys);
if (direct != null) return direct;
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _extractIntDeep(value, keys);
if (found != null) return found;
} else if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final found = _extractIntDeep(item, keys);
if (found != null) return found;
}
}
}
}
return null;
}
static int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.toInt();
return int.tryParse(value.toString());
}
static bool _truthyDirectoryFlag(dynamic value) {
if (value is bool) return value;
if (value is num) return value == 1;
final text = value.toString().toLowerCase();
return text == '1' || text == 'true' || text == 'folder' || text == 'dir';
}
static String _formatEpoch(int epoch) {
final seconds = epoch > 9999999999 ? epoch ~/ 1000 : epoch;
return DateFormat(
'yyyy-MM-dd HH:mm',
).format(DateTime.fromMillisecondsSinceEpoch(seconds * 1000));
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is CloudFile && id == other.id;
@override
int get hashCode => id.hashCode;
}
+387
View File
@@ -0,0 +1,387 @@
import 'cloud_file.dart';
enum TMDBMediaKind { automatic, movie, tv }
extension TMDBMediaKindX on TMDBMediaKind {
String get title {
switch (this) {
case TMDBMediaKind.automatic:
return '自动识别';
case TMDBMediaKind.movie:
return '电影';
case TMDBMediaKind.tv:
return '剧集';
}
}
}
enum MediaLibraryKind { movies, series, mixed }
extension MediaLibraryKindX on MediaLibraryKind {
String get title {
switch (this) {
case MediaLibraryKind.movies:
return '电影';
case MediaLibraryKind.series:
return '电视剧';
case MediaLibraryKind.mixed:
return '混合内容';
}
}
}
class MediaLibrarySource {
final String id;
final String? rootID;
final String path;
const MediaLibrarySource({
required this.id,
required this.rootID,
required this.path,
});
factory MediaLibrarySource.fromJson(Map<String, dynamic> json) {
return MediaLibrarySource(
id:
json['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(),
rootID: json['rootID']?.toString(),
path: json['path']?.toString() ?? '未配置目录',
);
}
Map<String, dynamic> toJson() => {'id': id, 'rootID': rootID, 'path': path};
}
class MediaLibraryDefinition {
final String id;
final String name;
final List<MediaLibrarySource> sources;
final MediaLibraryKind kind;
final bool recursive;
final int minimumSizeMB;
final DateTime? updatedAt;
const MediaLibraryDefinition({
required this.id,
required this.name,
required this.sources,
this.kind = MediaLibraryKind.mixed,
this.recursive = true,
this.minimumSizeMB = 50,
this.updatedAt,
});
String? get rootID => sources.isEmpty ? null : sources.first.rootID;
String get rootPath {
if (sources.length == 1) return sources.first.path;
if (sources.isEmpty) return '未配置目录';
return '${sources.length} 个媒体目录';
}
MediaLibraryDefinition copyWith({
String? name,
List<MediaLibrarySource>? sources,
MediaLibraryKind? kind,
bool? recursive,
int? minimumSizeMB,
DateTime? updatedAt,
}) {
return MediaLibraryDefinition(
id: id,
name: name ?? this.name,
sources: sources ?? this.sources,
kind: kind ?? this.kind,
recursive: recursive ?? this.recursive,
minimumSizeMB: minimumSizeMB ?? this.minimumSizeMB,
updatedAt: updatedAt ?? this.updatedAt,
);
}
factory MediaLibraryDefinition.fromJson(Map<String, dynamic> json) {
final rawSources = json['sources'];
final sources = rawSources is List
? rawSources
.whereType<Map>()
.map(
(item) => MediaLibrarySource.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList()
: [
MediaLibrarySource(
id: '${json['id'] ?? DateTime.now().microsecondsSinceEpoch}-legacy',
rootID: json['rootID']?.toString(),
path: json['rootPath']?.toString() ?? '未配置目录',
),
];
return MediaLibraryDefinition(
id:
json['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(),
name: json['name']?.toString() ?? '未命名媒体库',
sources: sources,
kind: MediaLibraryKind.values.firstWhere(
(kind) => kind.name == json['kind']?.toString(),
orElse: () => MediaLibraryKind.mixed,
),
recursive: json['recursive'] != false,
minimumSizeMB: _toInt(json['minimumSizeMB']) ?? 50,
updatedAt: _parseDate(json['updatedAt']),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'sources': sources.map((source) => source.toJson()).toList(),
'kind': kind.name,
'recursive': recursive,
'minimumSizeMB': minimumSizeMB,
'updatedAt': updatedAt?.toIso8601String(),
};
}
class MediaLibraryItem {
final String libraryID;
final CloudFile file;
final int? tmdbID;
final String title;
final String originalTitle;
final TMDBMediaKind? mediaKind;
final String releaseDate;
final String overview;
final String? posterPath;
final String? backdropPath;
final bool hasChineseAudio;
final bool hasChineseSubtitle;
final int? collectionID;
final String? collectionName;
final DateTime updatedAt;
const MediaLibraryItem({
required this.libraryID,
required this.file,
this.tmdbID,
required this.title,
required this.originalTitle,
this.mediaKind,
this.releaseDate = '',
this.overview = '',
this.posterPath,
this.backdropPath,
this.hasChineseAudio = false,
this.hasChineseSubtitle = false,
this.collectionID,
this.collectionName,
required this.updatedAt,
});
String get id => file.id;
String get year => releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
bool get isMatched => mediaKind != null && tmdbID != null;
factory MediaLibraryItem.fromFile(String libraryID, CloudFile file) {
final parsed = ParsedMediaName.parse(file.name);
final kind = parsed.isEpisode ? TMDBMediaKind.tv : TMDBMediaKind.movie;
return MediaLibraryItem(
libraryID: libraryID,
file: file,
title: parsed.title,
originalTitle: parsed.title,
mediaKind: kind,
releaseDate: parsed.year == null ? '' : '${parsed.year}-01-01',
updatedAt: DateTime.now(),
);
}
factory MediaLibraryItem.fromJson(Map<String, dynamic> json) {
return MediaLibraryItem(
libraryID: json['libraryID']?.toString() ?? '',
file: CloudFile(
id: json['fileID']?.toString() ?? '',
name: json['cloudName']?.toString() ?? '',
isDirectory: false,
size: _toInt(json['fileSize']),
gcid: json['gcid']?.toString(),
modifiedAt: json['modifiedAt']?.toString() ?? '',
cloudPath: json['resourcePath']?.toString() ?? '',
fileType: _toInt(json['fileType']) ?? 2,
),
tmdbID: _toInt(json['tmdbID']),
title: json['title']?.toString() ?? '',
originalTitle: json['originalTitle']?.toString() ?? '',
mediaKind: TMDBMediaKind.values
.where((kind) => kind.name == json['mediaKind'])
.firstOrNull,
releaseDate: json['releaseDate']?.toString() ?? '',
overview: json['overview']?.toString() ?? '',
posterPath: json['posterPath']?.toString(),
backdropPath: json['backdropPath']?.toString(),
hasChineseAudio: json['hasChineseAudio'] == true,
hasChineseSubtitle: json['hasChineseSubtitle'] == true,
collectionID: _toInt(json['collectionID']),
collectionName: json['collectionName']?.toString(),
updatedAt: _parseDate(json['updatedAt']) ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() => {
'libraryID': libraryID,
'fileID': file.id,
'resourcePath': file.cloudPath,
'cloudName': file.name,
'fileSize': file.size,
'gcid': file.gcid,
'fileType': file.fileType,
'modifiedAt': file.modifiedAt,
'tmdbID': tmdbID,
'mediaKind': mediaKind?.name,
'title': title,
'originalTitle': originalTitle,
'releaseDate': releaseDate,
'overview': overview,
'posterPath': posterPath,
'backdropPath': backdropPath,
'hasChineseAudio': hasChineseAudio,
'hasChineseSubtitle': hasChineseSubtitle,
'collectionID': collectionID,
'collectionName': collectionName,
'updatedAt': updatedAt.toIso8601String(),
};
}
class MediaLibraryStatistics {
final int total;
final int movies;
final int series;
final int unmatched;
final int collections;
const MediaLibraryStatistics({
this.total = 0,
this.movies = 0,
this.series = 0,
this.unmatched = 0,
this.collections = 0,
});
factory MediaLibraryStatistics.fromItems(Iterable<MediaLibraryItem> items) {
var total = 0;
var movies = 0;
var series = 0;
var unmatched = 0;
final collections = <String>{};
for (final item in items) {
total++;
if (item.mediaKind == TMDBMediaKind.movie) movies++;
if (item.mediaKind == TMDBMediaKind.tv) series++;
if (!item.isMatched) unmatched++;
final collectionKey =
item.collectionID?.toString() ?? item.collectionName;
if (collectionKey != null && collectionKey.isNotEmpty) {
collections.add(collectionKey);
}
}
return MediaLibraryStatistics(
total: total,
movies: movies,
series: series,
unmatched: unmatched,
collections: collections.length,
);
}
}
class MediaLibraryScanProgress {
final String phase;
final int completed;
final int total;
const MediaLibraryScanProgress({
this.phase = '',
this.completed = 0,
this.total = 0,
});
}
class ParsedMediaName {
final String title;
final int? year;
final int? season;
final int? episode;
final bool isEpisode;
const ParsedMediaName({
required this.title,
this.year,
this.season,
this.episode,
this.isEpisode = false,
});
factory ParsedMediaName.parse(String name) {
var stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
stem = stem.replaceAll(RegExp(r'[\._]+'), ' ');
final episodeMatch =
RegExp(
r'(?:S|第)\s*(\d{1,2})\s*(?:E|季\s*第?)\s*(\d{1,3})',
caseSensitive: false,
).firstMatch(stem) ??
RegExp(r'(\d{1,2})x(\d{1,3})', caseSensitive: false).firstMatch(stem);
final yearMatch = RegExp(r'(19\d{2}|20\d{2})').firstMatch(stem);
final cutIndex =
[
episodeMatch?.start,
yearMatch?.start,
RegExp(
r'\b(2160p|1080p|720p|bluray|web[- ]?dl|hdtv|x264|x265|hevc)\b',
caseSensitive: false,
).firstMatch(stem)?.start,
].whereType<int>().fold<int?>(
null,
(min, value) => min == null || value < min ? value : min,
);
var title = cutIndex == null ? stem : stem.substring(0, cutIndex);
title = title
.replaceAll(RegExp(r'[\[\]\(\)]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (title.isEmpty) title = stem.trim();
return ParsedMediaName(
title: title,
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
season: episodeMatch == null
? null
: int.tryParse(episodeMatch.group(1)!),
episode: episodeMatch == null
? null
: int.tryParse(episodeMatch.group(2)!),
isEpisode: episodeMatch != null,
);
}
}
int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.toInt();
return int.tryParse(value.toString());
}
DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}
+330
View File
@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/auth_provider.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _phoneController = TextEditingController(text: '+86 ');
final _codeController = TextEditingController();
String _activeTab = 'sms';
@override
void dispose() {
_phoneController.dispose();
_codeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final auth = ref.watch(authProvider);
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withAlpha(30),
theme.colorScheme.background,
theme.colorScheme.primary.withAlpha(15),
],
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(80),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Icon(
LucideIcons.cloud,
size: 48,
color: theme.colorScheme.primaryForeground,
),
),
const SizedBox(height: 20),
Text(
'光鸭云盘',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: theme.colorScheme.foreground,
),
),
const SizedBox(height: 8),
Text(
'登录以访问您的云端文件',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 32),
// Login card
ShadCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTabs(context),
const SizedBox(height: 24),
if (_activeTab == 'sms')
_buildSMSLogin(context, auth)
else
_buildQRLogin(context, auth),
],
),
),
),
],
),
),
),
),
),
);
}
Widget _buildTabs(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.muted,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
Expanded(
child: _TabButton(
label: '手机登录',
isActive: _activeTab == 'sms',
onTap: () => setState(() => _activeTab = 'sms'),
),
),
Expanded(
child: _TabButton(
label: '扫码登录',
isActive: _activeTab == 'qr',
onTap: () {
setState(() => _activeTab = 'qr');
if (_activeTab == 'qr') {
ref.read(authProvider.notifier).initQRLogin();
}
},
),
),
],
),
);
}
Widget _buildSMSLogin(BuildContext context, AuthState auth) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ShadInput(
controller: _phoneController,
placeholder: const Text('手机号'),
leading: Icon(
LucideIcons.phone,
size: 16,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ShadInput(
controller: _codeController,
placeholder: const Text('验证码'),
leading: Icon(
LucideIcons.keyRound,
size: 16,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 12),
ShadButton.outline(
onPressed: auth.codeCountdown > 0
? null
: () {
ref.read(authProvider.notifier).updatePhoneNumber(
_phoneController.text);
ref.read(authProvider.notifier).sendVerificationCode();
},
child: Text(
auth.codeCountdown > 0
? '${auth.codeCountdown}s'
: '获取验证码',
),
),
],
),
if (auth.errorMessage != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.destructive.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.destructive.withAlpha(50),
),
),
child: Text(
auth.errorMessage!,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.destructive,
),
),
),
],
const SizedBox(height: 24),
ShadButton(
onPressed: () {
ref.read(authProvider.notifier).updatePhoneNumber(
_phoneController.text);
ref.read(authProvider.notifier).updateVerificationCode(
_codeController.text);
ref.read(authProvider.notifier).verifySMSCode();
},
child: const Text('登录'),
),
],
);
}
Widget _buildQRLogin(BuildContext context, AuthState auth) {
final theme = ShadTheme.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (auth.qrPayload.isNotEmpty)
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border.all(color: theme.colorScheme.border),
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: auth.qrPayload,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.transparent,
),
)
else
const SizedBox(
width: 200,
height: 200,
child: Center(child: ShadProgress()),
),
const SizedBox(height: 16),
Text(
auth.qrStatus,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 16),
ShadButton.outline(
onPressed: () => ref.read(authProvider.notifier).initQRLogin(),
leading: const Icon(LucideIcons.refreshCw, size: 16),
child: const Text('刷新二维码'),
),
],
);
}
}
class _TabButton extends StatelessWidget {
final String label;
final bool isActive;
final VoidCallback onTap;
const _TabButton({
required this.label,
required this.isActive,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isActive ? theme.colorScheme.background : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: isActive
? [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
color: isActive
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
),
);
}
}
+789
View File
@@ -0,0 +1,789 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/storage/storage_manager.dart';
import '../models/media_library.dart';
import '../providers/auth_provider.dart';
import '../providers/file_provider.dart';
import '../providers/media_library_provider.dart';
class MediaLibraryPage extends ConsumerStatefulWidget {
final bool showLibrarySidebar;
final String? searchTitle;
const MediaLibraryPage({
super.key,
this.showLibrarySidebar = true,
this.searchTitle,
});
static void showCreateDialog(BuildContext context, WidgetRef ref) {
_MediaLibraryPageState._showCreateLibraryDialog(context, ref);
}
@override
ConsumerState<MediaLibraryPage> createState() => _MediaLibraryPageState();
}
class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
String _tmdbApiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
bool _showApiKeyInput = false;
bool _tmdbSearching = false;
String? _tmdbError;
List<Map<String, dynamic>> _tmdbResults = [];
final _apiKeyController = TextEditingController();
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_apiKeyController.text = _tmdbApiKey;
Future.microtask(() {
ref.read(mediaLibraryProvider.notifier).api = ref
.read(authProvider.notifier)
.api;
ref.read(mediaLibraryProvider.notifier).load();
});
}
@override
void dispose() {
_apiKeyController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(mediaLibraryProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
child: Column(
children: [
_buildHeader(context, state),
const SizedBox(height: 12),
_buildToolbar(context, state),
const SizedBox(height: 12),
if (state.errorMessage != null || state.statusMessage != null)
_buildMessageBar(context, state),
Expanded(
child: widget.showLibrarySidebar
? Row(
children: [
_buildLibraryList(context, state),
VerticalDivider(
width: 24,
color: ShadTheme.of(context).colorScheme.border,
),
Expanded(child: _buildMainPanel(context, state)),
],
)
: _buildMainPanel(context, state),
),
],
),
);
}
Widget _buildHeader(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
final stats = state.statistics;
return Row(
children: [
Icon(Icons.movie_filter_rounded, size: 26, color: cs.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.searchTitle ?? '光鸭影视',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
Text(
state.selectedLibrary?.name ?? '创建媒体库后扫描云盘影视文件',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
overflow: TextOverflow.ellipsis,
),
],
),
),
_statPill(context, '全部', stats.total.toString()),
_statPill(context, '电影', stats.movies.toString()),
_statPill(context, '剧集', stats.series.toString()),
_statPill(context, '待匹配', stats.unmatched.toString()),
],
);
}
Widget _statPill(BuildContext context, String label, String value) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Text(
'$label $value',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
);
}
Widget _buildToolbar(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: ShadInput(
controller: _searchController,
placeholder: const Text('搜索影视库或 TMDB…'),
leading: Icon(
Icons.search_rounded,
size: 16,
color: cs.mutedForeground,
),
onChanged: (value) =>
ref.read(mediaLibraryProvider.notifier).setSearchQuery(value),
onSubmitted: _searchTMDB,
),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => _showCreateLibraryDialog(context, ref),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('媒体库'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: state.selectedLibrary == null || state.isScanning
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.scanSelectedLibrary(),
leading: const Icon(Icons.refresh_rounded, size: 16),
child: const Text('扫描'),
),
if (state.isScanning) ...[
const SizedBox(width: 8),
ShadButton.destructive(
onPressed: () =>
ref.read(mediaLibraryProvider.notifier).cancelScan(),
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('停止'),
),
],
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => setState(() => _showApiKeyInput = !_showApiKeyInput),
leading: Icon(
_tmdbApiKey.isEmpty ? Icons.key_off_rounded : Icons.key_rounded,
size: 16,
),
child: const Text('TMDB'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: _tmdbApiKey.isEmpty
? null
: () => _searchTMDB(_searchController.text),
leading: const Icon(Icons.travel_explore_rounded, size: 16),
child: const Text('匹配'),
),
],
);
}
Widget _buildMessageBar(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
final isError = state.errorMessage != null;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isError ? cs.destructive.withValues(alpha: 0.08) : cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isError ? cs.destructive : cs.border),
),
child: Text(
state.errorMessage ?? state.statusMessage ?? '',
style: TextStyle(
fontSize: 12,
color: isError ? cs.destructive : cs.mutedForeground,
),
),
);
}
Widget _buildLibraryList(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_showApiKeyInput) _buildTMDBConfig(context),
Text(
'媒体库',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.mutedForeground,
),
),
const SizedBox(height: 8),
Expanded(
child: state.libraries.isEmpty
? _emptyLibraryHint(context)
: ListView.builder(
itemCount: state.libraries.length,
itemBuilder: (context, index) {
final library = state.libraries[index];
final selected = library.id == state.selectedLibrary?.id;
return _LibraryRow(
library: library,
selected: selected,
onTap: () => ref
.read(mediaLibraryProvider.notifier)
.selectLibrary(library.id),
onDelete: () => ref
.read(mediaLibraryProvider.notifier)
.deleteLibrary(library.id),
);
},
),
),
],
),
);
}
Widget _buildTMDBConfig(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
ShadInput(
controller: _apiKeyController,
placeholder: const Text('TMDB API Key'),
obscureText: true,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ShadButton(onPressed: _saveApiKey, child: const Text('保存')),
),
],
),
);
}
Widget _emptyLibraryHint(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.video_library_outlined,
size: 42,
color: cs.mutedForeground,
),
const SizedBox(height: 12),
Text(
'暂无媒体库',
style: TextStyle(fontSize: 14, color: cs.mutedForeground),
),
const SizedBox(height: 10),
ShadButton.outline(
onPressed: () => _showCreateLibraryDialog(context, ref),
child: const Text('创建媒体库'),
),
],
),
);
}
Widget _buildMainPanel(BuildContext context, MediaLibraryState state) {
if (state.isLoading) {
return const Center(child: ShadProgress());
}
if (state.isScanning) {
return _scanProgress(context, state);
}
if (_tmdbSearching || _tmdbResults.isNotEmpty || _tmdbError != null) {
return _tmdbResultPanel(context);
}
final items = state.visibleItems;
if (state.selectedLibrary == null) {
return _mainEmpty(context, '还没有媒体库', '从云盘根目录或当前目录创建一个媒体库');
}
if (items.isEmpty) {
return _mainEmpty(context, '没有扫描结果', '点击扫描读取该媒体库下的视频文件');
}
return LayoutBuilder(
builder: (context, constraints) {
final columns = (constraints.maxWidth / 220).floor().clamp(2, 6);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.8,
),
itemCount: items.length,
itemBuilder: (context, index) => _MediaItemTile(item: items[index]),
);
},
);
}
Widget _scanProgress(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ShadProgress(),
const SizedBox(height: 16),
Text(
state.progress.phase,
textAlign: TextAlign.center,
style: TextStyle(color: cs.foreground),
),
const SizedBox(height: 6),
Text(
'已发现 ${state.progress.completed} 个视频文件',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
],
),
),
);
}
Widget _mainEmpty(BuildContext context, String title, String subtitle) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_creation_outlined,
size: 56,
color: cs.mutedForeground,
),
const SizedBox(height: 14),
Text(title, style: TextStyle(fontSize: 16, color: cs.foreground)),
const SizedBox(height: 6),
Text(
subtitle,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
],
),
);
}
Widget _tmdbResultPanel(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
if (_tmdbSearching) return const Center(child: ShadProgress());
if (_tmdbError != null) {
return _mainEmpty(context, 'TMDB 请求失败', _tmdbError!);
}
if (_tmdbResults.isEmpty) {
return _mainEmpty(context, '没有 TMDB 结果', '换一个片名继续搜索');
}
return ListView.separated(
itemCount: _tmdbResults.length,
separatorBuilder: (context, index) =>
Divider(color: cs.border, height: 1),
itemBuilder: (context, index) =>
_buildTMDBResultItem(context, _tmdbResults[index]),
);
}
Widget _buildTMDBResultItem(BuildContext context, Map<String, dynamic> item) {
final cs = ShadTheme.of(context).colorScheme;
final title = item['title'] ?? item['name'] ?? '未知';
final overview = item['overview']?.toString() ?? '';
final releaseDate =
item['release_date']?.toString() ??
item['first_air_date']?.toString() ??
'';
final mediaType = item['media_type']?.toString() ?? 'movie';
final posterPath = item['poster_path'] as String?;
final year = releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: posterPath == null
? _posterPlaceholder(context, 74, 110)
: Image.network(
'https://image.tmdb.org/t/p/w200$posterPath',
width: 74,
height: 110,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_posterPlaceholder(context, 74, 110),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
year.isEmpty ? title.toString() : '$title ($year)',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
),
ShadBadge(child: Text(mediaType == 'tv' ? '剧集' : '电影')),
],
),
const SizedBox(height: 6),
Text(
overview.isEmpty ? '暂无简介' : overview,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _posterPlaceholder(BuildContext context, double width, double height) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: width,
height: height,
color: cs.muted,
child: Icon(Icons.movie_rounded, color: cs.mutedForeground),
);
}
static void _showCreateLibraryDialog(BuildContext context, WidgetRef ref) {
final fileState = ref.read(fileProvider);
final currentRootID = fileState.folderPath.isEmpty
? null
: fileState.folderPath.last.id;
final currentPath = fileState.folderPath.isEmpty
? '云盘根目录'
: fileState.folderPath.map((file) => file.name).join(' / ');
final nameController = TextEditingController(
text: fileState.folderPath.isEmpty
? '我的影视库'
: fileState.folderPath.last.name,
);
final minSizeController = TextEditingController(text: '50');
var kind = MediaLibraryKind.mixed;
var recursive = true;
showShadDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setDialogState) => ShadDialog(
title: const Text('创建媒体库'),
description: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('来源:$currentPath'),
),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
ShadButton(
onPressed: () {
ref
.read(mediaLibraryProvider.notifier)
.createLibrary(
name: nameController.text,
rootID: currentRootID,
rootPath: currentPath,
kind: kind,
recursive: recursive,
minimumSizeMB:
int.tryParse(minSizeController.text.trim()) ?? 50,
);
Navigator.of(ctx).pop();
},
child: const Text('创建'),
),
],
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ShadInput(
controller: nameController,
placeholder: const Text('媒体库名称'),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: DropdownButtonFormField<MediaLibraryKind>(
initialValue: kind,
decoration: const InputDecoration(
labelText: '类型',
border: OutlineInputBorder(),
),
items: MediaLibraryKind.values
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.title),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
setDialogState(() => kind = value);
}
},
),
),
const SizedBox(width: 10),
SizedBox(
width: 120,
child: ShadInput(
controller: minSizeController,
placeholder: const Text('最小 MB'),
),
),
],
),
const SizedBox(height: 10),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: recursive,
onChanged: (value) =>
setDialogState(() => recursive = value ?? true),
title: const Text('递归扫描子目录'),
controlAffinity: ListTileControlAffinity.leading,
),
],
),
),
),
),
);
}
void _saveApiKey() {
final key = _apiKeyController.text.trim();
StorageManager.set(StorageKeys.tmdbApiKey, key);
setState(() {
_tmdbApiKey = key;
_showApiKeyInput = false;
});
}
Future<void> _searchTMDB(String query) async {
final text = query.trim();
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
setState(() {
_tmdbSearching = true;
_tmdbError = null;
_tmdbResults = [];
});
try {
final api = ref.read(authProvider.notifier).api;
final result = await api.tmdbSearch(text, apiKey: _tmdbApiKey);
final results =
(result['results'] as List?)
?.whereType<Map>()
.where(
(item) =>
item['media_type'] == 'movie' || item['media_type'] == 'tv',
)
.map((item) => Map<String, dynamic>.from(item))
.toList() ??
[];
setState(() {
_tmdbResults = results;
_tmdbSearching = false;
});
} catch (e) {
setState(() {
_tmdbError = e.toString();
_tmdbSearching = false;
});
}
}
}
class _LibraryRow extends StatelessWidget {
final MediaLibraryDefinition library;
final bool selected;
final VoidCallback onTap;
final VoidCallback onDelete;
const _LibraryRow({
required this.library,
required this.selected,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: selected
? cs.primary.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? cs.primary : cs.border),
),
child: ListTile(
dense: true,
leading: Icon(
Icons.video_library_rounded,
size: 20,
color: selected ? cs.primary : cs.mutedForeground,
),
title: Text(
library.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: cs.foreground,
),
),
subtitle: Text(
'${library.kind.title} · ${library.rootPath}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
trailing: PopupMenuButton<String>(
tooltip: '更多',
icon: Icon(
Icons.more_horiz_rounded,
size: 18,
color: cs.mutedForeground,
),
onSelected: (value) {
if (value == 'delete') onDelete();
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
onTap: onTap,
),
);
}
}
class _MediaItemTile extends StatelessWidget {
final MediaLibraryItem item;
const _MediaItemTile({required this.item});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Row(
children: [
Container(
width: 54,
height: 76,
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.movie_rounded,
size: 24,
color: cs.mutedForeground,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.year.isEmpty
? item.title
: '${item.title} (${item.year})',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 6),
Text(
item.file.formattedSize,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
const SizedBox(height: 2),
Text(
item.file.cloudPath,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
],
),
),
],
),
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/theme_provider.dart';
import '../core/storage/storage_manager.dart';
class SettingsDialog extends ConsumerStatefulWidget {
const SettingsDialog({super.key});
@override
ConsumerState<SettingsDialog> createState() => _SettingsDialogState();
}
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
final _tmdbApiKeyController = TextEditingController();
final _tmdbProxyHostController = TextEditingController();
final _tmdbProxyPortController = TextEditingController();
@override
void initState() {
super.initState();
_tmdbApiKeyController.text =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
_tmdbProxyHostController.text =
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '';
_tmdbProxyPortController.text =
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '';
}
@override
void dispose() {
_tmdbApiKeyController.dispose();
_tmdbProxyHostController.dispose();
_tmdbProxyPortController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final themeState = ref.watch(themeProvider);
return ShadDialog(
title: const Text('设置'),
description: const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text('自定义应用外观和行为'),
),
actions: [
ShadButton(
child: const Text('完成'),
onPressed: () {
_saveSettings();
Navigator.of(context).pop();
},
),
],
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('外观',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground)),
const SizedBox(height: 12),
_SettingsRow(
icon: Icons.light_mode_rounded,
label: '主题模式',
child: ShadSelect<String>(
initialValue: _themeModeToString(themeState.themeMode),
minWidth: 160,
placeholder: const Text('选择主题'),
options: [
ShadOption(
value: 'light',
child: const Row(children: [
Icon(Icons.light_mode_rounded, size: 14),
SizedBox(width: 8),
Text('浅色')
])),
ShadOption(
value: 'dark',
child: const Row(children: [
Icon(Icons.dark_mode_rounded, size: 14),
SizedBox(width: 8),
Text('深色')
])),
ShadOption(
value: 'system',
child: const Row(children: [
Icon(Icons.brightness_auto_rounded, size: 14),
SizedBox(width: 8),
Text('跟随系统')
])),
],
selectedOptionBuilder: (ctx, value) =>
Text(_themeModeToString(_stringToThemeMode(value))),
onChanged: (value) {
if (value != null) {
ref
.read(themeProvider.notifier)
.setThemeMode(_stringToThemeMode(value));
}
},
),
),
const SizedBox(height: 16),
const ShadSeparator.horizontal(),
const SizedBox(height: 16),
Text('TMDB 配置',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground)),
const SizedBox(height: 12),
_SettingsRow(
icon: Icons.key_rounded,
label: 'API Key',
child: SizedBox(
width: 200,
child: ShadInput(
controller: _tmdbApiKeyController,
placeholder: const Text('输入 TMDB API Key'),
),
),
),
const SizedBox(height: 8),
_SettingsRow(
icon: Icons.dns_rounded,
label: '代理地址',
child: SizedBox(
width: 200,
child: ShadInput(
controller: _tmdbProxyHostController,
placeholder: const Text('例: 127.0.0.1'),
),
),
),
const SizedBox(height: 8),
_SettingsRow(
icon: Icons.numbers_rounded,
label: '代理端口',
child: SizedBox(
width: 200,
child: ShadInput(
controller: _tmdbProxyPortController,
placeholder: const Text('例: 7890'),
),
),
),
const SizedBox(height: 16),
const ShadSeparator.horizontal(),
const SizedBox(height: 16),
Text('关于',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground)),
const SizedBox(height: 12),
_SettingsRow(
icon: Icons.cloud_rounded,
label: '版本',
child: Text('v1.0.0',
style: TextStyle(fontSize: 13, color: cs.mutedForeground)),
),
],
),
),
),
);
}
void _saveSettings() {
StorageManager.set(
StorageKeys.tmdbApiKey, _tmdbApiKeyController.text.trim());
StorageManager.set(
StorageKeys.tmdbProxyHost, _tmdbProxyHostController.text.trim());
StorageManager.set(
StorageKeys.tmdbProxyPort, _tmdbProxyPortController.text.trim());
}
String _themeModeToString(ThemeMode mode) {
switch (mode) {
case ThemeMode.light:
return 'light';
case ThemeMode.dark:
return 'dark';
case ThemeMode.system:
return 'system';
}
}
ThemeMode _stringToThemeMode(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
}
class _SettingsRow extends StatelessWidget {
final IconData icon;
final String label;
final Widget child;
const _SettingsRow(
{required this.icon, required this.label, required this.child});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 18, color: cs.mutedForeground),
const SizedBox(width: 12),
Text(label,
style: TextStyle(fontSize: 14, color: cs.foreground)),
const Spacer(),
child,
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+351
View File
@@ -0,0 +1,351 @@
import 'dart:async';
import 'package:flutter_riverpod/legacy.dart';
import '../core/storage/storage_manager.dart';
import '../api/guangya_api.dart';
class AuthState {
final bool isSignedIn;
final bool isLoading;
final bool isRefreshing;
final Map<String, dynamic>? userInfo;
final String? errorMessage;
final String phoneNumber;
final String verificationCode;
final String captchaToken;
final String verificationID;
final int codeCountdown;
final String qrPayload;
final String qrToken;
final String qrStatus;
const AuthState({
this.isSignedIn = false,
this.isLoading = true,
this.isRefreshing = false,
this.userInfo,
this.errorMessage,
this.phoneNumber = '+86 ',
this.verificationCode = '',
this.captchaToken = '',
this.verificationID = '',
this.codeCountdown = 0,
this.qrPayload = '',
this.qrToken = '',
this.qrStatus = '等待生成二维码',
});
AuthState copyWith({
bool? isSignedIn,
bool? isLoading,
bool? isRefreshing,
Map<String, dynamic>? userInfo,
bool clearUserInfo = false,
String? errorMessage,
bool clearError = false,
String? phoneNumber,
String? verificationCode,
String? captchaToken,
String? verificationID,
int? codeCountdown,
String? qrPayload,
String? qrToken,
String? qrStatus,
}) {
return AuthState(
isSignedIn: isSignedIn ?? this.isSignedIn,
isLoading: isLoading ?? this.isLoading,
isRefreshing: isRefreshing ?? this.isRefreshing,
userInfo: clearUserInfo ? null : (userInfo ?? this.userInfo),
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
phoneNumber: phoneNumber ?? this.phoneNumber,
verificationCode: verificationCode ?? this.verificationCode,
captchaToken: captchaToken ?? this.captchaToken,
verificationID: verificationID ?? this.verificationID,
codeCountdown: codeCountdown ?? this.codeCountdown,
qrPayload: qrPayload ?? this.qrPayload,
qrToken: qrToken ?? this.qrToken,
qrStatus: qrStatus ?? this.qrStatus,
);
}
// ── Derived getters ────────────────────────────────────────────
String get userName =>
_findStringDeep(userInfo, ['nickname', 'name', 'username']) ?? '光鸭用户';
String get memberLevel =>
_findStringDeep(userInfo, ['vipName', 'memberName', 'memberLevelName']) ??
'普通会员';
int? get capacity =>
_findInt64Deep(userInfo, ['capacity', 'totalCapacity']);
int? get usedCapacity =>
_findInt64Deep(userInfo, ['usedCapacity', 'usedSpace']);
String get capacityText {
if (capacity == null) return '空间信息暂不可用';
return '${_formatBytes(usedCapacity ?? 0)} / ${_formatBytes(capacity!)}';
}
// ── Static helpers ─────────────────────────────────────────────
static String? _findStringDeep(
Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found =
_findStringDeep(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static int? _findInt64Deep(
Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
return null;
}
static String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
class AuthNotifier extends StateNotifier<AuthState> {
final GuangyaAPI _api = GuangyaAPI(
deviceID: StorageManager.get<String>(StorageKeys.deviceID),
);
Timer? _qrPollingTimer;
Timer? _countdownTimer;
GuangyaAPI get api => _api;
AuthNotifier() : super(const AuthState()) {
_api.accessToken = '';
tryRestoreSession();
}
Future<void> tryRestoreSession() async {
final access = StorageManager.get<String>(StorageKeys.accessToken) ?? '';
final refresh = StorageManager.get<String>(StorageKeys.refreshToken);
_api.accessToken = access;
_api.refreshTokenValue = refresh;
if (access.isNotEmpty || refresh != null) {
try {
await _api.refreshAccessToken();
await _saveTokens();
state = state.copyWith(isSignedIn: true);
await loadAccount();
} catch (_) {
state = state.copyWith(isSignedIn: false);
_api.clearTokens();
await _clearTokens();
}
}
state = state.copyWith(isLoading: false);
}
Future<void> loadAccount() async {
try {
final userInfo = await _api.userInfo();
state = state.copyWith(userInfo: userInfo);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
// ── SMS Login ─────────────────────────────────────────────────────
void updatePhoneNumber(String value) {
state = state.copyWith(phoneNumber: value);
}
void updateVerificationCode(String value) {
state = state.copyWith(verificationCode: value);
}
Future<void> sendVerificationCode() async {
state = state.copyWith(clearError: true);
final cleanPhone =
state.phoneNumber.replaceAll(RegExp(r'[^\d]'), '');
if (cleanPhone.length < 8) {
state = state.copyWith(errorMessage: '请输入有效的手机号');
return;
}
try {
final initResult = await _api.loginSMSInit(cleanPhone);
final captcha =
AuthState._findStringDeep(initResult, ['captcha_token', 'captchaToken']);
if (captcha == null) {
if (AuthState._findStringDeep(initResult, ['url', 'verify_url']) !=
null) {
throw Exception('需要完成验证码验证后再发送短信');
}
throw Exception('获取验证码令牌失败');
}
state = state.copyWith(captchaToken: captcha);
await _api.loginSMSSend(cleanPhone, captchaToken: captcha);
_startCountdown();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void _startCountdown() {
state = state.copyWith(codeCountdown: 60);
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final next = state.codeCountdown - 1;
if (next <= 0) {
timer.cancel();
state = state.copyWith(codeCountdown: 0);
} else {
state = state.copyWith(codeCountdown: next);
}
});
}
Future<void> verifySMSCode() async {
state = state.copyWith(clearError: true);
try {
final verifyResult = await _api.loginSMSVerify(
state.verificationID, state.verificationCode);
final vToken = AuthState._findStringDeep(
verifyResult, ['verification_token', 'verificationToken']);
final code =
AuthState._findStringDeep(verifyResult, ['code']);
if (vToken == null || code == null) throw Exception('验证码验证失败');
await _api.loginSMSSignIn(
code: code,
verificationToken: vToken,
username: state.phoneNumber.replaceAll(RegExp(r'[^\d]'), ''),
captchaToken: state.captchaToken,
);
state = state.copyWith(isSignedIn: true);
await _saveTokens();
await loadAccount();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
// ── QR Login ──────────────────────────────────────────────────────
Future<void> initQRLogin() async {
state = state.copyWith(clearError: true);
try {
final result = await _api.loginQRInit();
final token = AuthState._findStringDeep(
result, ['device_code', 'deviceCode']) ??
'';
final payload = AuthState._findStringDeep(
result, ['verification_uri_complete', 'verificationUriComplete', 'qr_url', 'qrUrl', 'url', 'verification_uri', 'verificationUri', 'qrcode', 'qrCode', 'code', 'qr_payload', 'qrPayload']) ??
token;
state = state.copyWith(
qrPayload: payload,
qrToken: token,
qrStatus: '请使用光鸭APP扫描二维码',
);
_startQRPolling();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void _startQRPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer =
Timer.periodic(const Duration(seconds: 5), (timer) async {
if (state.qrToken.isEmpty) {
timer.cancel();
return;
}
try {
final result = await _api.loginQRPoll(state.qrToken);
final accessToken = AuthState._findStringDeep(
result, ['access_token', 'accessToken']);
if (accessToken != null) {
timer.cancel();
state = state.copyWith(isSignedIn: true);
await _saveTokens();
await loadAccount();
} else {
final error =
AuthState._findStringDeep(result, ['error']);
String? newStatus;
if (error == 'slow_down') {
newStatus = '轮询过快,请稍候';
} else if (error == 'authorization_pending') {
newStatus = '等待扫码确认…';
}
if (newStatus != null) {
state = state.copyWith(qrStatus: newStatus);
}
}
} catch (_) {
// Continue polling on transient errors
}
});
}
// ── Sign out ──────────────────────────────────────────────────────
Future<void> signOut() async {
_api.clearTokens();
await _clearTokens();
_qrPollingTimer?.cancel();
_countdownTimer?.cancel();
state = const AuthState(isLoading: false);
}
// ── Token persistence ─────────────────────────────────────────────
Future<void> _saveTokens() async {
await StorageManager.set(StorageKeys.accessToken, _api.accessToken);
if (_api.refreshTokenValue != null) {
await StorageManager.set(StorageKeys.refreshToken, _api.refreshTokenValue!);
}
if (_api.tokenExpiresAt != null) {
await StorageManager.set(
StorageKeys.tokenExpiresAt,
_api.tokenExpiresAt!.millisecondsSinceEpoch,
);
}
await StorageManager.set(StorageKeys.deviceID, _api.deviceID);
}
Future<void> _clearTokens() async {
await StorageManager.delete(StorageKeys.accessToken);
await StorageManager.delete(StorageKeys.refreshToken);
}
@override
void dispose() {
_qrPollingTimer?.cancel();
_countdownTimer?.cancel();
super.dispose();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
+597
View File
@@ -0,0 +1,597 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/legacy.dart';
import '../api/guangya_api.dart';
import '../models/cloud_file.dart';
enum FileSort { name, size, modifiedAt, createdAt, type }
extension FileSortExt on FileSort {
String get title {
switch (this) {
case FileSort.name:
return '名称';
case FileSort.size:
return '大小';
case FileSort.modifiedAt:
return '修改时间';
case FileSort.createdAt:
return '创建时间';
case FileSort.type:
return '类型';
}
}
int get apiOrderBy {
switch (this) {
case FileSort.name:
return 0;
case FileSort.size:
return 1;
case FileSort.createdAt:
return 2;
case FileSort.modifiedAt:
return 3;
case FileSort.type:
return 4;
}
}
}
enum SortDirection { ascending, descending }
enum WorkspaceSection {
files('全部', 'folder'),
recentViewed('最近查看', 'clock'),
recentRestored('最近转存', 'history'),
photos('图片', 'image'),
videos('视频', 'movie'),
audio('音频', 'music_note'),
documents('文档', 'description'),
cloud('云下载', 'cloud_download'),
shares('我的分享', 'share'),
recycle('回收站', 'delete'),
mediaLibrary('光鸭影视', 'movie_filter');
final String label;
final String icon;
const WorkspaceSection(this.label, this.icon);
}
class FileState {
final WorkspaceSection section;
final List<CloudFile> files;
final List<CloudFile> folderPath;
final bool isLoading;
final int currentPage;
final int pageSize;
final int totalPages;
final FileSort serverSort;
final SortDirection serverSortDirection;
final String? errorMessage;
final String? statusMessage;
final Set<String> selectedIDs;
final List<CloudFile>? clipboard;
final bool clipboardIsMove;
const FileState({
this.section = WorkspaceSection.files,
this.files = const [],
this.folderPath = const [],
this.isLoading = false,
this.currentPage = 0,
this.pageSize = 50,
this.totalPages = 1,
this.serverSort = FileSort.name,
this.serverSortDirection = SortDirection.ascending,
this.errorMessage,
this.statusMessage,
this.selectedIDs = const {},
this.clipboard,
this.clipboardIsMove = false,
});
bool get hasSelection => selectedIDs.isNotEmpty;
int get selectedCount => selectedIDs.length;
FileState copyWith({
WorkspaceSection? section,
List<CloudFile>? files,
List<CloudFile>? folderPath,
bool? isLoading,
int? currentPage,
int? pageSize,
int? totalPages,
FileSort? serverSort,
SortDirection? serverSortDirection,
String? errorMessage,
bool clearError = false,
String? statusMessage,
bool clearStatus = false,
Set<String>? selectedIDs,
List<CloudFile>? clipboard,
bool clearClipboard = false,
bool? clipboardIsMove,
}) {
return FileState(
section: section ?? this.section,
files: files ?? this.files,
folderPath: folderPath ?? this.folderPath,
isLoading: isLoading ?? this.isLoading,
currentPage: currentPage ?? this.currentPage,
pageSize: pageSize ?? this.pageSize,
totalPages: totalPages ?? this.totalPages,
serverSort: serverSort ?? this.serverSort,
serverSortDirection: serverSortDirection ?? this.serverSortDirection,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage),
selectedIDs: selectedIDs ?? this.selectedIDs,
clipboard: clearClipboard ? null : (clipboard ?? this.clipboard),
clipboardIsMove: clipboardIsMove ?? this.clipboardIsMove,
);
}
}
class FileNotifier extends StateNotifier<FileState> {
GuangyaAPI? _api;
FileNotifier() : super(const FileState());
set api(GuangyaAPI value) => _api = value;
String? get _currentParentID =>
state.folderPath.isNotEmpty ? state.folderPath.last.id : null;
void setSection(WorkspaceSection section) {
final preservePath = section == WorkspaceSection.mediaLibrary;
state = state.copyWith(
section: section,
files: [],
folderPath: preservePath ? state.folderPath : [],
currentPage: 0,
selectedIDs: {},
);
loadFiles();
}
Future<void> loadFiles({String? parentID}) async {
if (_api == null) return;
state = state.copyWith(isLoading: true, clearError: true);
try {
final result = await _fetchFiles(parentID ?? _currentParentID);
final extracted = _extractFiles(result);
final totalPages = _extractTotalPages(result, extracted.length);
state = state.copyWith(files: extracted, totalPages: totalPages);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<Map<String, dynamic>> _fetchFiles(String? parentID) async {
switch (state.section) {
case WorkspaceSection.files:
return await _api!.fsFiles(
parentID: parentID,
page: state.currentPage,
pageSize: state.pageSize,
orderBy: state.serverSort.apiOrderBy,
sortType: state.serverSortDirection == SortDirection.ascending
? 0
: 1,
);
case WorkspaceSection.recentViewed:
return await _api!.recentViewed(pageSize: 100);
case WorkspaceSection.recentRestored:
return await _api!.recentRestored(pageSize: 100);
case WorkspaceSection.photos:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [1],
resType: 1,
);
case WorkspaceSection.videos:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [2],
resType: 1,
);
case WorkspaceSection.audio:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [3],
resType: 1,
needPlayRecord: true,
);
case WorkspaceSection.documents:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [4],
resType: 1,
);
case WorkspaceSection.cloud:
return await _api!.cloudTaskList();
case WorkspaceSection.shares:
return await _api!.shareUserList();
case WorkspaceSection.recycle:
return await _api!.fsFiles(orderBy: 10, dirType: 4);
case WorkspaceSection.mediaLibrary:
return {
'code': 0,
'data': {'list': <dynamic>[], 'total': 0},
};
}
}
Future<void> navigateToFolder(CloudFile folder) async {
final newPath = [...state.folderPath, folder];
state = state.copyWith(
folderPath: newPath,
currentPage: 0,
selectedIDs: {},
);
await loadFiles(parentID: folder.id);
}
Future<void> navigateBack() async {
if (state.folderPath.isEmpty) return;
final newPath = List<CloudFile>.from(state.folderPath)..removeLast();
state = state.copyWith(
folderPath: newPath,
currentPage: 0,
selectedIDs: {},
);
final parentID = newPath.isNotEmpty ? newPath.last.id : null;
await loadFiles(parentID: parentID);
}
void navigateToPathIndex(int index) {
final newPath = state.folderPath.sublist(0, index + 1);
state = state.copyWith(
folderPath: newPath,
currentPage: 0,
selectedIDs: {},
);
final parentID = index >= 0 ? newPath[index].id : null;
loadFiles(parentID: parentID);
}
void toggleSelection(String id) {
final newSelected = Set<String>.from(state.selectedIDs);
if (newSelected.contains(id)) {
newSelected.remove(id);
} else {
newSelected.add(id);
}
state = state.copyWith(selectedIDs: newSelected);
}
void selectAll() {
if (state.selectedIDs.length == state.files.length) {
state = state.copyWith(selectedIDs: {});
} else {
state = state.copyWith(selectedIDs: state.files.map((f) => f.id).toSet());
}
}
void clearSelection() {
state = state.copyWith(selectedIDs: {});
}
void setSort(FileSort sort) {
SortDirection direction;
if (state.serverSort == sort) {
direction = state.serverSortDirection == SortDirection.ascending
? SortDirection.descending
: SortDirection.ascending;
} else {
direction = SortDirection.ascending;
}
state = state.copyWith(
serverSort: sort,
serverSortDirection: direction,
currentPage: 0,
);
loadFiles(parentID: _currentParentID);
}
void nextPage() {
if (state.currentPage < state.totalPages - 1) {
state = state.copyWith(currentPage: state.currentPage + 1);
loadFiles(parentID: _currentParentID);
}
}
void prevPage() {
if (state.currentPage > 0) {
state = state.copyWith(currentPage: state.currentPage - 1);
loadFiles(parentID: _currentParentID);
}
}
void setPageSize(int size) {
state = state.copyWith(pageSize: size, currentPage: 0);
loadFiles(parentID: _currentParentID);
}
// ── File operations ─────────────────────────────────────────────
Future<void> createFolder(String name) async {
if (_api == null) return;
state = state.copyWith(statusMessage: '正在创建文件夹…');
try {
await _api!.fsCreateDir(name, parentID: _currentParentID);
state = state.copyWith(statusMessage: '文件夹已创建');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString(), clearStatus: true);
}
}
Future<void> renameFile(CloudFile file, String newName) async {
if (_api == null) return;
try {
await _api!.fsRename(file.id, newName);
state = state.copyWith(statusMessage: '重命名成功');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> deleteFiles(List<CloudFile> files) async {
if (_api == null) return;
state = state.copyWith(statusMessage: '正在删除…');
try {
await _api!.fsDelete(files.map((f) => f.id).toList());
state = state.copyWith(
statusMessage: '已删除 ${files.length} 个项目',
selectedIDs: {},
);
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> recycleFiles(List<CloudFile> files) async {
if (_api == null) return;
try {
await _api!.fsRecycle(files.map((f) => f.id).toList());
state = state.copyWith(statusMessage: '已移入回收站', selectedIDs: {});
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> clearRecycleBin() async {
if (_api == null) return;
try {
await _api!.fsClearRecycleBin();
state = state.copyWith(statusMessage: '回收站已清空');
await loadFiles();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void copyToClipboard(List<CloudFile> files) {
state = state.copyWith(clipboard: files, clipboardIsMove: false);
}
void cutToClipboard(List<CloudFile> files) {
state = state.copyWith(clipboard: files, clipboardIsMove: true);
}
Future<void> pasteFromClipboard() async {
if (_api == null || state.clipboard == null) return;
state = state.copyWith(
statusMessage: state.clipboardIsMove ? '正在移动…' : '正在复制…',
);
try {
final ids = state.clipboard!.map((f) => f.id).toList();
if (state.clipboardIsMove) {
await _api!.fsMove(ids, parentID: _currentParentID);
} else {
await _api!.fsCopy(ids, parentID: _currentParentID);
}
state = state.copyWith(statusMessage: '操作完成');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void clearClipboard() {
state = state.copyWith(clearClipboard: true);
}
Future<void> downloadFile(CloudFile file) async {
if (_api == null) return;
try {
final result = await _api!.downloadURL(file.id);
final url = _findStringDeep(result, [
'url',
'downloadUrl',
'download_url',
]);
if (url != null) {
state = state.copyWith(statusMessage: '下载链接: $url');
}
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> uploadLocalFiles(List<File> files, {String? parentID}) async {
if (_api == null || files.isEmpty) return;
final targetParentID = parentID ?? _currentParentID;
state = state.copyWith(statusMessage: '正在上传 ${files.length} 个文件…');
var completed = 0;
try {
for (final file in files) {
if (!await file.exists()) continue;
state = state.copyWith(
statusMessage: '正在上传 ${completed + 1}/${files.length}${file.uri.pathSegments.last}',
);
await _api!.fileUpload(file, parentID: targetParentID);
completed += 1;
}
state = state.copyWith(statusMessage: '已上传 $completed 个文件');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> moveFilesTo(List<CloudFile> files, {String? parentID}) async {
if (_api == null || files.isEmpty) return;
state = state.copyWith(statusMessage: '正在移动 ${files.length} 个项目…');
try {
await _api!.fsMove(files.map((file) => file.id).toList(), parentID: parentID);
state = state.copyWith(statusMessage: '移动完成', selectedIDs: {});
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void clearError() {
state = state.copyWith(clearError: true);
}
void clearStatus() {
state = state.copyWith(clearStatus: true);
}
// ── Helpers ─────────────────────────────────────────────────────
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
final result = <CloudFile>[];
final seen = <String>{};
void visit(dynamic value) {
if (value is Map) {
final map = Map<String, dynamic>.from(value);
try {
final file = CloudFile.fromJson(map);
if (seen.add(file.id)) result.add(file);
} catch (_) {}
for (final v in map.values) {
visit(v);
}
} else if (value is List) {
for (final v in value) {
visit(v);
}
}
}
final preferred = _findArrayDeep(json, const [
'list',
'files',
'fileList',
'file_list',
'items',
'records',
'rows',
'dataList',
'resList',
'resourceList',
]);
if (preferred != null) {
visit(preferred);
return result;
}
visit(json);
return result;
}
int _extractTotalPages(Map<String, dynamic> json, int itemCount) {
final explicitPages = _extractInt(json, [
'totalPages',
'pages',
'pageCount',
]);
if (explicitPages != null && explicitPages > 0) return explicitPages;
final totalItems = _extractInt(json, ['total', 'totalCount', 'count']);
if (totalItems == null || totalItems <= 0) {
return itemCount < state.pageSize ? 1 : state.currentPage + 2;
}
return (totalItems / state.pageSize).ceil().clamp(1, 1 << 31).toInt();
}
static List<dynamic>? _findArrayDeep(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final v = json[key];
if (v is List) return v;
}
const preferredKeys = ['data', 'result', 'payload'];
for (final key in preferredKeys) {
final v = json[key];
if (v is Map) {
final found = _findArrayDeep(Map<String, dynamic>.from(v), keys);
if (found != null) return found;
}
}
for (final entry in json.entries) {
if (preferredKeys.contains(entry.key)) continue;
final v = entry.value;
if (v is Map) {
final found = _findArrayDeep(Map<String, dynamic>.from(v), keys);
if (found != null) return found;
}
}
return null;
}
static int? _extractInt(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found = _extractInt(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static String? _findStringDeep(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found = _findStringDeep(
entry.value as Map<String, dynamic>,
keys,
);
if (found != null) return found;
}
}
return null;
}
}
final fileProvider = StateNotifierProvider<FileNotifier, FileState>(
(ref) => FileNotifier(),
);
+453
View File
@@ -0,0 +1,453 @@
import 'dart:async';
import 'package:flutter_riverpod/legacy.dart';
import '../api/guangya_api.dart';
import '../core/storage/storage_manager.dart';
import '../models/cloud_file.dart';
import '../models/media_library.dart';
class MediaLibraryState {
final List<MediaLibraryDefinition> libraries;
final String? selectedLibraryID;
final List<MediaLibraryItem> items;
final bool isLoading;
final bool isScanning;
final MediaLibraryScanProgress progress;
final String searchQuery;
final String? errorMessage;
final String? statusMessage;
const MediaLibraryState({
this.libraries = const [],
this.selectedLibraryID,
this.items = const [],
this.isLoading = false,
this.isScanning = false,
this.progress = const MediaLibraryScanProgress(),
this.searchQuery = '',
this.errorMessage,
this.statusMessage,
});
MediaLibraryDefinition? get selectedLibrary {
for (final library in libraries) {
if (library.id == selectedLibraryID) return library;
}
return libraries.isEmpty ? null : libraries.first;
}
List<MediaLibraryItem> get visibleItems {
final query = searchQuery.trim().toLowerCase();
if (query.isEmpty) return items;
return items.where((item) {
return item.title.toLowerCase().contains(query) ||
item.file.name.toLowerCase().contains(query) ||
item.file.cloudPath.toLowerCase().contains(query);
}).toList();
}
MediaLibraryStatistics get statistics =>
MediaLibraryStatistics.fromItems(items);
MediaLibraryState copyWith({
List<MediaLibraryDefinition>? libraries,
String? selectedLibraryID,
bool clearSelectedLibrary = false,
List<MediaLibraryItem>? items,
bool? isLoading,
bool? isScanning,
MediaLibraryScanProgress? progress,
String? searchQuery,
String? errorMessage,
bool clearError = false,
String? statusMessage,
bool clearStatus = false,
}) {
return MediaLibraryState(
libraries: libraries ?? this.libraries,
selectedLibraryID: clearSelectedLibrary
? null
: (selectedLibraryID ?? this.selectedLibraryID),
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
isScanning: isScanning ?? this.isScanning,
progress: progress ?? this.progress,
searchQuery: searchQuery ?? this.searchQuery,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage),
);
}
}
class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
GuangyaAPI? _api;
bool _loaded = false;
bool _cancelScan = false;
MediaLibraryNotifier() : super(const MediaLibraryState());
set api(GuangyaAPI value) => _api = value;
Future<void> load() async {
if (_loaded) return;
_loaded = true;
state = state.copyWith(isLoading: true, clearError: true);
try {
final libraries = _loadLibraries();
final selectedID = libraries.isEmpty ? null : libraries.first.id;
final items = selectedID == null
? <MediaLibraryItem>[]
: _loadItems(selectedID);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
items: items,
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<void> selectLibrary(String id) async {
state = state.copyWith(
selectedLibraryID: id,
items: _loadItems(id),
searchQuery: '',
clearError: true,
);
}
Future<void> createLibrary({
required String name,
required String? rootID,
required String rootPath,
MediaLibraryKind kind = MediaLibraryKind.mixed,
bool recursive = true,
int minimumSizeMB = 50,
}) async {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
final now = DateTime.now();
final id = now.microsecondsSinceEpoch.toString();
final library = MediaLibraryDefinition(
id: id,
name: trimmed,
sources: [
MediaLibrarySource(id: '$id-source-0', rootID: rootID, path: rootPath),
],
kind: kind,
recursive: recursive,
minimumSizeMB: minimumSizeMB,
updatedAt: now,
);
final libraries = [...state.libraries, library];
await _saveLibraries(libraries);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: library.id,
items: const [],
statusMessage: '已创建媒体库「${library.name}',
);
}
Future<void> deleteLibrary(String id) async {
final libraries = state.libraries
.where((library) => library.id != id)
.toList();
final allItems = _loadAllItems()
..removeWhere((item) => item.libraryID == id);
await _saveLibraries(libraries);
await _saveAllItems(allItems);
final selectedID = libraries.isEmpty ? null : libraries.first.id;
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
clearSelectedLibrary: selectedID == null,
items: selectedID == null ? const [] : _loadItems(selectedID),
statusMessage: '媒体库已删除',
);
}
Future<void> scanSelectedLibrary() async {
final library = state.selectedLibrary;
if (library == null || _api == null || state.isScanning) return;
_cancelScan = false;
state = state.copyWith(
isScanning: true,
progress: const MediaLibraryScanProgress(phase: '准备扫描'),
clearError: true,
clearStatus: true,
);
try {
final discovered = <CloudFile>[];
for (final source in library.sources) {
if (_cancelScan) break;
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: '扫描 ${source.path}',
completed: discovered.length,
),
);
final files = await _scanSource(
source.rootID,
source.path,
recursive: library.recursive,
minimumSizeBytes: library.minimumSizeMB * 1024 * 1024,
);
discovered.addAll(files);
}
final unique = <String, MediaLibraryItem>{};
for (final file in discovered) {
unique[file.id] = MediaLibraryItem.fromFile(library.id, file);
}
final items = unique.values.toList()
..sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final allItems = _loadAllItems()
..removeWhere((item) => item.libraryID == library.id)
..addAll(items);
final updatedLibrary = library.copyWith(updatedAt: DateTime.now());
final libraries = state.libraries
.map((item) => item.id == library.id ? updatedLibrary : item)
.toList();
await _saveAllItems(allItems);
await _saveLibraries(libraries);
state = state.copyWith(
libraries: libraries,
items: items,
statusMessage: _cancelScan
? '扫描已停止,已保留 ${items.length} 个项目'
: '扫描完成:${items.length} 个视频文件',
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(
isScanning: false,
progress: const MediaLibraryScanProgress(),
);
}
}
void cancelScan() {
_cancelScan = true;
state = state.copyWith(
progress: const MediaLibraryScanProgress(phase: '正在停止扫描'),
);
}
void setSearchQuery(String query) {
state = state.copyWith(searchQuery: query);
}
void clearError() {
state = state.copyWith(clearError: true);
}
Future<List<CloudFile>> _scanSource(
String? rootID,
String rootPath, {
required bool recursive,
required int minimumSizeBytes,
}) async {
final folders = <_ScanFolder>[_ScanFolder(rootID, rootPath)];
final visited = <String>{};
final mediaFiles = <CloudFile>[];
while (folders.isNotEmpty && !_cancelScan) {
final folder = folders.removeAt(0);
final visitKey = folder.id ?? 'root';
if (!visited.add(visitKey)) continue;
var page = 0;
while (!_cancelScan) {
final response = await _api!.fsFiles(
parentID: folder.id,
page: page,
pageSize: 200,
orderBy: 0,
sortType: 0,
);
final files = _extractFiles(response);
for (final file in files) {
if (file.isDirectory) {
if (recursive) {
folders.add(_ScanFolder(file.id, '${folder.path}/${file.name}'));
}
} else if (file.isVideo && (file.size ?? 0) >= minimumSizeBytes) {
mediaFiles.add(_withPath(file, '${folder.path}/${file.name}'));
}
}
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: '扫描 ${folder.path}',
completed: mediaFiles.length,
total: folders.length + visited.length,
),
);
if (files.length < 200) break;
page += 1;
}
}
return mediaFiles;
}
CloudFile _withPath(CloudFile file, String path) {
return CloudFile(
id: file.id,
name: file.name,
isDirectory: file.isDirectory,
size: file.size,
gcid: file.gcid,
subDirectoryCount: file.subDirectoryCount,
subFileCount: file.subFileCount,
modifiedAt: file.modifiedAt,
cloudPath: path,
fileType: file.fileType,
);
}
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
final result = <CloudFile>[];
final seen = <String>{};
void appendList(List<dynamic> values) {
for (final value in values) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(value));
if (seen.add(file.id)) result.add(file);
} catch (_) {}
}
}
}
final preferred = _findArrayDeep(json, const [
'list',
'files',
'fileList',
'items',
'records',
'rows',
'resList',
'resourceList',
]);
if (preferred != null) {
appendList(preferred);
return result;
}
void visit(dynamic value) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(value));
if (seen.add(file.id)) result.add(file);
} catch (_) {}
for (final child in value.values) {
visit(child);
}
} else if (value is List) {
for (final child in value) {
visit(child);
}
}
}
visit(json);
return result;
}
List<MediaLibraryDefinition> _loadLibraries() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraries);
if (raw is! List) return [];
return raw
.whereType<Map>()
.map(
(item) =>
MediaLibraryDefinition.fromJson(Map<String, dynamic>.from(item)),
)
.toList();
}
List<MediaLibraryItem> _loadItems(String libraryID) {
return _loadAllItems()
.where((item) => item.libraryID == libraryID)
.toList();
}
List<MediaLibraryItem> _loadAllItems() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraryItems);
if (raw is! List) return [];
return raw
.whereType<Map>()
.map(
(item) => MediaLibraryItem.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.file.id.isNotEmpty)
.toList();
}
Future<void> _saveLibraries(List<MediaLibraryDefinition> libraries) {
return StorageManager.set(
StorageKeys.mediaLibraries,
libraries.map((library) => library.toJson()).toList(),
);
}
Future<void> _saveAllItems(List<MediaLibraryItem> items) {
return StorageManager.set(
StorageKeys.mediaLibraryItems,
items.map((item) => item.toJson()).toList(),
);
}
static List<dynamic>? _findArrayDeep(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value is List) return value;
}
const preferredKeys = ['data', 'result', 'payload'];
for (final key in preferredKeys) {
final value = json[key];
if (value is Map<String, dynamic>) {
final found = _findArrayDeep(value, keys);
if (found != null) return found;
}
}
for (final entry in json.entries) {
if (preferredKeys.contains(entry.key)) continue;
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _findArrayDeep(value, keys);
if (found != null) return found;
}
}
return null;
}
}
class _ScanFolder {
final String? id;
final String path;
const _ScanFolder(this.id, this.path);
}
final mediaLibraryProvider =
StateNotifierProvider<MediaLibraryNotifier, MediaLibraryState>(
(ref) => MediaLibraryNotifier(),
);
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/legacy.dart';
import '../core/storage/storage_manager.dart';
class ThemeState {
final ThemeMode themeMode;
const ThemeState({this.themeMode = ThemeMode.system});
ThemeState copyWith({ThemeMode? themeMode}) =>
ThemeState(themeMode: themeMode ?? this.themeMode);
}
class ThemeNotifier extends StateNotifier<ThemeState> {
ThemeNotifier() : super(const ThemeState()) {
_load();
}
void _load() {
final saved = StorageManager.get<String>(StorageKeys.themeMode);
if (saved == 'light') {
state = const ThemeState(themeMode: ThemeMode.light);
} else if (saved == 'dark') {
state = const ThemeState(themeMode: ThemeMode.dark);
} else {
state = const ThemeState();
}
}
Future<void> setThemeMode(ThemeMode mode) async {
state = state.copyWith(themeMode: mode);
final name =
mode == ThemeMode.light ? 'light' : mode == ThemeMode.dark ? 'dark' : 'system';
await StorageManager.set(StorageKeys.themeMode, name);
}
}
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeState>(
(ref) => ThemeNotifier(),
);
+19
View File
@@ -0,0 +1,19 @@
extension StringExtensions on String {
String truncate(int maxLen) {
if (length <= maxLen) return this;
return '${substring(0, maxLen)}';
}
}
extension IntExtensions on int {
String get formattedBytes {
if (this < 1024) return '$this B';
if (this < 1024 * 1024) {
return '${(this / 1024).toStringAsFixed(1)} KB';
}
if (this < 1024 * 1024 * 1024) {
return '${(this / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(this / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/cloud_file.dart';
class BreadcrumbBar extends StatelessWidget {
final List<CloudFile> path;
final ValueChanged<int> onNavigate;
const BreadcrumbBar({
super.key,
required this.path,
required this.onNavigate,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final items = <Widget>[
// Home button
GestureDetector(
onTap: () => onNavigate(-1),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: path.isEmpty ? cs.primary.withAlpha(15) : null,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'云盘',
style: TextStyle(
fontSize: 13,
fontWeight: path.isEmpty ? FontWeight.w600 : FontWeight.normal,
color: path.isEmpty ? cs.primary : cs.mutedForeground,
),
),
),
),
),
];
for (int i = 0; i < path.length; i++) {
final isLast = i == path.length - 1;
items.add(
Icon(
Icons.chevron_right_rounded,
size: 16,
color: cs.mutedForeground.withAlpha(100),
),
);
items.add(
GestureDetector(
onTap: isLast ? null : () => onNavigate(i),
child: MouseRegion(
cursor: isLast ? SystemMouseCursors.basic : SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isLast ? cs.primary.withAlpha(15) : null,
borderRadius: BorderRadius.circular(6),
),
child: Text(
path[i].name,
style: TextStyle(
fontSize: 13,
fontWeight: isLast ? FontWeight.w600 : FontWeight.normal,
color: isLast ? cs.primary : cs.mutedForeground,
),
),
),
),
),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: items),
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
Future<bool> showConfirmDialog(
BuildContext context, {
required String title,
required String content,
String confirmText = '确认',
String cancelText = '取消',
}) async {
final result = await showShadDialog<bool>(
context: context,
builder: (context) => ShadDialog(
title: Text(title),
description: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(content),
),
actions: [
ShadButton.outline(
child: Text(cancelText),
onPressed: () => Navigator.of(context).pop(false),
),
ShadButton(
child: Text(confirmText),
onPressed: () => Navigator.of(context).pop(true),
),
],
),
);
return result ?? false;
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/cloud_file.dart';
class FileIcon extends StatelessWidget {
final CloudFile file;
final double size;
const FileIcon({
super.key,
required this.file,
this.size = 28,
});
@override
Widget build(BuildContext context) {
if (file.isDirectory) {
return Icon(
LucideIcons.folder,
size: size,
color: const Color(0xFFF59E0B),
);
}
final iconData = _getIconForFileType(file.fileType);
final color = _getColorForFileType(file.fileType);
return Icon(
iconData,
size: size,
color: color,
);
}
IconData _getIconForFileType(int fileType) {
switch (fileType) {
case 1:
return LucideIcons.image;
case 2:
return LucideIcons.film;
case 3:
return LucideIcons.music;
case 4:
return LucideIcons.fileText;
case 5:
case 9:
return LucideIcons.archive;
default:
return LucideIcons.file;
}
}
Color _getColorForFileType(int fileType) {
switch (fileType) {
case 1:
return const Color(0xFF10B981);
case 2:
return const Color(0xFF3B82F6);
case 3:
return const Color(0xFFF59E0B);
case 4:
return const Color(0xFF8B5CF6);
case 5:
case 9:
return const Color(0xFF6B7280);
default:
return const Color(0xFF6B7280);
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/cloud_file.dart';
import 'file_icon.dart';
/// A single file row in the file list with context menu support.
class FileListTile extends StatelessWidget {
final CloudFile file;
final bool isSelected;
final VoidCallback? onSelect;
final VoidCallback? onOpen;
final VoidCallback? onRename;
final VoidCallback? onCopy;
final VoidCallback? onCut;
final VoidCallback? onDownload;
final VoidCallback? onShare;
final VoidCallback? onDelete;
const FileListTile({
super.key,
required this.file,
this.isSelected = false,
this.onSelect,
this.onOpen,
this.onRename,
this.onCopy,
this.onCut,
this.onDownload,
this.onShare,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final isDark = theme.brightness == Brightness.dark;
return ShadContextMenuRegion(
items: [
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.folderOpen, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onOpen,
child: const Text('打开'),
),
const ShadContextMenuItem.inset(
enabled: false,
child: Text('重命名'),
),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.copy, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onCopy,
child: const Text('复制'),
),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.scissors, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onCut,
child: const Text('剪切'),
),
const Divider(height: 8),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.download, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onDownload,
child: const Text('下载'),
),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.share2, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onShare,
child: const Text('分享'),
),
const Divider(height: 8),
ShadContextMenuItem.inset(
leading: Icon(LucideIcons.trash2, size: 16, color: theme.colorScheme.destructive),
trailing: Icon(LucideIcons.chevronRight, color: theme.colorScheme.destructive),
onPressed: onDelete,
child: Text(
'删除',
style: TextStyle(color: theme.colorScheme.destructive),
),
),
],
child: GestureDetector(
onTap: () => onSelect?.call(),
onDoubleTap: onOpen,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: isSelected
? (isDark
? theme.colorScheme.primary.withAlpha(30)
: theme.colorScheme.primary.withAlpha(15))
: (isDark
? Colors.white.withAlpha(3)
: Colors.black.withAlpha(2)),
border: Border(
bottom: BorderSide(
color: isDark
? Colors.white.withAlpha(10)
: Colors.black.withAlpha(8),
width: 0.5,
),
),
),
child: Row(
children: [
// Selection indicator
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
LucideIcons.checkCircle,
size: 18,
color: theme.colorScheme.primary,
),
),
// File icon
SizedBox(
width: 32,
height: 32,
child: FileIcon(file: file),
),
const SizedBox(width: 12),
// File name
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.foreground,
),
overflow: TextOverflow.ellipsis,
),
if (file.isDirectory && file.subFileCount != null)
Text(
'${file.subFileCount} 个项目',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
),
),
],
),
),
// File size
if (!file.isDirectory)
SizedBox(
width: 80,
child: Text(
file.formattedSize,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 12),
// Modified date
SizedBox(
width: 120,
child: Text(
file.modifiedAt,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
+302
View File
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/file_provider.dart';
import '../providers/auth_provider.dart';
import '../models/cloud_file.dart';
class SidePanel extends ConsumerWidget {
const SidePanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
return Container(
width: 280,
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(left: BorderSide(color: theme.colorScheme.border)),
),
child: Column(
children: [
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(LucideIcons.info, size: 18, color: theme.colorScheme.foreground),
const SizedBox(width: 8),
Text(
'详情',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
),
],
),
),
const ShadSeparator.horizontal(),
Expanded(child: _buildContent(context, ref)),
],
),
);
}
Widget _buildContent(BuildContext context, WidgetRef ref) {
final fileState = ref.watch(fileProvider);
final selected = fileState.files
.where((f) => fileState.selectedIDs.contains(f.id))
.toList();
if (selected.isEmpty) {
return _buildDefaultContent(context, ref);
}
if (selected.length == 1) {
return _buildSingleFileDetail(context, selected.first, ref);
}
return _buildMultiSelectDetail(context, selected, ref);
}
Widget _buildDefaultContent(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final fileState = ref.watch(fileProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(context, '账号信息'),
const SizedBox(height: 12),
_infoRow(context, '用户名', auth.userName),
_infoRow(context, '会员等级', auth.memberLevel),
_infoRow(context, '存储空间', auth.capacityText),
const SizedBox(height: 24),
if (fileState.section == WorkspaceSection.recycle) ...[
_sectionTitle(context, '回收站'),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ShadButton.outline(
onPressed: () {
showShadDialog(
context: context,
builder: (context) => ShadDialog(
title: const Text('清空回收站'),
description: const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text('确定要永久删除回收站中的所有文件吗?此操作不可恢复。'),
),
actions: [
ShadButton.outline(
child: const Text('取消'),
onPressed: () => Navigator.of(context).pop(),
),
ShadButton(
child: const Text('清空'),
onPressed: () {
ref.read(fileProvider.notifier).clearRecycleBin();
Navigator.of(context).pop();
},
),
],
),
);
},
leading: const Icon(LucideIcons.trash2, size: 16),
child: const Text('清空回收站'),
),
),
],
],
),
);
}
Widget _buildSingleFileDetail(
BuildContext context, CloudFile file, WidgetRef ref) {
final theme = ShadTheme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Icon(
file.isDirectory ? LucideIcons.folder : LucideIcons.file,
size: 64,
color: file.isDirectory
? const Color(0xFFF59E0B)
: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
file.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
_sectionTitle(context, '文件信息'),
const SizedBox(height: 12),
_infoRow(context, '类型', file.typeName),
if (!file.isDirectory) _infoRow(context, '大小', file.formattedSize),
if (file.modifiedAt.isNotEmpty)
_infoRow(context, '修改时间', file.modifiedAt),
const SizedBox(height: 24),
_sectionTitle(context, '操作'),
const SizedBox(height: 12),
_buildActionButtons(context, file, ref),
],
),
);
}
Widget _buildMultiSelectDetail(
BuildContext context, List<CloudFile> files, WidgetRef ref) {
final theme = ShadTheme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Icon(
LucideIcons.checkCircle,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'已选择 ${files.length} 个项目',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
),
],
),
),
const SizedBox(height: 24),
_sectionTitle(context, '批量操作'),
const SizedBox(height: 12),
_buildActionButtons(context, files.first, ref),
],
),
);
}
Widget _buildActionButtons(
BuildContext context, CloudFile file, WidgetRef ref) {
final theme = ShadTheme.of(context);
final fp = ref.read(fileProvider.notifier);
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (file.isDirectory)
_actionChip(
context,
icon: LucideIcons.folderOpen,
label: '打开',
onTap: () => fp.navigateToFolder(file),
),
_actionChip(
context,
icon: LucideIcons.copy,
label: '复制',
onTap: () => fp.copyToClipboard([file]),
),
_actionChip(
context,
icon: LucideIcons.scissors,
label: '剪切',
onTap: () => fp.cutToClipboard([file]),
),
_actionChip(
context,
icon: LucideIcons.download,
label: '下载',
onTap: () => fp.downloadFile(file),
),
_actionChip(
context,
icon: LucideIcons.share2,
label: '分享',
onTap: () {},
),
_actionChip(
context,
icon: LucideIcons.trash2,
label: '删除',
color: theme.colorScheme.destructive,
onTap: () => fp.deleteFiles([file]),
),
],
);
}
Widget _actionChip(
BuildContext context, {
required IconData icon,
required String label,
Color? color,
required VoidCallback onTap,
}) {
return ShadButton.outline(
onPressed: onTap,
leading: Icon(icon, size: 14, color: color),
child: Text(label, style: TextStyle(fontSize: 12, color: color)),
);
}
Widget _sectionTitle(BuildContext context, String title) {
final theme = ShadTheme.of(context);
return Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
);
}
Widget _infoRow(BuildContext context, String label, String value) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(fontSize: 13, color: theme.colorScheme.mutedForeground),
),
Flexible(
child: Text(
value,
style: TextStyle(fontSize: 13, color: theme.colorScheme.foreground),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/file_provider.dart';
class SortMenu extends StatelessWidget {
final FileSort currentSort;
final SortDirection currentDirection;
final ValueChanged<FileSort> onSortChanged;
const SortMenu({
super.key,
required this.currentSort,
required this.currentDirection,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadButton.ghost(
onPressed: () => _showSortMenu(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sort_rounded, size: 16, color: cs.mutedForeground),
const SizedBox(width: 4),
Text('排序', style: TextStyle(fontSize: 13, color: cs.mutedForeground)),
],
),
);
}
void _showSortMenu(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
showModalBottomSheet(
context: context,
builder: (ctx) => Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text('排序方式',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.foreground)),
const Spacer(),
Text('当前: ${currentSort.title}',
style: TextStyle(fontSize: 12, color: cs.mutedForeground)),
],
),
),
const ShadSeparator.horizontal(),
for (final sort in FileSort.values)
ListTile(
dense: true,
leading: currentSort == sort
? Icon(Icons.check_rounded, size: 16, color: cs.primary)
: const SizedBox(width: 16),
title: Text(
sort.title,
style: TextStyle(
fontSize: 14,
fontWeight: currentSort == sort ? FontWeight.w600 : FontWeight.normal,
color: currentSort == sort ? cs.primary : cs.foreground,
),
),
trailing: currentSort == sort
? Icon(
currentDirection == SortDirection.ascending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 14,
color: cs.primary,
)
: null,
onTap: () {
Navigator.of(ctx).pop();
onSortChanged(sort);
},
),
],
),
),
);
}
}